前言

今天看文章复现了一下 phpcms v9.6 的一个 sql 注入,觉得还有点意思 : )

代码审计

还是从 index.php 开始。

1
2
3
include PHPCMS_PATH.'/phpcms/base.php';

pc_base::creat_app();

在 /phpcms/base.php 中主要定义了一个与加载类、文件相关的 pc_base 类,上面可以看到在 index.php 的最后调用 pc_base 类的 create_app 方法。

01

先加载了类文件,然后实例化了 application 类,跟进该类。

02

在 application 类的构造函数中,实例化了 param 类,加载 param 类的方式同 application 类。param 是一个处理参数的类,跟进该类。

1
2
3
4
5
6
7
8
9
10
// /phpcms/libs/classes/param.class.php line 14
public function __construct() {
if(!get_magic_quotes_gpc()) {
$_POST = new_addslashes($_POST);
$_GET = new_addslashes($_GET);
$_REQUEST = new_addslashes($_REQUEST);
$_COOKIE = new_addslashes($_COOKIE);
}
...
}

这里调用了 new_addslashes 函数对 gpc 中的数据进行了转义。

1
2
3
4
5
function new_addslashes($string){
if(!is_array($string)) return addslashes($string);
foreach($string as $key => $val) $string[$key] = new_addslashes($val);
return $string;
}

到这里可以知道,该 cms 会对 gpc 的参数进行转义的处理,即引号是无法逃逸的,而该 cms 的 sql 语句中,大部分参数都是在引号内的,那为什么还会产生注入呢?

回到 application 类的构造函数中,接着调用了 param 类的 route_m()、route_c()、route_a() 这个几个函数去取模块、控制器和函数名,这里就是该 cms 的路由规则。

03

取到 m、c、a 后,调用了 application 类的 init 方法。

04

其中先调用了 load_controller 方法根据 m 和 c 去找到控制器类文件,实例化控制器后返回,接着在 init 方法中调用 call_user_func 去执行 a 指定的方法。

到这里就大致熟悉了该 cms 基本路由,在 /phpcms/modules/ 下有很多模块,通过 get 参数中 m 来指定模块,在每个模块下有许多类文件,通过 c 和 a 去调用指定类的指定方法。

接着我们直接定位到漏洞触发点,在 /phpcms/modules/content/down.php 文件的 dwon 类的 init 方法中。

05

init 方法中,先从 get 数组中取出了 a_k 变量,不要以为这里是从 get 直接取的就以为没有对值过滤或者转义,根据上面的分析,到这里时,gpc 数组中的数据已经被转义了,那为什么会有注入的,接着往下看。a_k 变量经过了 sys_auth 函数处理,由该函数第二个参数可以猜到,这应该是个解码解密类的函数,第三个参数是加解密的 key。

先继续看,sys_auth 函数处理完后,调用了 parse_str 注册变量。所以这里正常逻辑是,a_k 是个字符串,在某个地方加密后,在这里解密然后注册下面的 i、m、modelid、catid、f 等变量。看一下 parse_str 函数。

06

该函数接收的是 url 问号后面的参数。

07

可以看到,该函数会对传入的参数进行 url 解码,这样的话,可以在加密 a_k 的值时,传入 %27,到这里进行解密,并被 parse_str 注册到变量时,就会引入引号了。

回到 init 方法,通过调用 parser_str 函数,我们就可以控制下面其他变量的值了,其中的 id 变量会被带入数据库去查询,跟进下具体操作。

08

这里除了简单拼接外,并没有对 val 变量做其他处理。

整理一下,就是我们需要将类似m=2&modelid=3&catid=4&f=5&id=%27<payload>#这样的字符串,通过 sys_auth 函数进行加密,然后在这里传给 get 中的 a_k 变量,解密后就可以利用 payload 了。关于 modelid 变量,我们可以注释掉部分内容,然后断点调试到这里,看一下可用的值。

09

因为是在有源码的情况下,所以我直接将 payload 加密进行利用,验证该处是否存在漏洞。

10

在 parse_str 处下个断点,然后传入该 payload。

11

可以看到,解密后,id 的值中还原了单引号,这样就可以实现 sql 注入了。

12

可是,在这里,我直接运行了 sys_auth 函数对 payload 进行加密,那如果实际场景中,密钥和我本地不同的时候,payload 就无法利用了。所以我们需要找到一个可以输入某些值,并回显输入值经过 sys_auth 函数处理后的值的地方。

vscode 中搜索下 sys_auth 函数调用处。

13

在 set_cookie 函数中,会调用 sys_auth 函数对传入 set_cookie 函数中的第二个参数进行加密,搜索下 set_cookie 函数的调用处,发现在 /phpcms/modules/attachment/attachments.php 的 swfupload_json 方法中,调用了 set_cookie,且参数可控。

14

可以看到 get 数组中的 src 变量我们可控,该变量会经过 safe_replace 函数和 json_encode 函数处理,先看 json_encode 函数。上面分析时,payload 为m=2&modelid=3&catid=4&f=5&id=%27<payload>#,我们可以将其改成 get 中?src=&m=2&modelid=3&catid=4&f=5&id=%27<payload>#,因为 parse_str 解析是根据 & 来分割变量的。

15

