Logo root@nayyyr:~/blog#
corCTF 2023 Writeups
Table of Contents

Intro

Despite the fact that I couldn’t solve more than 5 real challenges during corCTF last year either, it’s one of my favorite CTF events, and I played again for this edition, albeit mostly alone. The event was just as hard, if not harder, than last year, and while I didn’t have enough time to solve some of the challenges I wanted to, I thought I’d at least writeup the relatively easy challenges that I did do. Got 78th out of ~600+ teams though, which is pretty funny considering I only solved maybe 3 challenges during the event.

force

Web - 118 solves

Description

Unbreakable vault door!

Author: larry

Challenge

We’re given source code for this challenge, and it’s a very, very minimal web application.

import fastify from 'fastify'
import mercurius from 'mercurius'
import { randomInt } from 'crypto'
import { readFile } from 'fs/promises'
 
const app = fastify({
    logger: true
});
const index = await readFile('./index.html', 'utf-8');
 
const secret = randomInt(0, 10 ** 5); // 1 in a 100k??
 
let requests = 10;
 
setInterval(() => requests = 10, 60000);
 
await app.register(mercurius, {
    schema: `type Query {
        flag(pin: Int): String
    }`,
    resolvers: {
        Query: {
            flag: (_, { pin }) => {
                if (pin != secret) {
                    return 'Wrong!';
                }
                return process.env.FLAG || 'corctf{test}';
            }
        }
    },
    routes: false
});
 
app.get('/', (req, res) => {
    return res.header('Content-Type', 'text/html').send(index);
});
 
app.post('/', async (req, res) => {
    if (requests <= 0) {
        return res.send('no u')
    }
    requests --;
    return res.graphql(req.body);
});
 
app.listen({ host: '0.0.0.0', port: 80 });

The task is fairly simple. When spawned, the server will choose a random number from 0 to 100,000. Using GraphQL, a user is able to submit a request using the flag() query, and if the numbers match up, it will print the flag. However, you’re only allowed 10 requests, meaning if you pick 10 distinct numbers, that is about a 0.01% chance of happening.

We can open the app up to get a better idea of what this looks like. Pasted_image_20230801220235.png

If I submit this request as is, we’re told we have the wrong answer.

Pasted_image_20230801220316.png

Solution

If you don’t know, GraphQL is an open-source querying language for APIs that is not intrinsically tied to any one database or storage engine, the motivation for this being the ability to be highly specific about what data you want to pull. In a more traditional REST API, you have set structures in place to query resources, which is nice because of how easy it is, but not efficient. For instance, a developer might set up an endpoint like http://example.com/api/v1/users/<ID> to access information about a user by ID. However, what if you just want the email of that user, or just the registration date? This might not seem like a big problem, but when your user list becomes insanely large, optimization will become necessary.

In GraphQL, we could define our API with certain types and structures, and create a query like so:

query CurrentUser {
	currentUser {
		name
		email
	}
}

Now, anytime we call that query, we get the exact data back. When I see GraphQL, my first instinct is to approach it like I would an SQL database. GraphQL features introspection, a technique that allows you to use GraphQL queries to get more information about the database schema, and John Hammond shows this off a lot better in a video on an HTB Business CTF Challenge. However, it won’t be very useful here. I can submit the payload from PayloadsAllTheThings, and then use GraphQLVoyager to visualize this data, but all it returns is a single box.

Pasted_image_20230801221207.png

If we think about the actual function of GraphQL here, it’s not actually storing any data. Although we’re allowed to submit arbitrary GraphQL requests to this server there is only one defined query in the source code. flag is the only defined query, and when we submit that with a pin, the server receives that number, and compares it with what was stored in memory. TL;DR: There’s no way to leak the number out like this.

However, GraphQL was created as a way to hone in API requests, so surely there’s a way to make multiple requests in one go? GraphQL actually has batching, meaning we can make multiple queries in a single request to the backend. We can try this with two just to confirm.

kali@transistor:~/ctf/corctf-23/web_force$ curl -X POST 'https://web-force-force-9676b1ba97b0af6d.be.ax/' -H 'Content-Type: text/plain' -d '{req1: flag(pin: 31337) req2: flag(pin: 420)}' | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    87  100    42  100    45    168    180 --:--:-- --:--:-- --:--:--   349
{
  "data": {
    "req1": "Wrong!",
    "req2": "Wrong!"
  }
}

Nice! Given that there are 100,000 values we need to try, I’ll submit them 10,000 at a time so that we don’t overload the server.

import hashlib
import requests as r
import sys
 
URL = sys.argv[1]
 
def create_payload(lbound, rbound):
    payload = {}
    for i in range(lbound, rbound):
        payload["a" + hashlib.md5(str(i).encode()).hexdigest()] = f"flag(pin: {i})"
    return payload
 
for i in range(1,11):
    p = str(create_payload((i-1) * 10000, (i)*10000))
    payload = p.replace("', ", "\n").replace("'", "")
    res = r.post(URL, data=payload, headers={"Content-Type":"text/plain;charset=UTF-8"})
    if 'corctf' in res.text:
        #print("DEBUG: " + res.text)
        index = res.text.find("corctf")
        print(res.text[index:index+100])
        break
else:
    print("womp womp, you failed")

Running the script, we get our flag:

kali@transistor:~/ctf/corctf-23/web_force$ python3 solve.py https://web-force-force-9676b1ba97b0af6d.be.ax/
corctf{S                T                  O               N                   K                 S}"

flag: corctf{S T O N K S}

This flag is definitely going to break the blog but it’s fine (Update: Looks like the whitespace is getting truncated, trust the longer one from the shell command).

fizzbuzz100

Crypto - 134 solves

Description

lsb oracles are pretty overdone... anyway here's fizzbuzz

Author: willwam

Challenge

We’re given source code to a remote server.

#!/usr/local/bin/python
from Crypto.Util.number import *
from os import urandom
 
flag = open("flag.txt", "rb").read()
flag = bytes_to_long(urandom(16) + flag + urandom(16))
 
p = getPrime(512)
q = getPrime(512)
n = p * q
e = 0x10001
d = pow(e, -1, (p-1)*(q-1))
assert flag < n
ct = pow(flag, e, n)
 
print(f"{n = }")
print(f"{e = }")
print(f"{ct = }")
 
while True:
    ct = int(input("> "))
    pt = pow(ct, d, n)
    out = ""
    if pt == flag:
        exit(-1)
    if pt % 3 == 0:
        out += "Fizz"
    if pt % 5 == 0:
        out += "Buzz"
    if not out:
        out = pt
    print(out)

This was the first of a series of three challenges that built on the idea of FizzBuzz, a classic coding interview question and game that kids in the UK play (I guess they have nothing better to there). This challenge starts out with a pretty standard RSA implementation to encrypt the flag, this time adding random bytes to the beginning and end of the flag to prevent unintended solutions in the later versions. We’re then given access to a decryption oracle, with a twist. We can give it any RSA ciphertext as an integer, and it will use the private key to decrypt it. However, if it’s the original flag, the program will exit. Otherwise, if the plaintext is a multiple of 3, the oracle will print “Fizz”, a multiple of 5 will print “Buzz”, and a multiple of 15 will print “FizzBuzz”. If none of these conditions are met, we’re given the computed plaintext.

