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.