但这里有 safe_replace 函数处理,会将 %27 替换为空,可以用双写 %2%277 来绕过,或者在中间添加一个 %27 后面过滤的字符,如 %2*7。

找到了可以加密 payload 的地方,即 attachments 类中的 swfupload_json 方法,但是在实例化 attachments 类时,有身份验证的操作。

1
2
3
4
5
6
7
8
9
10
11
class attachments {
function __construct() {
...
$this->userid = $_SESSION['userid'] ? $_SESSION['userid'] : (param::get_cookie('_userid') ? param::get_cookie('_userid') : sys_auth($_POST['userid_flash'],'DECODE'));
...
if(empty($this->userid)){
showmessage(L('please_login','','member'));
}
}
...
}

这里利用 cookie 中的值或者 post 的中 userid_flash 给属性 userid 赋值都是可以的,我以 post 中的 userid_flash 为例,我们还是要有一个类似上面的地方,可以将输出的数据通过 sys_auth 加密后回显,因为该处身份的验证使用 empty 函数,我们只要保证解密后有值即可。

这样的地方不止一处, /phpcms/modules/wap/index.php 中的 index 类的构造函数就符合要求。

1
2
3
4
5
6
7
8
class index {
function __construct() {
...
$this->siteid = isset($_GET['siteid']) && (intval($_GET['siteid']) > 0) ? intval(trim($_GET['siteid'])) : (param::get_cookie('siteid') ? param::get_cookie('siteid') : 1);
param::set_cookie('siteid',$this->siteid);
...
}
}

不过网站的 wap 模块默认是处于禁用状态的,但响应包中仍然可以拿到 sys_auth 处理后的值。在参考文章中还有一处也可以拿到合理加密值。

总结一下。

  • 在 /phpcms/modules/wap/index.php 中,get 传入 siteid =1,在响应包中拿到合理 sys_auth 函数加密值。
  • 在 /phpcms/modules/attachment/attachments.php 中,post 传入 userid_flash = [上一步加密值],通过 attachments 类的构造函数中的登录检测。get 传入 src = &m=2&modelid=3&catid=4&f=5&id=%2*7 and updatexml(1,concat(1,(database())),1)#,src 的值记得 url 编码。在响应包中拿到 payload 加密后的值。
  • 在 /phpcms/modules/content/down.php 中,get 传入 a_k = [上一步加密值]。

这样我们就实现了整个注入。在 phpcms 中,会将用户的 cookie 存储在数据库中,我们可以利用注入获取管理员的 cookie,然后登录管理员账号。先看一下数据库中相关信息。

16

注意这里的 userid 和 roleid,当管理员登录网站时,会将其修改为 1,跟进代码查看一下该逻辑。在后台登录界面,查看一下该表单的提交地址。

17

在 /phpcms/modules/admin/index.php 的 login 方法中,其中查询了登录用户的信息。

1
2
// line 59
$r = $this->db->get_one(array('username'=>$username));

执行的具体 sql 语句如下,从 v9_admin 表中取出了身份信息。

18

在 v9_admin 表中也有 userid 和 roleid 这两个字段,且都为 1。

19

回到 login 方法,将 userid 和 roleid 这两个值存进了 session 数组中。

1
2
3
// line 90
$_SESSION['userid'] = $r['userid'];
$_SESSION['roleid'] = $r['roleid'];

在前面提到的配置文件 /caches/configs/system.php 中定义了 session 的存储配置。

20

session 相关信息存放在数据库中,/phpcms/libs/classes/session_mysql.class.php 中的 write 方法如下。

21

这样就把 v9_session 中的 userid 和 roleid 的值修改成了 1,以记录该用户已经登录。

那如何注入出 cookie 用以伪造身份呢?在后台模块 /phpcms/modules/admin/ 下,所有类都继承自 /phpcms/modules/admin/classes/admin.class.php 中的后台基类 admin。其他类的构造函数中都调用了父类 admin 的构造函数,在 admin 类的构造函数中,调用了 check_admin 方法检查后台用户身份。

22

在 check_admin 方法中,从 cookie 中取出了 userid(带有前缀)对应的值,由上面知道在 get_cookie 中会调用 sys_auth 进行解密。然后从 session 中取出 userid 和 roleid,要满足 userid 和 roleid 都不为 0。根据上面分析,两个值都是 0 时,代表管理员没有登录,而管理员登录后,会将 v9_session 表中的两个值修改为 1。除此之外,get_cookie 取出的 userid 要和 session 中的相等,即也为 1。

我们可以利用前面的 wap 模块,对应文件 /phpcms/modules/wap/index.php,get 中传入 siteid = 1,会在响应包中拿到数字 1 经过 sys_auth 加密后的值。在将该值在伪造登录时传给 cookie 中的 userid(带有前缀),这样就可以通过上图的验证了。

23

漏洞补丁

对比 v9.6.1 中 /phpcms/modules/content/down.php 的 init 方法。

24

在 parse_str 前面调用了 safe_repalce 函数过滤,但如果 id 为 %2%2%2777,在加密时会调用 safe_replace 处理一次,在解密后,上图又会调用 safe_replace 处理一次,进入到 parse_str 时还是 %27,单引号也会逃逸。主要是 id 经过了下面的 intval 处理,这样就避免了该处的 sql 注入。

ref :

https://www.secpulse.com/archives/57486.html

https://mochazz.github.io/