Solution

Like last year’s corCTF, the easiest crypto challenge is a simple case of modular arithmetic. We may not be able to directly submit the ciphertext, but we can always modify it. We know that the ciphertext we receive is cme(modn)c \equiv m^e \pmod{n}. Now suppose we submit the ciphertext c2ec(modn)c' \equiv 2^e * c \pmod{n}. Then, the decryption oracle works out like this:

m(c)d(2ec)d(2eme)d2m(modn)\begin{aligned} m' \equiv (c')^d \equiv (2^e * c)^d \equiv (2^e * m^e)^d \equiv 2m \pmod{n} \end{aligned}

We can easily compute the modular inverse of 22 mod nn, so we can multiply that by the output of the decryption oracle to get the flag.

#!/usr/bin/env python3
from pwn import *
from Crypto.Util.number import long_to_bytes
 
context.log_level = "debug"
 
def start(argv=[], *a, **kw):
    if args.REMOTE:  # ('server', 'port')
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    else:  # Run locally
        return process(["python3", "fizzbuzz100.py"])
    
io = start()
n = int(io.recvlineS().split('=')[1].strip())
e = int(io.recvlineS().split('=')[1].strip())
ct = int(io.recvlineS().split('=')[1].strip())
 
io.sendlineafter(">", str((pow(2,e) * ct)%n))
m_prime = int(io.recvlineS().strip())
 
print(long_to_bytes((m_prime * pow(2, -1, n))%n))
kali@transistor:~/ctf/corctf-23/crypto_fizzbuzz100$ python3 solve.py REMOTE be.ax 31100
[+] Opening connection to be.ax on port 31100: Done
[...trim...]
b'\xbf\x82\x1e}\xa9\xef\x1f~\xd0\xbdF\xa8a\xa5_\xe4corctf{h4ng_0n_th15_1s_3v3n_34s13r_th4n_4n_LSB_0r4cl3...4nyw4y_1snt_f1zzbuzz_s0_fun}\xa79x\xac\xd7O\xe4\x931.hBz\x8fo\xa1'

flag: corctf{h4ng_0n_th15_1s_3v3n_34s13r_th4n_4n_LSB_0r4cl3...4nyw4y_1snt_f1zzbuzz_s0_fun}

cbc

Crypto - 59+1* solves

* I solved this immediately after the CTF was over because I just got too busy with life on Sunday, so I’m going to retroactively give myself the solve (they’re fake internet points anyway)

Description

who on earth is putting CLASSICAL BORING CRYPTOGRAPHY in my ctf

Author: willwam

Challenge

We’re given the output of the below Python program.

import random
 
def random_alphastring(size):
    return "".join(random.choices(alphabet, k=size))
 
def add_key(key, block):
    ct_idxs = [(k_a + pt_a) % len(alphabet) for k_a, pt_a in zip([alphabet.index(k) for k in key], [alphabet.index(pt) for pt in block])]
    return "".join([alphabet[idx] for idx in ct_idxs])
 
def cbc(key, plaintext):
    klen = len(key)
    plaintext = pad(klen, plaintext)
    iv = random_alphastring(klen)
    blocks = [plaintext[i:i+klen] for i in range(0, len(plaintext), klen)]
    prev_block = iv
    ciphertext = ""
    for block in blocks:
        block = add_key(prev_block, block)
        prev_block = add_key(key, block)
        ciphertext += prev_block
    return iv, ciphertext
    
def pad(block_size, plaintext):
    plaintext += "X" * (-len(plaintext) % block_size)
    return plaintext
 
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
bs = 16
 
message = open("message.txt").read().upper()
message = "".join([char for char in message if char in alphabet])
flag = open("flag.txt").read()
flag = flag.lstrip("corctf{").rstrip("}")
message += flag
assert all([char in alphabet for char in message])
 
key = random_alphastring(bs)
iv, ct = cbc(key, pad(bs, message))
print(f"{iv = }")
print(f"{ct = }")

This code might seem a bit complicated with all of the list comprehensions, but the encryption scheme is very simple. Going from top to bottom, the random_alphastring function will generate a random string of uppercase English characters with a specified length. The add_key function is where the encrypting happens: for each character in the key and the plaintext block, we take the index of the letters, and add them together modulo the length of the alphabet. In this case, A = 0, B = 1, C = 2, etc. So, if we did add_key("C", "Z"), that would work out to be (2+25)(mod26)1(2 + 25) \pmod{26} \equiv 1, and 11 corresponds to “B”. The TL;DR here is that this is basically just a Caesar Cipher, except there is a different shift per character.

The interesting part is the cbc() function. In Three-Eyed Oracle, I briefly discussed various block cipher modes, and this function is effectively applying Cipher Block Chaining (CBC) mode to this add_key() encryption. Normally, CBC looks like this:

cbc Courtesy: Wikipedia

The basic idea, in its normal use, is that every plaintext is XOR-ed with the previous ciphertext block, and then run through the block cipher. For the first message block, we use a random set of bytes called an Initialization Vector (IV), which helps add to the overall randomness of the encryption. Here, however, the algorithm has been slightly modified.

Pasted_image_20230802105835.png

Instead of XORs, we’re just applying the add_key() function again. The only other thing that hasn’t been mentioned here is that the plaintext is padded out to a 16 byte boundary using X’s as seen in the pad() function (e.g. pad("ABCDEF") = "ABCDEFXXXXXXXXXX").

Solution

The big problem with this script is that the add_key() function is fully reversible. In the add_key() function, I can just do the reverse of k_a + pt_a, which is pt_a - k_a, and get back to the original value.

def remove_key(key, block):
    ct_idxs = [(pt_a - k_a) % len(alphabet) for k_a, pt_a in zip([alphabet.index(k) for k in key], [alphabet.index(pt) for pt in block])]
    return "".join([alphabet[idx] for idx in ct_idxs])
>>> def add_key(key, block):
...     ct_idxs = [(k_a + pt_a) % len(alphabet) for k_a, pt_a in zip([alphabet.index(k) for k in key], [alphabet.index(pt) for pt in block])]
...     return "".join([alphabet[idx] for idx in ct_idxs])
...
>>> def remove_key(key, block):
...     ct_idxs = [(pt_a - k_a) % len(alphabet) for k_a, pt_a in zip([alphabet.index(k) for k in key], [alphabet.index(pt) for pt in block])]
...     return "".join([alphabet[idx] for idx in ct_idxs])
...
>>> alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
>>> bs = 16
>>> iv = 'RLNZXWHLULXRLTNP'
>>> add_key(iv, "YELLOWSUBMARINEX")
'PPYKLSZFVXXITGRM'
>>> remove_key(iv, add_key(iv, "YELLOWSUBMARINEX"))
'YELLOWSUBMARINEX'

