Descriptions of a and b:

  • You are allowed to inject a software fault.
  • Changing in main() is not allowed.

In addition to that, a pre-compiled binary is provided.

Original code

// To compile:
// git clone https://github.com/kokke/tiny-AES-c
// gcc encrypt.c tiny-AES-c/aes.c
#include "tiny-AES-c/aes.h"
#include <unistd.h>

uint8_t plaintext[16] = {0x20, 0x24};
uint8_t key[16] = {0x20, 0x24};

int main() {
    struct AES_ctx ctx;
    AES_init_ctx(&ctx, key);
    AES_ECB_encrypt(&ctx, plaintext);
    write(STDOUT_FILENO, plaintext, 16);
    return 0;
}

The first challenge skips the OFFSET_MAIN_START / OFFSET_MAIN_END check - so for part 2, you must flip a bit outside the main - otherwise the code for both main.py is the same.

To run this code locally, just delete all references to from secret.network_util import check_client, ban_client and the if not check_client(): return line.

# Please ensure that you solved the challenge properly at the local.
# If things do not run smoothly, you generally won't be allowed to make another attempt.
from secret.network_util import check_client, ban_client

import sys
import os
import subprocess
import tempfile

OFFSET_PLAINTEXT = 0x4010
OFFSET_KEY = 0x4020
OFFSET_MAIN_START = 0x1169
OFFSET_MAIN_END = 0x11ed

def main():
    if not check_client():
        return

    key = os.urandom(16)
    with open("encrypt", "rb") as f:
        content = bytearray(f.read())

    # input format: hex(plaintext) i j
    try:
        plaintext_hex, i_str, j_str = input().split()
        pt = bytes.fromhex(plaintext_hex)
        assert len(pt) == 16
        i = int(i_str)
        assert 0 <= i < len(content)
        assert not OFFSET_MAIN_START <= i < OFFSET_MAIN_END
        j = int(j_str)
        assert 0 <= j < 8
    except Exception as err:
        print(err, file=sys.stderr)
        # ban_client()
        return

    # update key, plaintext, and inject the fault
    content[OFFSET_KEY:OFFSET_KEY + 16] = key
    content[OFFSET_PLAINTEXT:OFFSET_PLAINTEXT + 16] = pt
    content[i] ^= (1 << j)

    tmpfile = tempfile.NamedTemporaryFile(delete=True)
    with open(tmpfile.name, "wb") as f:
        f.write(content)
    os.chmod(tmpfile.name, 0o775)
    tmpfile.file.close()

    # execute the modified binary
    try:
        ciphertext = subprocess.check_output(tmpfile.name, timeout=1.0)
        print(ciphertext.hex())
    except Exception as err:
        print(err, file=sys.stderr)
        ban_client()
        return

    # please guess the AES key
    if bytes.fromhex(input()) == key:
        with open("secret/flag.txt") as f:
            print(f.read())
        from datetime import datetime
        print(datetime.now(), plaintext_hex, i, j, file=sys.stderr)


main()

Solution

The binary is just ~21kb, so we can just flip all possible bits. The regular execution of “encrypt” is 0.001s so if we run it for 0.01s we are safe.

The bit-flipper is just a minor rewrite of the original program.

Generally, one step of AES is XORing the plaintext with the key and then doing AES stuff - so we hope for an early exit after that.

To speed up the process

  • We run it in parallel
  • We pre-compute the 2 results we are looking for - the key and the key^plaintext
  • We set the key and plaintext before executing the binary

The binary also contains a lot of 0x00 bytes, that we could theoretically skip because they are the least likely to contain an interesting instruction.

This whole bruteforcer runs in ~10s on my machine.

Brute force bitflipper

import os
import subprocess
import tempfile
from contextlib import suppress
from multiprocessing import Pool

OFFSET_PLAINTEXT = 0x4010
OFFSET_KEY = 0x4020
OFFSET_MAIN_START = 0x1169
OFFSET_MAIN_END = 0x11ed

# chose any key/pt pair and calculate xor
key = "keyakeyakeyakeya".encode()
pt = "0123456789abcdef".encode()
kxp = kxp = bytes([k ^ p for k, p in zip(key, pt)])
# the standard_res is the output of running it once normally
standard_res = b'\xbaP\xaa\x1d?\xfa\xbfJ5\xd2\xd8&\xfd%"\xcb'


def brute_index(idx):
    content_copy = content.copy()
    i = idx // 8
    j = idx % 8
    content_copy[i] ^= (1 << j)
    tmp = tempfile.NamedTemporaryFile(prefix=str(idx), delete=True)
    with open(tmp.name, "wb") as f:
        f.write(content_copy)
    os.chmod(tmp.name, 0o775)
    tmp.file.close()

    # ignore exception with contextlib
    with suppress(Exception):
        out = subprocess.check_output(tmp.name, timeout=0.01, stderr=subprocess.DEVNULL)
        if out != standard_res:
            if key in out:
                postfix = "Bitflip in main" if OFFSET_MAIN_START <= i < OFFSET_MAIN_END else ""
                print(f"Found key @ {i}/{j} " + postfix)
            if kxp in out:
                print(f"Found key^plaintext @ {i}/{j}")


with open("encrypt", "rb") as f:
    content = bytearray(f.read())
print(f"{key=}, {pt=}, {kxp=}")

# update key, plaintext, and inject the fault
content[OFFSET_KEY:OFFSET_KEY + 16] = key
content[OFFSET_PLAINTEXT:OFFSET_PLAINTEXT + 16] = pt

