前言
这题拖延了快半个月了 , 现在才彻底搞明白 . 刚做的时候虽然能拿到 RCE , 但 Bypass Open_basedir
不会 , Bypass disable_functions
也不会 , 总不能照抄大佬们的 Payload 吧 , 那样还有啥意义~
搞懂之后准备记录下过程 , 其中的坑有点多 , 看各个师傅做的如此轻松 , 我真是太菜了 !
环境准备
环境使用的是 glzjin/bytectf_2019_babyblog , 来源为 Github . 咱们 Clone 一下~
然后别急着 docker-compose
, 题目环境有点问题 , 需要修改一下~
打开 file/html/register.php
文件 , 看第 10 行
可看出是一个判断验证码是否正确的过程 , 从 POST 方法取到 verify
参数的值 , 然后对其 MD5 校验后再取其前5位 , 最后判断是否与 Session
中 verify
参数的值相同 .
那么 $_SESSION['verify']
的值是在哪里获取的呢 ? 看该文件第 38 行 .
使用 Mersenne Twister
算法返回随机整数 , 然后对其 MD5 校验并取其前 5 位 , 作为 $_SESSION['verify']
的值
那么 POST 方法传递的参数值在什么呢 ? 可以看 /template/register.html
文件第 14 行
POST 方法传递的实际上是用户输入的 $_SESSION['verify']
的值 .
那么问题就来了 , 我们从用户那里获取到 $_SESSION['verify']
的值( 假设用户输入正确 ) , 再对它进行 MD5 校验并截取前 5 位 , 肯定不会和 $_SESSION['verify']
本身相同啊 .
也就是说 , substr(md5($_SESSION['verify']) , 0 , 5)
肯定不等于 $_SESSION['verify']
, 因此在注册时无论你输入怎样的验证码 , 都不会通过验证 .
总的来说 , 这里需要修改 file/html/register.php
文件第 10 行为如下
这样才能确保当用户输入正确验证码时 , 可以注册成功 . 现在 , 你可以 docker-compose
了~
Babyblog
来看一看题目吧~
-
login.php
URL 键入
127.0.0.1:8302
, 即可看到登录页面 .可以登录或者注册 , 但现在咱没帐号 , 因此前往注册页面
-
Register.php
输入用户名 , 密码 以及 验证码 即可注册成功
若之前没有修改 register.php , 则现在会一直显示验证码错误
-
index.php
主页可以看到最近发布的帖子 , 并且可以修改或者删除已发布的帖子 . 同时 Writing 选项卡可以发布新的帖子 . 其他的选项卡都没啥用 .
-
edit.php
编辑帖子的页面 , 其中有个
replace
功能 , 但是需要 VIP 用户才可以使用 . -
writing.php
没啥好说的 , 正常的发布页面 .
仅看已有的页面 , 并没有发现哪里有明显的问题 , 实际上当我通过 BurpSuite 跑了一遍整个站点后 , 依旧没有找到有问题的地方 . 因此 , 必须要从其他地方入手 , 看一下是否存在什么其他线索 .
思路
dirsearch 拿到站点后台源码
一般遇到这种没有头绪的题目 , 拿 dirsearch
爆破一遍后台路径肯定是没错的 .
然后发现了 www
目录的压缩包 , 猜测里面有后台源码 , 下载到本地并且解压 .
然后就拿到了源码 . 准备审计 .
审计源码发现二次注入漏洞
简单的看下拿到个各个 PHP 文件
-
config.php
包含 与数据库连接 和 一个全局使用的安全过滤函数 , 这个安全过滤函数看得头疼 , 后面用到再说吧 .
-
about.php
判断当前用户的登录状态
-
delete.php
用于用户删除自己的帖子 , 包含两条数据库操作语句 , 但每条 SQL 语句的
id
参数都有intval()
函数做类型强制转换 , 看不到利用点 . -
edit.php
用于用户编辑自己已发布的帖子 , 包含三条数据库操作语句 , 但是 SQL 语句中的
$_POST['title']
,$_POST['$content']
参数有addslashes()
函数做敏感字符转义 ,$_POST['id']
参数有intval()
函数做类型强制转换 , 这些都看不到利用点 .$row['title']
参数是直接从SELECT
语句返回数据中取出的 , 没有进行任何过滤与转换 , 就直接插入到UPDATE
语句中了 , 这里可能存在二次注入漏洞 . -
index.php
输出当前用户的所有文章 , 包含一条数据库操作语句 , 但是参数是从 Session 中直接获取 , 而非用户可控 . 看不到利用点 .
-
login.php
用于用户登录 , 包含一条数据库操作语句 , 但
$_POST['username']
参数有addslashes()
函数做敏感字符转义 , 看不到利用点 . -
logout.php
销毁 Session , 用户等出 , 没有啥好说的 .
-
register.php
用于用户注册 , 包含三条数据库操作语句 , 但实际上只与
$_POST['username']
和$_POST['password']
两个参数有关 , 但$_POST['username']
参数有addslashes()
函数做敏感字符转义 ,$_POST['password']
参数有md5()
做加密校验 , 看不到利用点 . -
replace.php
这是 VIP 用户才能使用的功能 . 包含四条数据库操作语句 , 但是所用的参数都不可以利用 . 看似没有利用点 .
但该页面存在
preg_replace
函数 , 该函数的/e
任意代码执行的安全问题在测试时经常被使用 . 虽然现在还不知道 PHP 版本 , 但放在这就很可能存在利用点 . -
writing.php
用于用户发表新帖子 , 包含一条数据库操作语句 , 但两个字符串参数都有
addslashes()
函数做敏感字符转义 , 看不到利用点 .
看完了所有页面 , 总结一下 , 现在我们有两个可能的利用点 , 一个是 edit.php
处的二次注入 , 一个是 replace.php
处的 preg_replace /e
任意代码执行 .
但想要利用 preg_replace
, 当前用户就必须是 VIP 用户 , 但是在 register.php
中可以明确看到注册的用户默认都不是 VIP
因此我们必须拿到已存在的 VIP 用户或者将自己提升为 VIP 用户 , 这就要用到 SQL 注入了 , 也就是 edit.php
页面上的二次注入漏洞 .
这里从数据库取出 $row['title']
, 没有做任何过滤 , 就直接插入到 UPDATE
语句中 . 这样会出现什么问题呢 ? 看下面的 demo
经过 addslashes()
函数的敏感字符转义 , 存入数据库的 title
值为 'a
, 取出数据库的 title
值也为 'a
, 这看似没啥问题 . 但是如果我们直接将 'a
再次放入到 SQL 语句中时 , " '
" 是不会被转义的 , 这很可能引发 SQL 注入 .
注出数据库相关信息
现在思路有了
-
在
writing.php
页面注入包含 Payload 的恶意title
值 -
在
edit.php
页面利用SELECT
语句读取恶意的title
值 , 然后直接将它插入到UPDATE
语句中 . -
因为取出的
$row['title']
是放在WHERE
语句中做条件判断的 , 因此我们可以利用如下性质 .该性质可以用于布尔注入 , 当我们指定的条件为 " 1 " 时 , 可以 UPDATE 成功 , 若条件为 " 0 " , 则不能 UPDATE 成功
-
请求
edit.php
页面 , 查看之前是否 UPDATE 成功若页面中的
title
值为恶意的title
值 , 即UPDATE
语句没有成功执行 , 则我们指定的条件是错误的 , 若页面中title
值变为了UPDATE
语句中的title
值 , 则代表UPDATE
语句成功执行 , 我们指定的条件是正确的 .
有了思路就可以构造脚本了 , 但是之前 config.php
中将 and
和 or
都过滤了 , 我们如何拼接 Payload 到 WHERE 语句中呢 ?
可以利用 Mysql 中的异或运算符 , 拼接 WHERE
语句的条件 .
Payload 中仅需要有一个条件判断就可以了 , 比如 ASCII(SUBSTR((...),1,1)) > x
这样的经典格式 . 完整的 Payload 如下所示
Payload : 1'^(ascii(substr((select(( ... ),1,1))>1)^'1
构造一个 Python 脚本 , 包含上述的整个流程 .( 我写的脚本很乱 , 在先知社区上看到 W&M 的 WriteUp 中构造的脚本 , 思路非常清晰 . 这里就是修改自他们的脚本 )
然后我们需要写个循环 , 不断调用 http_get()
函数来判断何时 Payload 为 " 1 " . 这里使用二分法 , 以便快速找到边界值 .
然后是一些基本信息 , 比如 username
, cookie
之类的~
接着需要调用二分法函数 half()
, 并且指定最终的 Payload 的值 . 比如我们想要拿到 当前数据库的版本信息 , 就可以构造如下函数 .
最后在主函数中调用 get_version()
函数就可以了
执行该脚本 , 即可拿到当前数据库版本信息
不用管数据库的版本信息是否完全输出 , 这不是重点 , 我们仅需要判断脚本是否构造正确就可以了~ 我们可以利用该脚本拿到很多信息
-
读取当前数据库
Payload :
select database()
-
读取 babyblog 库中所有的表
Payload :
select(group_concat(table_name)) from information_schema.tables where table_schema = 'babyblog'
注意 , 这里
SELECT (...) FROM (...)
在config.php
中被过滤了 , 但我们可以利用SELECT(...) FROM (...)
绕过限制 . -
读取 user 表中的所有字段
Payload :
select(group_concat(column_name)) from information_schema.columns where table_name = 'users' and table_schema = 'babyblog'
-
读取 babyblog.user 表中的所有信息
Payload :
select(group_concat(id,0x3a,username,0x3a,isvip)) from(babyblog.users)
password 是一串 MD5 , 就不输出了
可以看到当前数据库里只有一个用户 , 即为刚才注册的用户 , 并且
isvip
字段值为 0 .
提升当前用户为 VIP
我们拿到了足够的信息 , 而且现在的目标很明确 , 即通过 SQL 注入将当前用户提升为 VIP 用户
虽然 UPDATE
相关语句在 config.php
中都被过滤了 , 无法直接构造 Payload , 但依旧可以利用 堆叠注入 + 预处理语句 + Hex编码
绕过正则限制 .
代码不难 , 构造好的函数如下所示
执行后即可提升 epicccal
用户为 VIP 用户
当前用户已经被提升为 VIP 用户 .
Preg_replace /e 任意代码执行
现在我们是 VIP 用户了 , 就可以进入 replace.php
页面并利用 Preg_replace
函数了
从 replace.php
函数可以看到 , 只有当开启了 regex
功能后 , 才能利用 preg_replace
. 现在可以掏出 BurpSuite
了
利用该函数的方法就不多说了 , 老问题了 . Payload 如下 .
Payload : find=.*/e%00&replace=phpinfo();®ex=1&id=xxxx
然后跳转页面 , 会发现 phpinfo()
被执行了 .
%00
截断是一个比较老的字符串截断方式了 , 需要满足 1 : PHP 版本小于 5.3.4 , 2 : PHP 没有开启 magic_quotes_gpc()
既然能执行代码 , 那肯定先搞个 WebShell 上去 .
Payload : file_put_contents('/var/www/html/webshell.php','<?php eval($_POST[cmd]);?>');
然后问题就来了 , 当你想要遍历目录时 , 会发现 AntSword 提示 : Path Not Or Not Permission
, 当你用终端输入任何命令时会显示 : ret 127
.
这是为啥呢 ? 回到 phpinfo()
界面 , 会发现目标主机同时开启了 open_basedir
和 disable_functions
因此 , 接下来的任务就是 Bypass Open_basedir
和 Bypass Disable_functions
!
Glob:// Bypass Open_basedir
因为当前版本为 PHP 5.3+
, 所以可以利用 glob://
伪协议来 Bypass Open_basedir
这里的内容可以参考 浅谈几种Bypass open_basedir的方法 .
glob:// 伪协议用于查找匹配的路径文件格式 , 它是 PHP 5.3.0 开始生效的一个用于筛选目录的伪协议 .
写个 demo
仅使用 glob://
伪协议是无法 Bypass Open_basedir
的 , 必须配合其它函数 .
-
DirectoryIterator + glob://
DirectoryIterator
是 PHP5 中增加的一个类 , 为用户提供一个简单的查看目录的接口 .利用
DirectoryIterator + glob://
的组合可以无视 Open_basedir , 直接读取某个目录下的文件 . -
opendir() + readdir() + glob://
opendir()
函数可以打开目录句柄 ,readdir()
函数可以从目录句柄中读取条目 .利用这两个函数搭配
glob://
也可以无视 Open_basedir , 直接读取某个目录下的文件 .
利用 glob://
的方法应该是目前最方便的方法 , 它不用去暴力猜解目录 , 而是直接列举文件 , 非常高效 .
当然也存在其他的 Bypass Open_basedir
的方法 , 读者可以自行学习~
LD_PRELOAD + error_log() Bypass disable_functions
具体的原理我已经写在这篇文章里了 : Bypass disable_functions
直接构造恶意脚本
然后将恶意脚本上传到目标服务器上
接着在 preg_replace
的 RCE 处通过 putenv()
设置环境变量并通过 error_log()
函数触发恶意脚本 .
Payload : eval('putenv("LD_PRELOAD=/var/www/html/payload.so");error_log("",1,"","");');
注意 , 这里 putenv()
和 error_log()
必须写在一个 eval() 函数中才能生效
此时在目标服务器的 Web 根目录下会生成 result.php , 其中包含读取到的 Flag .
成功读取到 Flag !
总结
这道题现在看起来不难了 , 我从中学到了很多东西 , 还有部分细节问题值得再研究一下~
poc.py
文章中使用的 poc.py
完整脚本的链接如下 :
可以讲一下那个preg_replace为何要构造./*e%00
这个其实是利用 “preg_replace /e的任意代码执行” 和 “PHP5.3.4 %00截断漏洞” 的组合.
1. preg_replace(/pattern/e , replacement , string) 会将 string 中匹配 pattern 的内容替换为 replacement , 并将匹配后的内容作为PHP代码执行.
2. PHP5.3.4 截断漏洞会截断 %00 后的内容
放在本题中, find 参数为
.*/e%00
, 那么拼接到代码中即为/.*/e%00
, 同时截断后面的"/"
. 此时匹配的为 string 的全部内容 , 相当于把 string 的全部内容替换为 “phpinfo()” 字符串 , 并作为 PHP 代码执行.因此,这里相当于直接执行
eval(phpinfo())
函数 , 所以可以达到命令执行的效果.