This is a good first step; we can easily decrypt any ciphertext knowing the key. That said, we still don’t know the key. However, we might not even need the key. Consider the general way to decrypt a block cipher using CBC.

cbc Courtesy: Wikipedia

Unlike CBC encryption, which can only be done one block at a time because it needs to be chained with the previous ciphertext block, CBC decryption can be done in parallel, as we already know all of the ciphertext. In our case, this means mi=remove_key(k,remove_key(ci1,ci))m_i = \texttt{remove\_key}(k,\texttt{remove\_key}(c_{i-1}, c_i)). Observe that we can actually compute that inner remove_key() function, leaving it at a single key being applied to each block: mi=remove_key(k,ci)m_i = \texttt{remove\_key}(k, c'_i). If this was AES, we’d be stuck, but because the algorithm is basically just a Caesar/Vigenere/Shift cipher, we can crack this using frequency analysis. If you’re interested in learning exactly how frequency analysis works here, I recommend looking at Cryptopals Challenge 6, as they do this with XOR. However, writing your own algorithm to score the “English-ness” of candidate plaintexts can be a bit tedious and error prone, so it’s much easier to use someone else’s implementation like this online Vigenere cipher tool.

In summary, to solve this challenge, we can compute remove_key(ci1,ci)\texttt{remove\_key}(c_{i-1}, c_i) for every ciphertext block, and then put the resulting “unchained ciphertext” into the Vigenere cipher solver, which should give us the key. The tool I linked didn’t apply the key it found to the whole plaintext, so I did that part in Python to get the output.

def remove_key(key, block):
    ct_idxs = [(pt_a - k_a) % len(alphabet) for k_a, pt_a in zip([alphabet.index(k) for k in key], [alphabet.index(pt) for pt in block])]
    return "".join([alphabet[idx] for idx in ct_idxs])
 
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
bs = 16
iv = 'RLNZXWHLULXRLTNP'
ct = '[long ciphertext]'
 
ct_blocks = [ct[i:i+bs] for i in range(0, len(ct), bs)]
 
unchained = [remove_key(chain, ct) for chain,ct in zip([iv] + ct_blocks[:-1], ct_blocks)]
print("PASTE THIS INTO https://www.boxentriq.com/code-breaking/vigenere-cipher#tool")
print("".join(unchained))
 
key = "acxqtstcsxzwfczy".upper()
 
plaintext = []
for chain,ct in zip([iv] + ct_blocks[:-1], ct_blocks):
    block = remove_key(chain, remove_key(key, ct))
    plaintext.append(block)
 
pt = "".join(plaintext)
print(pt+"\n")
print(f"corctf{{{pt[-47:]}}}")
kali@transistor:~/ctf/corctf-23/crypto_cbc$ python3 solve.py
PASTE THIS INTO https://www.boxentriq.com/code-breaking/vigenere-cipher#tool
IFGKLLEKCBSKNPSCRLBSMXHTSJNIJPSUHCQOHMKGJBEAWKMETQXIEAGWPFRESHZATIKKEAGWPLQWXKUCRGZUGLEALXJASVNAANIYGYBVYKTLQWRJIPRNEAGWPFRJTVZLORBHTLBPYPXOYGLSNVLYMKXNXYTPWCSFETXDHLAGJCQAJENKPQKUGLHHSCTHQAESNEQYHFBPYDMQXARREOJQWWNUWCTHGASFEIKKVGKGDFAOXJDJLWQYEAMKWPZJIXHRANPOLLXOULLLTPDLTUZEFHKKKFMCFHTJLQPQLVXHAKDZGAOMSKUCTFREGJOQYGQSSGOIKMGCELCEKKDBVGOIBGGQXQGALPTQYUQUFWOGJVCWDYHRHQRJKWTNAWHJLKSRHTLKZZTRWZTHNCQRUTKEYWOGFQRPMGUCRUFEGGYIFRVDNEGGSYFTXDRWKBCPTFZWIULVMWGESIKAINHLUZXDWETPQLEEYUTQETPQKWGQLXVWWGSFAVFJBKUCKFBWQNXRHGDDNKRULBLZJXDJORBTUQMJWDMQUTNHEEQJAWKGJBZHQAHQANFDNPTPVQGAXGOCORIUTJXWKFMCNVASTKQYLBNULXOWWVNDTJBIRKMGEQGADWRCLKKKQALVZBJAWPDJTJBFKGZTSJHJYJDQYUQUFLACLXKHTEZREUQXXETEZFMAXTDQOWOSXKMQLEDKYJDPPTLWKSFULEZPDQTPUPQXXCXTFBKEXCMCSUBDMATNHXQPTHZLORBHTLBPYPXOYGLZUVRIXDXUKYXEYUDJFKQSTFHPDVEQSESGOPFDMZXEGKSACVNDAELCIDXVWLOAWCSGNIPOLLXODFMQCKRLOTJQEDRWKBCESENKBKKQMAHPOFSDYJDENWLFXJTVAKFODUSCMVEUPZHNWPXOYGLGSDXIBUTNDVFJZYHRHNFDNPTFVBCKWIMSLKKKQSENLEDOTEZJLGABBFNZVFRPWKASTKLDLSKGJBZHQACGSVOYUMMKGKRKKIMSLKKKQSGAOXXDJTDAOOBIMZXHDXFEYUDTETVJAAGISCSAWVGGSCQBXSLVAQRJTVZEEPBHBUKQLQGEWVDCNEEQEDXPYBHCZGRQ
IDJUSTLIKETOINTERJECTFORAMOMENTWHATYOUREREFERINGTOASLINUXISINFACTGNULINUXORASIVERECENTLYTAKENTOCALLINGITGNUPLUSLINUXLINUXISNOTANOPERATINGSYSTEMUNTOITSELFBUTRATHERANOTHERFREECOMPONENTOFAFULLYFUNCTIONINGGNUSYSTEMMADEUSEFULBYTHEGNUCORELIBSSHELLUTILITIESANDVITALSYSTEMCOMPONENTSCOMPRISINGAFULLOSASDEFINEDBYPOSIXMANYCOMPUTERUSERSRUNAMODIFIEDVERSIONOFTHEGNUSYSTEMEVERYDAYWITHOUTREALIZINGITTHROUGHAPECULIARTURNOFEVENTSTHEVERSIONOFGNUWHICHISWIDELYUSEDTODAYISOFTENCALLEDLINUXANDMANYOFITSUSERSARENOTAWARETHATITISBASICALLYTHEGNUSYSTEMDEVELOPEDBYTHEGNUPROJECTTHEREREALLYISALINUXANDTHESEPEOPLEAREUSINGITBUTITISJUSTAPARTOFTHESYSTEMTHEYUSELINUXISTHEKERNELTHEPROGRAMINTHESYSTEMTHATALLOCATESTHEMACHINESRESOURCESTOTHEOTHERPROGRAMSTHATYOURUNTHEKERNELISANESSENTIALPARTOFANOPERATINGSYSTEMBUTUSELESSBYITSELFITCANONLYFUNCTIONINTHECONTEXTOFACOMPLETEOPERATINGSYSTEMLINUXISNORMALLYUSEDINCOMBINATIONWITHTHEGNUOPERATINGSYSTEMTHEWHOLESYSTEMISBASICALLYGNUWITHLINUXADDEDORGNULINUXALLTHESOCALLEDLINUXDISTRIBUTIONSAREREALLYDISTRIBUTIONSOFGNULINUXANYWAYHERECOMESTHEFLAGITSEVERYTHINGAFTERTHISATLEASTITSNOTAGENERICROTTHIRTEENCHALLENGEIGUESS
 
corctf{ATLEASTITSNOTAGENERICROTTHIRTEENCHALLENGEIGUESS}

Fun plaintext.

flag: corctf{ATLEASTITSNOTAGENERICROTTHIRTEENCHALLENGEIGUESS}

utterly-deranged

Rev - 60 solves

Description

Can you untangle my twisted ~~CFG~~ web of lies?

Author: clubby789

Challenge

The binary is a seemingly innocent ELF executable. It’s stripped, which is annoying, but luckily PIE is disabled, meaning functions will always load into the same spots, making reversing a bit easier.

kali@transistor:~/ctf/corctf-23/rev_utterly_deranged$ file utterlyderanged
utterlyderanged: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=4dd902c197fc570da057095fccfc8e90581b6895, for GNU/Linux 3.2.0, stripped
kali@transistor:~/ctf/corctf-23/rev_utterly_deranged$ checksec --file utterlyderanged
[*] '/home/kali/ctf/corctf-23/rev_utterly_deranged/utterlyderanged'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

We don’t get much from strings, except what seems to be some messages that the program uses and a fake flag.

kali@transistor:~/ctf/corctf-23/rev_utterly_deranged$ strings utterlyderanged
<...trim>
Abandon hope all ye who decompile here
Well done for peeling back the layers
Better luck next time :(
p\P~
corctf{notflag}!
<trim...>

If we run it, we’re given a pretty basic “crackme”-style prompt, albeit with a weird warning. We can also confirm that the fake flag is indeed fake.

kali@transistor:~/ctf/corctf-23/rev_utterly_deranged$ ./utterlyderanged
Abandon hope all ye who decompile here
: corctf{notflag}
Better luck next time :(
kali@transistor:~/ctf/corctf-23/rev_utterly_deranged$ ./utterlyderanged
Abandon hope all ye who decompile here
: corctf{notflag}!
Better luck next time :(

I can also try to run strace and ltrace, but nothing much comes of that either.

Solution

Unintended

Most people, including myself, solved it much differently than the author intended. I’ll start by walking through how I originally approached this, and then, with knowledge of the challenge post-event, explain why the unintended works and why deobfuscating the binary is pain.

My go-to program for decompilation is Ghidra, and the warning immediately makes sense. After clicking around the identified functions to try and find the entry point or main, I found a function that refused to decompile.

Pasted_image_20230802120501.png

If I look at the disassembly in the center window, it’s clear something’s suspicious. Rather than see a variety of instructions moving values and registers around, I see a repeating pattern for a very long time.

                             LAB_004011ba                                      
        004011ba 49 89 e4        MOV        R12,RSP
        004011bd 49 83 c4 f0     ADD        R12,-0x10
        004011c1 4c 89 e4        MOV        RSP,R12
        004011c4 48 89 e0        MOV        RAX,RSP
        004011c7 48 83 c0 f0     ADD        RAX,-0x10
        004011cb 48 89 c4        MOV        RSP,RAX
        004011ce 48 c1 e8 3f     SHR        RAX,0x3f
        004011d2 48 a9 01        TEST       RAX,0x1
                 00 00 00
        004011d8 0f 85 74        JNZ        LAB_00416952
                 57 01 00
        004011de 49 89 e5        MOV        R13,RSP
        004011e1 49 83 c5 f0     ADD        R13,-0x10
        004011e5 4c 89 ec        MOV        RSP,R13
        004011e8 48 89 e0        MOV        RAX,RSP
        004011eb 48 83 c0 f0     ADD        RAX,-0x10
        004011ef 48 89 c4        MOV        RSP,RAX
        004011f2 48 c1 e8 3f     SHR        RAX,0x3f
        004011f6 48 a9 01        TEST       RAX,0x1
                 00 00 00
        004011fc 0f 85 50        JNZ        LAB_00416952
                 57 01 00
        00401202 49 89 e6        MOV        R14,RSP
        00401205 49 83 c6 f0     ADD        R14,-0x10
        00401209 4c 89 f4        MOV        RSP,R14
        0040120c 48 89 e0        MOV        RAX,RSP
        0040120f 48 83 c0 f0     ADD        RAX,-0x10

It looks like we’re only going to be looking at assembly for this challenge. If I don’t have access to a decompiler, my preferred disassembly program is Rizin’s Cutter, a program that used to just be a frontend GUI for radare2, but has now evolved into its own ecosystem of tools.

Cutter is actually able to find the main function itself, and pretty easily produce a control flow graph (CFG), things that Ghidra isn’t as good at.

9c2c6499-a3ff-4bdb-8821-2ed0b30b5649.gif

Oh. That’s what they meant in the description. The craziest part about this is that the gif doesn’t even do the CFG justice, it just keeps going. If we scroll through the disassembly of main, we find some code that resembles what we saw when running the program for the first time

0x00403ab5      mov     edi, str.Abandon_hope_all_ye_who_decompile_here ; 0x417004 ; const char *s
0x00403aba      call    puts       ; sym.imp.puts ; int puts(const char *s)
0x00403abf      mov     rax, rsp
0x00403ac2      add     rax, 0xfffffffffffffff0
0x00403ac6      mov     rsp, rax
0x00403ac9      shr     rax, 0x3f
0x00403acd      test    rax, 1     ; 1
0x00403ad3      jne     0x416952
0x00403ad9      xorps   xmm0, xmm0
0x00403adc      mov     rax, qword [s1]
0x00403ae3      movaps  xmmword [rax + 0x70], xmm0
0x00403ae7      movaps  xmmword [rax + 0x60], xmm0
0x00403aeb      movaps  xmmword [rax + 0x50], xmm0
0x00403aef      movaps  xmmword [rax + 0x40], xmm0
0x00403af3      movaps  xmmword [rax + 0x30], xmm0
0x00403af7      movaps  xmmword [rax + 0x20], xmm0
0x00403afb      movaps  xmmword [rax + 0x10], xmm0
0x00403aff      movaps  xmmword [rax], xmm0
0x00403b02      mov     rax, rsp
0x00403b05      add     rax, 0xfffffffffffffff0
0x00403b09      mov     rsp, rax
0x00403b0c      shr     rax, 0x3f
0x00403b10      test    rax, 1     ; 1
0x00403b16      jne     0x416952
0x00403b1c      movaps  xmm0, xmmword data.004170e0 ; 0x4170e0
0x00403b23      mov     rax, qword [s2]
0x00403b2a      movaps  xmmword [rax + 0x70], xmm0
0x00403b2e      movaps  xmm0, xmmword data.004170d0 ; 0x4170d0
0x00403b35      movaps  xmmword [rax + 0x60], xmm0
0x00403b39      movaps  xmm0, xmmword data.004170c0 ; 0x4170c0
0x00403b40      movaps  xmmword [rax + 0x50], xmm0
0x00403b44      movaps  xmm0, xmmword data.004170b0 ; 0x4170b0
0x00403b4b      movaps  xmmword [rax + 0x40], xmm0
0x00403b4f      movaps  xmm0, xmmword data.004170a0 ; 0x4170a0
0x00403b56      movaps  xmmword [rax + 0x30], xmm0
0x00403b5a      movaps  xmm0, xmmword data.00417090 ; 0x417090
0x00403b61      movaps  xmmword [rax + 0x20], xmm0
0x00403b65      movaps  xmm0, xmmword data.00417080 ; 0x417080
0x00403b6c      movaps  xmmword [rax + 0x10], xmm0
0x00403b70      movaps  xmm0, xmmword data.00417070 ; 0x417070
0x00403b77      movaps  xmmword [rax], xmm0

From a high level, after printing the “Abandon hope all ye who decompile here”, we zero out ~128 bytes of memory at a specified address, and then move data stored at 0x417070 into that buffer. If locate that address, it looks like random bytes, which could very likely be some kind of encrypted text.

Pasted_image_20230802141801.png

Trying to guess some basic XOR keys didn’t work, so we’ll have to keep looking at the program. This is where the unintended path begins. During the CTF, I wasn’t really able to gather much from the obfuscated assembly, aside from the fact that it was doing basically nothing. I assumed that the real instructions were interspersed throughout the obfuscated/garbage assembly, so trying to actually deobfuscate the program would be a challenge. However, if we go to the end of the program, we see a very important function call.

0x00416869      mov     rdi, qword [s1] ; const void *s1
0x00416870      mov     rsi, qword [s2] ; const void *s2
0x00416877      mov     edx, 0x80  ; 128 ; size_t n
0x0041687c      call    memcmp     ; sym.imp.memcmp ; int memcmp(const void *s1, const void *s2, size_t n)
0x00416881      mov     rcx, rsp
0x00416884      add     rcx, 0xfffffffffffffff0
0x00416888      mov     rsp, rcx
0x0041688b      shr     rcx, 0x3f
0x0041688f      xor     cl, 0xff   ; 255
0x00416892      test    cl, 1      ; 1
0x00416895      jne     0x41689c
0x00416897      jmp     0x416952
0x0041689c      test    eax, eax
0x0041689e      sete    al
0x004168a1      mov     rcx, rsp
0x004168a4      add     rcx, 0xfffffffffffffff0
0x004168a8      mov     rsp, rcx
0x004168ab      shr     rcx, 0x3f
0x004168af      xor     cl, 0xff   ; 255
0x004168b2      test    cl, 1      ; 1
0x004168b5      jne     0x4168bc
0x004168b7      jmp     0x416952
0x004168bc      test    al, 1      ; 1
0x004168be      jne     0x4168c2
0x004168c0      jmp     0x4168e4
0x004168c2      mov     edi, str.Well_done_for_peeling_back_the_layers ; 0x41702e ; const char *s
0x004168c7      call    puts       ; sym.imp.puts ; int puts(const char *s)
0x004168cc      mov     rax, rsp
0x004168cf      add     rax, 0xfffffffffffffff0
0x004168d3      mov     rsp, rax
0x004168d6      shr     rax, 0x3f
0x004168da      xor     al, 0xff   ; 255
0x004168dc      test    al, 1      ; 1
0x004168de      jne     0x4168e2
0x004168e0      jmp     0x416952
0x004168e2      jmp     0x416906
0x004168e4      mov     edi, str.Better_luck_next_time_: ; 0x417054 ; const char *s
0x004168e9      call    puts       ; sym.imp.puts ; int puts(const char *s)
0x004168ee      mov     rax, rsp

memcmp() will just compare the bytes located at two blocks of memory up to a specific length. If the memcmp() returns true, then “Well done for peeling back the layers” is printed, otherwise, we get the “Better luck next time” message. If we open the program in GDB, we can actually see exactly what’s being compared. I’ll submit a random flag that’s 128 bytes long, since that’s the length parameter being passed into memcmp().

kali@transistor:~/ctf/corctf-23/rev_utterly_deranged/test$ gdb-pwndbg utterlyderanged
Reading symbols from utterlyderanged...
(No debugging symbols found in utterlyderanged)
pwndbg: loaded 141 pwndbg commands and 48 shell commands. Type pwndbg [--shell | --all] [filter] for a list.
pwndbg: created $rebase, $ida GDB functions (can be used with print/break)
------- tip of the day (disable with set show-tips off) -------
Use Pwndbgs config and theme commands to tune its configuration and theme colors!
pwndbg> b *0x0041687c
Breakpoint 1 at 0x41687c
pwndbg> r
Starting program: /home/kali/ctf/corctf-23/rev_utterly_deranged/test/utterlyderanged
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Abandon hope all ye who decompile here
: corctf{AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA}
 
Breakpoint 1, 0x000000000041687c in ?? ()
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
[ REGISTERS / show-flags off / show-compact-regs off ]─────────────────────────────────
 RAX  0x0
*RBX  0x7fffffffd2c0 ◂— 0x7fff0000000d /* '\r' */
 RCX  0x0
*RDX  0x80
*RDI  0x7fffffffb3a0 ◂— 0xe8e807ae5ce963f3
*RSI  0x7fffffffb310 ◂— 0xdae807ae5ce963f3
 R8   0x0
 R9   0x0
*R10  0x7ffff7de00f8 ◂— 0x10001a00001033
*R11  0x7ffff7f1efe0 (__strchr_avx2) ◂— vmovd xmm0, esi
*R12  0x7fffffffd320 ◂— 0xc9b6361c
*R13  0x7fffffffd300 ◂— 9 /* '\t' */
*R14  0x7fffffffd2e0 ◂— 0x7fffa20b92bd
*R15  0x7fffffffd340 ◂— 0x7fff00000007
*RBP  0x7fffffffdb00 ◂— 0x1
*RSP  0x7ffffff9d8e0 ◂— 0x0
*RIP  0x41687c ◂— call 0x401060
[ DISASM / x86-64 / set emulate on ]────────────────────────────────────────────────────
 0x41687c    call   memcmp@plt                      <memcmp@plt>
        s1: 0x7fffffffb3a0 ◂— 0xe8e807ae5ce963f3
        s2: 0x7fffffffb310 ◂— 0xdae807ae5ce963f3
        n: 0x80
 
   0x416881    mov    rcx, rsp
   0x416884    add    rcx, -0x10
   0x416888    mov    rsp, rcx
   0x41688b    shr    rcx, 0x3f
   0x41688f    xor    cl, 0xff
   0x416892    test   cl, 1
   0x416895    jne    0x41689c                      <0x41689c>
 
   0x416897    jmp    0x416952                      <0x416952>
 
   0x41689c    test   eax, eax
   0x41689e    sete   al
[ STACK ]───────────────────────────────────────────────────────────────────────────────
00:0000│ rsp 0x7ffffff9d8e0 ◂— 0x0
...        7 skipped
[ BACKTRACE ]───────────────────────────────────────────────────────────────────────────
 f 0         0x41687c
   f 1   0x7ffff7df118a __libc_start_call_main+122
   f 2   0x7ffff7df1245 __libc_start_main+133
   f 3         0x4010aa

In the DISASM part of the output, we can see the addresses of the two blocks being compared, 0x7fffffffb3a0 and 0x7fffffffb310. Note that these values are stored in the RDI and RSI, respectively. If we inspect memory at both of those points, it seems like one of these buffers is the random bytes we saw from earlier, and the other is what seems to be our input, but encrypted.

pwndbg> x/128bx 0x7fffffffb3a0
0x7fffffffb3a0: 0xf3    0x63    0xe9    0x5c    0xae    0x07    0xe8    0xe8
0x7fffffffb3a8: 0x02    0x2e    0x20    0x51    0x96    0x98    0x45    0x1f
0x7fffffffb3b0: 0x0c    0xd2    0xd5    0x25    0x97    0x47    0x3f    0x35
0x7fffffffb3b8: 0xb9    0x01    0x9a    0x98    0x6e    0xda    0x63    0x19
0x7fffffffb3c0: 0xab    0x99    0xe4    0x11    0x76    0x9f    0x27    0xc0
0x7fffffffb3c8: 0x38    0xe9    0x3e    0x2d    0xd2    0xd2    0x6b    0x5f
0x7fffffffb3d0: 0x1a    0x5f    0x96    0x34    0xc2    0x57    0x27    0xdf
0x7fffffffb3d8: 0x3f    0x4d    0x5e    0xe1    0x30    0x7b    0xd5    0x79
0x7fffffffb3e0: 0x49    0xb7    0x54    0xf5    0xa0    0x2e    0x45    0xb3
0x7fffffffb3e8: 0x57    0xdc    0xbc    0x4a    0xe4    0xc6    0x35    0x11
0x7fffffffb3f0: 0x9e    0x58    0x1c    0x2d    0xdf    0x6e    0xa0    0x2c
0x7fffffffb3f8: 0xdf    0xf0    0x9c    0x19    0x29    0xb3    0x44    0xfc
0x7fffffffb400: 0x47    0x6d    0x56    0xdc    0x11    0x3f    0x94    0xa8
0x7fffffffb408: 0xbc    0xc8    0xbb    0x7d    0x69    0xae    0x7c    0xf9
0x7fffffffb410: 0x48    0xb0    0x12    0x38    0xf5    0xe0    0xf6    0x69
0x7fffffffb418: 0x91    0xc9    0xc1    0x37    0x15    0xe0    0xac    0x1b
pwndbg> x/128bx 0x7fffffffb310
0x7fffffffb310: 0xf3    0x63    0xe9    0x5c    0xae    0x07    0xe8    0xda
0x7fffffffb318: 0x70    0x5c    0x50    0x7e    0xb0    0x86    0x70    0x36
0x7fffffffb320: 0x3f    0xe6    0xcb    0x09    0xaf    0x59    0x4e    0x04
0x7fffffffb328: 0xcc    0x31    0xae    0xea    0x70    0xeb    0x50    0x6b
0x7fffffffb330: 0x8e    0xe9    0xc6    0x64    0x43    0xed    0x53    0xfc
0x7fffffffb338: 0x79    0xa8    0x7f    0x6c    0x93    0x93    0x2a    0x1e
0x7fffffffb340: 0x5b    0x1e    0xd7    0x75    0x83    0x16    0x66    0x9e
0x7fffffffb348: 0x7e    0x0c    0x1f    0xa0    0x71    0x3a    0x94    0x38
0x7fffffffb350: 0x08    0xf6    0x15    0xb4    0xe1    0x6f    0x04    0xf2
0x7fffffffb358: 0x16    0x9d    0xfd    0x0b    0xa5    0x87    0x74    0x50
0x7fffffffb360: 0xdf    0x19    0x5d    0x6c    0x9e    0x2f    0xe1    0x6d
0x7fffffffb368: 0x9e    0xb1    0xdd    0x58    0x68    0xf2    0x05    0xbd
0x7fffffffb370: 0x06    0x2c    0x17    0x9d    0x50    0x7e    0xd5    0xe9
0x7fffffffb378: 0xfd    0x89    0xfa    0x3c    0x28    0xef    0x3d    0xb8
0x7fffffffb380: 0x09    0xf1    0x53    0x79    0xb4    0xa1    0xb7    0x28
0x7fffffffb388: 0xd0    0x88    0x80    0x76    0x54    0xa1    0xed    0x1b

See how the bytes in the second buffer exactly line up with what we saw in the hexdump earlier? As for the first buffer, notice that the first 7 bytes and the last byte match the second buffer. Those bytes in our plaintext are corctf{ and then the }. This likely means that there’s some kind of XOR cipher at play here. Unfortunately, we already tried doing some guessing earlier and couldn’t find the key. In many cases, we wouldn’t be able to progress much further here. However, notice what happens when I submit corctf{BAAAAAA...} instead of corctf{AAAAAAA...}.

pwndbg> x/128bx 0x7fffffffb3a0
0x7fffffffb3a0: 0xf3    0x63    0xe9    0x5c    0xae    0x07    0xe8    0xeb
0x7fffffffb3a8: 0x02    0x2e    0x20    0x51    0x96    0x98    0x45    0x1f
0x7fffffffb3b0: 0x0c    0xd2    0xd5    0x25    0x97    0x47    0x3f    0x35
0x7fffffffb3b8: 0xb9    0x01    0x9a    0x98    0x6e    0xda    0x63    0x19
0x7fffffffb3c0: 0xab    0x99    0xe4    0x11    0x76    0x9f    0x27    0xc0
0x7fffffffb3c8: 0x38    0xe9    0x3e    0x2d    0xd2    0xd2    0x6b    0x5f
0x7fffffffb3d0: 0x1a    0x5f    0x96    0x34    0xc2    0x57    0x27    0xdf
0x7fffffffb3d8: 0x3f    0x4d    0x5e    0xe1    0x30    0x7b    0xd5    0x79
0x7fffffffb3e0: 0x49    0xb7    0x54    0xf5    0xa0    0x2e    0x45    0xb3
0x7fffffffb3e8: 0x57    0xdc    0xbc    0x4a    0xe4    0xc6    0x35    0x11
0x7fffffffb3f0: 0x9e    0x58    0x1c    0x2d    0xdf    0x6e    0xa0    0x2c
0x7fffffffb3f8: 0xdf    0xf0    0x9c    0x19    0x29    0xb3    0x44    0xfc
0x7fffffffb400: 0x47    0x6d    0x56    0xdc    0x11    0x3f    0x94    0xa8
0x7fffffffb408: 0xbc    0xc8    0xbb    0x7d    0x69    0xae    0x7c    0xf9
0x7fffffffb410: 0x48    0xb0    0x12    0x38    0xf5    0xe0    0xf6    0x69
0x7fffffffb418: 0x91    0xc9    0xc1    0x37    0x15    0xe0    0xac    0x1b
pwndbg> x/128bx 0x7fffffffb310
0x7fffffffb310: 0xf3    0x63    0xe9    0x5c    0xae    0x07    0xe8    0xda
0x7fffffffb318: 0x70    0x5c    0x50    0x7e    0xb0    0x86    0x70    0x36
0x7fffffffb320: 0x3f    0xe6    0xcb    0x09    0xaf    0x59    0x4e    0x04
0x7fffffffb328: 0xcc    0x31    0xae    0xea    0x70    0xeb    0x50    0x6b
0x7fffffffb330: 0x8e    0xe9    0xc6    0x64    0x43    0xed    0x53    0xfc
0x7fffffffb338: 0x79    0xa8    0x7f    0x6c    0x93    0x93    0x2a    0x1e
0x7fffffffb340: 0x5b    0x1e    0xd7    0x75    0x83    0x16    0x66    0x9e
0x7fffffffb348: 0x7e    0x0c    0x1f    0xa0    0x71    0x3a    0x94    0x38
0x7fffffffb350: 0x08    0xf6    0x15    0xb4    0xe1    0x6f    0x04    0xf2
0x7fffffffb358: 0x16    0x9d    0xfd    0x0b    0xa5    0x87    0x74    0x50
0x7fffffffb360: 0xdf    0x19    0x5d    0x6c    0x9e    0x2f    0xe1    0x6d
0x7fffffffb368: 0x9e    0xb1    0xdd    0x58    0x68    0xf2    0x05    0xbd
0x7fffffffb370: 0x06    0x2c    0x17    0x9d    0x50    0x7e    0xd5    0xe9
0x7fffffffb378: 0xfd    0x89    0xfa    0x3c    0x28    0xef    0x3d    0xb8
0x7fffffffb380: 0x09    0xf1    0x53    0x79    0xb4    0xa1    0xb7    0x28
0x7fffffffb388: 0xd0    0x88    0x80    0x76    0x54    0xa1    0xed    0x1b

By changing one character, only one byte in our ciphertext changed (look at the 8th byte, the top right of the first buffer)! Knowing this, we can write a script to repeat this process that we’re doing to gradually brute force each character of the flag. I know other people solved this using angr, but I found it easier to use gdb’s Python scripting capabilities. This video from SloppyJoePirates helped me get a better grasp on some of the syntax used to script this stuff, but my final GDB script looks like this.

import gdb
 
gdb.execute("break *0x0041687c")
 
known_prefix = "corctf"
flag_len = 128
i = 0
while len(known_prefix) < flag_len:
    if '}' in known_prefix:
        print(known_prefix)
        break
    
    for char in range(0x21, 0x7e + 1):
        test_prefix = known_prefix + chr(char)
        test_prefix += "x" * (128 - len(test_prefix)) + "\n"
 
        with open("attempt.txt", "w") as fd:
            fd.write(test_prefix)
 
        #print(chr(char), test_prefix)
        gdb.execute("set args")
        gdb.execute("run < attempt.txt >/dev/null")
        
        frame = gdb.selected_frame()
        rdi = hex(frame.read_register("rdi"))
        rsi = hex(frame.read_register("rsi"))
 
        # guess one, rdi
        guess = gdb.execute(f"x/1bx ({rdi} + {hex(len(known_prefix))})", to_string=True)
        # good one, rsi
        good = gdb.execute(f"x/1bx ({rsi} + {hex(len(known_prefix))})", to_string=True)
 
        guess_hex = guess.split(':')[1].strip()
        good_hex = good.split(':')[1].strip()
        print(chr(char), test_prefix, good_hex, guess_hex)
 
        if guess_hex == good_hex:
            known_prefix += chr(char)
            break
    else:
        print("womp womp")
        raise Exception

The script honestly isn’t as complicated as I thought it would be. We can run gdb commands using gdb.execute(), the to_string parameter can be used to save the output which can be parsed later, and there’s some additional work you can do to read the registers. There’s definitely more documentation out there for this.

To run the script, we simply enter the GDB prompt, and then run source solve.py.

kali@transistor:~/ctf/corctf-23/rev_utterly_deranged/test$ gdb-pwndbg utterlyderanged
Reading symbols from utterlyderanged...
(No debugging symbols found in utterlyderanged)
pwndbg: loaded 141 pwndbg commands and 48 shell commands. Type pwndbg [--shell | --all] [filter] for a list.
pwndbg: created $rebase, $ida GDB functions (can be used with print/break)
------- tip of the day (disable with set show-tips off) -------
Pwndbg mirrors some of Windbg commands like eq, ew, ed, eb, es, dq, dw, dd, db, ds for writing and reading memory
pwndbg> source solve.py
Breakpoint 1 at 0x41687c
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
 
Breakpoint 1, 0x000000000041687c in ?? ()
! corctf!xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
 0xe8 0xb2
<trim...>

flag: corctf{s331ng_thru_my_0p4qu3_pr3d1c4t35}

Failing to Deobfuscate

This was a funny interaction after everyone telling clubby about the unintended. Pasted_image_20230802144650.png

After the CTF was over, clubby shared what the source code was, mentioning that most of the effort went into modifying the compiler to obfuscate the code.

#include "salsa20/salsa20.h"
#include <stdint.h>
#include <stdio.h>
#include <string.h>
 
#define BUFSIZE 128
 
int main(int argc, char **argv) {
  uint8_t key[16] = {0};
  uint8_t nonce[8] = {0};
 
  puts("Abandon hope all ye who decompile here");
  uint8_t pt[BUFSIZE] = {0};
  const char ct[BUFSIZE] = {
      0xf3, 0x63, 0xe9, 0x5c, 0xae, 0x07, 0xe8, 0xda, 0x70, 0x5c, 0x50, 0x7e,
      0xb0, 0x86, 0x70, 0x36, 0x3f, 0xe6, 0xcb, 0x09, 0xaf, 0x59, 0x4e, 0x04,
      0xcc, 0x31, 0xae, 0xea, 0x70, 0xeb, 0x50, 0x6b, 0x8e, 0xe9, 0xc6, 0x64,
      0x43, 0xed, 0x53, 0xfc, 0x79, 0xa8, 0x7f, 0x6c, 0x93, 0x93, 0x2a, 0x1e,
      0x5b, 0x1e, 0xd7, 0x75, 0x83, 0x16, 0x66, 0x9e, 0x7e, 0x0c, 0x1f, 0xa0,
      0x71, 0x3a, 0x94, 0x38, 0x08, 0xf6, 0x15, 0xb4, 0xe1, 0x6f, 0x04, 0xf2,
      0x16, 0x9d, 0xfd, 0x0b, 0xa5, 0x87, 0x74, 0x50, 0xdf, 0x19, 0x5d, 0x6c,
      0x9e, 0x2f, 0xe1, 0x6d, 0x9e, 0xb1, 0xdd, 0x58, 0x68, 0xf2, 0x05, 0xbd,
      0x06, 0x2c, 0x17, 0x9d, 0x50, 0x7e, 0xd5, 0xe9, 0xfd, 0x89, 0xfa, 0x3c,
      0x28, 0xef, 0x3d, 0xb8, 0x09, 0xf1, 0x53, 0x79, 0xb4, 0xa1, 0xb7, 0x28,
      0xd0, 0x88, 0x80, 0x76, 0x54, 0xa1, 0xed, 0x1b,
  };
  printf(": ");
  fgets((char *)pt, BUFSIZE, stdin);
  char *end;
  if ((end = strchr((char *)pt, '\n'))) {
    *end = 0;
  }
 
  s20_crypt(key, S20_KEYLEN_128, nonce, 0, pt, BUFSIZE);
  if (memcmp(pt, ct, BUFSIZE) == 0) {
    puts("Well done for peeling back the layers");
  } else {
    puts("Better luck next time :(");
  }
 
}

Surprisingly, in my analysis, I managed to identify most of these things. What I originally thought was a simple XOR cipher was actually a stream cipher (Salsa20), and is integral to why the unintended works. On this blog, we’ve mentioned AES a few times, which is basically the block cipher that everyone uses today. A block cipher is exactly what it sounds like, a cipher that works on blocks, more specifically, consistently sized groups of bytes. AES will encrypt the first 16 bytes, then the next 16, and so on until it’s done. In addition to this, pretty much every strong block cipher is designed so that a small change in the block will produce a completely different ciphertext. This concept goes by a lot of names, the Avalanche effect, confusion and diffusion, etc. but the core idea is not wanting to leak anything from the result of minor changes.

>>> from Crypto.Cipher import AES
>>> from os import urandom
>>> key = urandom(16)
>>> pt1 = b'A'*16
>>> pt2 = b'A'*15 + b'B'
>>> cipher = AES.new(key, AES.MODE_ECB)
>>> cipher.encrypt(pt1).hex()
'89317e985e052b9f9a67b27e916e67d1'
>>> cipher.encrypt(pt2).hex()
'dcdfee0e41d5b83dce1595903d1473ff'

Secure stream ciphers also incorporate these principles, but they apply them differently. Stream ciphers work by using the key to generate a series of random bytes and then proceeding to combine that keystream with the plaintext, most commonly via XOR. It’s effectively a weaker, but more practical one-time pad. Salsa20 is one of the more popular stream ciphers today, and while it’s a more recently published cipher (published in 2007 compared to 1998 for AES), Salsa is not considered weak.

Looking back at the source code, it is definitely a bad move to make the key and nonce an array of nothing. However, this isn’t the crux of the problem. Since Salsa’s randomness is coming from the way the keystream is generated, and not the actual manipulation of the plaintext, no matter how the key or nonce was set, the encryption can be simplified to XOR with an unknown key, which we can easily bruteforce.

In an alternate world where clubby understands how Salsa20 works, the intended is as brutal as it sounds: deobfuscate the binary. Even though clubby confirmed that there was compiler-level obfuscation, we already suspected such a thing from the assembly. Let’s take a closer look at an example of these unnecessary instructions.

0x00403d41      mov     rax, rsp
0x00403d44      add     rax, 0xfffffffffffffff0
0x00403d48      mov     rsp, rax
0x00403d4b      shr     rax, 0x3f
0x00403d4f      xor     al, 0xff   ; 255
0x00403d51      test    al, 1      ; 1
0x00403d53      jne     0x403d5a
0x00403d55      jmp     0x416952
0x00403d5a      mov     rax, rsp
0x00403d5d      add     rax, 0xfffffffffffffff0
0x00403d61      mov     rsp, rax
0x00403d64      shr     rax, 0x3f
0x00403d68      xor     al, 0xff   ; 255
0x00403d6a      test    al, 1      ; 1
0x00403d6c      jne     0x403d73
0x00403d6e      jmp     0x416952
0x00403d73      mov     rax, rsp

The reason these instructions are useless is because they’re blatantly redundant. After moving the value of RSP into RAX, we add a huge hex value to it, move that value back, do a right bitshift of 0x3f bits, and simply test if the lowest 8 bits are equal to 1. If it’s not, continue with the execution of the program, otherwise, exit. The program is simply guaranteed to operate as normal, but is forced to run through all of these instructions.

If we reverse engineer the actual challenge by looking at the flag, these bits of assembly are called opaque predicates. A blog post from Binary Ninja (CTF sponsors, who would have guessed), defines them as “a series of instructions which always evaluate to the same boolean result”. The impact on performance is rather minimal, but, as we’ve seen, they’re very effective at making static analysis tools like Cutter or Ghidra waste time analyzing code that doesn’t really matter. Doing some more searching on the topic, there is actually a good amount of academic literature on the subject, like this paper or this thesis.

Now, the Binary Ninja blog only highlights their tool, which is expensive and inaccessible to me, and most of the academic papers don’t really provide concrete deobfuscators. Unfortunately, I wasn’t able to find a neat and clean way to deobfuscate the binary without breaking many other things. I’ve spent at least 6-8 hours outside of the CTF to try and upsolve this without abusing the memcmp(), but I truly haven’t found a solution, and am looking forward to reading anyone’s solution that manages to get through this mess of a binary.

touch-grass

misc - 89 solves (how???)

Description

Vitamin D deficiency and complications from a sedentary lifestyle are plaguing the CTF community.

To help change this, the esteemed CoR tribunal has decided that touching grass in sunlight is a vital part of this CTF experience.

Trying to trick this system will result in your team being placed on a hall of SHAME - the entire world will know that you don't touch grass!

Authors: strellic, FizzBuzz101, 0x5a

Challenge

We’re presented with a website, but this is not a web challenge. After signing in with rCTF, we’re basically told this:

Pasted_image_20230805122727.png

Solution

Although only 89 teams managed to solve this challenge, it’s much simpler than you think it is.

Go outside. Touch grass. Realize that you forgot to write the code on a paper to take the photo. Decide not to go back inside. Take in the fresh air. Be relieved you’re not looking at clubby’s monstrosity of a program. Lie down. Appreciate what little time we have on this planet. Sleep. Maybe you don’t need to slave over these challenges until 2 am. Maybe it’s time to really become an outdoors person and embrace what the Earth has to offer. Maybe the true solution all along was-

Ah, you forgot to take the photo didn’t you? Just follow the instructions and touch grass. It’s that simple.

Next CTF is smoke grass” —jazzpizazz