with Pool() as p:
    list(p.map(brute_index, range(len(content)*8)))

Output:

key=b'keyakeyakeyakeya', pt=b'0123456789abcdef', kxp=b'[TKR_POVS\\\x18\x03\x08\x01\x1c\x07'
Found key^plaintext @ 5463/1
Found key @ 4537/0 Bitflip in main
Found key @ 4537/1 Bitflip in main
Found key @ 4537/2 Bitflip in main
Found key @ 4538/5 Bitflip in main
Found key @ 4538/6 Bitflip in main
Found key @ 4538/7 Bitflip in main
Found key @ 4539/0 Bitflip in main
Found key @ 4539/1 Bitflip in main
Found key @ 4539/2 Bitflip in main
Found key @ 4539/3 Bitflip in main
Found key @ 4545/4 Bitflip in main
Found key @ 4551/5 Bitflip in main
Found key^plaintext @ 8871/2

Pwntools exploit script

Pwntools script to try it out locally/remote - adapt the pos + bit from the output above.

from pwn import unhex, enhex, remote, process

# line we want to fault-inject: write(STDOUT_FILENO, plaintext, 16);
plain = "a" * 16  # irrelevant, just has to be printable
plain = enhex(plain.encode())  # will be 616161....
pos = 8871  # at 0x11b9 there is the relevant MOV EDX 0x10 / 0x ba 10 00 00 00 instruction that loads the 16 on stack
bit = 2  # flip the 0x10 to 0x30, so that we print 48 bytes instead of 16 chars
payload = f"{plain} {pos} {bit}"
print(payload)

proc = process(["python", "main.py"])
# proc = remote("139.162.24.230", 31340)
# proc = remote("139.162.24.230", 31339)
proc.sendline(payload.encode())
res = proc.recvline()  # the ciphertext + key + 16 bytes of garbage are returned
plain = unhex(res)  # first 16 bytes are the ciphertext
# xor with plaintext to get the key
key = bytes([plain[i] ^ ord("a") for i in range(16)])
proc.sendline(enhex(key))
print(proc.recvline())

What did we just do? - Xploring the 2 XORsolutions

For reference, it’s pretty helpful if you understand x86/64 fastcall calling conventions

The solution 5463 = 0x1557 / 1

Opening up ghidra, the closest instruction here initializes the loop in AddRoundKey

00101556 48 89 e5        MOV        RBP,RSP
---- for context: the followup instructions
00101559 89 f8           MOV        EAX,EDI
0010155b 48 89 75 e0     MOV        qword ptr [RBP + -0x20],RSI
0010155f 48 89 55 d8     MOV        qword ptr [RBP + -0x28],RDX
00101563 88 45 ec        MOV        byte ptr [RBP + -0x14],AL
00101566 c6 45 fe 00     MOV        byte ptr [RBP + -0x2],0x0

If we flip the 89 to 8b, we get the instruction

00101556 48 8b e5        MOV        RSP,RBP

So we swapped the assignment - the disassembly of the method is now utterly broken and uses a RBP that is set from before, and the RSP (stack pointer register) is never set.

This leads to us loading trash from the stack as RBP is never set, the following mov instructions use the old one. This skips the inner loops of the AES algorithm and we get the key as the output.

Then we return to the caller of the caller - which is the return of the Cipher method that calls AddRoundKey - which is called by our AES_ECB_encrypt.

void Cipher(undefined8 param_1,undefined8 param_2)
{
  char local_9;
  
  AddRoundKey(0,param_1,param_2);
  local_9 = '\x01';
  while( true ) {
    SubBytes(param_1);
    ShiftRows(param_1);
    if (local_9 == '\n') break;
    MixColumns(param_1);
    AddRoundKey(local_9,param_1,param_2);
    local_9 = local_9 + '\x01';
  }
  AddRoundKey(10,param_1,param_2);
  return;
}

The solution 8871 = 0x22a7 / 2

This time we are in this block where we prepare the call to AddRoundKey

Inside the Cipher-code, where we prepare for a call to AddRoundKey

0010228a f3 0f 1e fa     ENDBR64
0010228e 55              PUSH       RBP
0010228f 48 89 e5        MOV        RBP,RSP
00102292 48 83 ec 20     SUB        RSP,0x20
00102296 48 89 7d e8     MOV        qword ptr [RBP + local_20],RDI
0010229a 48 89 75 e0     MOV        qword ptr [RBP + local_28],RSI
0010229e c6 45 ff 00     MOV        byte ptr [RBP + local_9],0x0
001022a2 48 8b 55 e0     MOV        RDX,qword ptr [RBP + local_28]
001022a6 48 8b 45 e8     MOV        RAX,qword ptr [RBP + local_20]
001022aa 48 89 c6        MOV        RSI,RAX
001022ad bf 00 00        MOV        EDI,0x0
         00 00
001022b2 e8 9a f2        CALL       AddRoundKey                                      undefined AddRoundKey()
         ff ff

The original instruction here is

001022a6 48 8b 45 e8     MOV        RAX,qword ptr [RBP + local_20]

Which we flip to

001022a6 48 8f 45 e8     POP        qword ptr [RBP + local_20]

In the disassembly, this changes the AddRoundKey(0,param_1,param_2); to AddRoundKey(0); because instead of pushing the required 3 arguments to the stack, we pop one.

This leaves the stack in a perfectly misaligned state, where the arguments of the Cipher are popped/used, and then the instead of returning to Cipher, AddRoundKey returns to the caller of Cipher - which is AES_ECB_encrypt.

Original encrypt and the 2 flipped variants

You know, for reproduction.