Solving The Sword of Secrets

Table of Contents

After placing my order on day one of the release, waiting four months, then waiting another two because of various delays, the hardware CTF Sword of Secrets has finally arrived. I’ve always been interested in CTFs and have participated and won some before, but, never tried a hardware CTF as they are not as popular, usually expensive or exclusive, and as far as I know theres only a few that exist. So, after hearing about the Sword of Secrets I signed up the moment the it became available, couldn’t miss the boat like I did with The Skull.

Unboxing!

The packaging

Upon first opening the package, we are greeted with this nice laser cut packaging. Thought i’d show some nice photos of it since it looks like a good bit of time went into it. The instructions on how to start aren’t on the package which was a bit odd, I ended up finding them on the website. To get started we have to snap the off the bottom of the PCB and use it as a shim isn’t thick enough to fit in a USB port, which is a bit annoying since most PCB manufacturers support 2mm thick boards.

Snapping the PCB

Guide showing how to snap the PCB

I decided to just epoxy the shim to the back since last thing I wanted was to be digging it out of my USB port and I strongly suggest you do the same.

Hardware Analysis

With the board finally setup and flashed, we can finally move on to actually solving the secrets within. Starting with documenting the hardware components.

Components on the PCB
NameDescription
AMS1117-3.33.3v LDO regulator
CH32V003F4P6The core of the puzzle; contains quite a few accelerators and other neat features in a very small package
Windbond NOR 128MBITNOR flash for storing secrets; attached to the CH32V003F4P6 thru SPI
WCH CH340NUSB to serial module; only used for communication

The board itself is pretty simple; it contains nothing special or wireless, just the CH32V003F4P6 and a 128MBit flash chip which I’ve luckily used previously. Plugging it in and opening the terminal, we are greeted with the start of our journey.

Flash initialization success
Initializing (Reset count: 0)...
  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                  THE SWORD OF SECRETS: A HACKING ADVENTURE
  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

   In the dim light of a stormy night, the ancient castle looms before you,
   its towering walls cloaked in secrets and guarded by formidable traps.

               Legends speak of the fabled *Sword of Secrets*,
                  a relic said to grant untold power to those
                      daring enough to claim it.

               ➤ You now approach the castle from 0x10000 ➤ 

   Heart pounding, you stand before the towering gates. A deep silence fills
   the air, but you know the true challenge lies within—layers of encrypted
   doors, hidden switches, and mechanisms forged to repel all but the most
   cunning and daring.

  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                              ARE YOU READY?
  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

>> 

Looks like we are good to go, time to dive into the code and figure out what our goal is.

Understanding the commands

Observing the entrance to the code, we can right away pull out eight different commands and roughly what each one does. We can run these commands by typing them into the serial terminal which looks like our only way to talk to the device at the moment.

>> SOLVE
  - Runs challenges()
>> ASSERT
  - Pulls the CS line for the flash LOW
>> RELEASE
  - Pulls the CS line for the flash HIGH
>> BEGIN
  - Starts an SPI transaction
>> END
  - Stops an SPI transaction
>> RESET
  - Resets the challenge status and sets up the main quest
  - This is also called on boot; could provide hints on what we are looking for
>> REBOOT
  - Triggers a reboot
>> DATA
  - Sends out data over the SPI bus and reads back data on the SPI bus

None of the commands are important for figuring out the answer except for SOLVE which calls challenges(data + sizeof(CMD_SOLVE));.

if (!memcmp(CMD_SOLVE, data, sizeof(CMD_SOLVE) - 1))
{
    challenges(data + sizeof(CMD_SOLVE));
}

If we try to run SOLVE right now, it doesn’t yield much.

>> SOLVE
This is not the right header...

Looking inside of void challenges(char * challenge) we can see the various challenges we are tasked with solving.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
void challenges(char * challenge)
{
    handleChallengeStatus();

    // Puzzle 1
    if (palisade() < 0)
    {
        printf("This is not the right header..." "\r\n");

        goto done;
    }

    // Puzzle 2
    if (parapet() < 0)
    {
        printf("Invalid Header" "\r\n");

        goto done;
    }

    // Puzzle 3
    int err = postern();

    if (err < 0)
    {
        printf("Error in response." "\r\n");
        goto done;
    }
    else if (err == 1)
    {
        printf("Invalid padding" "\r\n");

        goto done;
    }

    // Puzzle 4
    digForTreasure();

    if (treasuryVisit() < 0)
    {
        printf("The secret is still safe!" "\r\n");
    }
    else
    {
        printf("THE SECRET IS REVEALED!" "\r\n");
    }
    blink(1);

done:
    return;
}

Solving PALISADE

Checking out int palisade(), the first puzzle, we can right away see it does a flash_read at PALISADE_FLASH_ADDR (0x10000), XOR’s it with an eight byte key in keys.h, and then tries to compare it to FLAG_BANNER. So the goal right now is to set the data at 0x10000 in flash equal to FLAG_BANNER after an XOR is performed on it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
///
/// Defined in keys.h
///

// XOR key is *8 bytes*
static uint8_t __attribute__((unused)) xor_key[] = {'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X'};

///
/// Inside of armory.c
///

// XOR encrypt, just XOR's each byte individually with the key
void xcryptXor(uint8_t * buf, size_t len)
{
    for (unsigned i = 0; i < len; i++)
    {
        buf[i] ^= xor_key[i % sizeof(xor_key)];
    }
}

int palisade()
{
    int err = -1;
    char message[128] = { 0 };

    // Read at PALISADE_FLASH_ADDR (which is 0x10000, found in keys.h)
    flash_read(PALISADE_FLASH_ADDR, message, sizeof(message));

    // Try to decrypt it
    xcryptXor((uint8_t *)message, sizeof(message));

    // Compare the decrypted message with the flag banner ("MAGICLIB" found in keys.h)
    if (memcmp(message, FLAG_BANNER, strlen(FLAG_BANNER)))
    {
        goto error;
    }

    printf(message);
    printf("\r\n");

    err = 0;
error:
    return err;
}

First thing to do is make a interface so we can stop typing in commands manually. There is flash_read() but the call to it looks like it does quite a bit of validation, luckily there exists void flash_read_ext(uint32_t addr, void * buf, size_t len)/void flash_write_ext(uint32_t addr, void * buf, size_t len)/void flash_erase_block_ext(uint32_t addr) which does the same thing but without the extra checks. These are easy enough to recreate both in python and we can just rely on waiting for the command prompt to reappear make sure enough time has passed between commands.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def flash_read(ser: serial.Serial, addr: int, length: int) -> bytes:
    logging.info(f"Reading {len} bytes from address {addr:#06X}")
    ser.write(b'BEGIN\r\n')
    wait_for_prompt(ser)

    ser.write(b'ASSERT\r\n')
    wait_for_prompt(ser)
    
    # Seperate addr into 00 00 00 
    addr_bytes = addr.to_bytes(3, 'big')
    ser_addr = 'DATA 03 ' + ' '.join(f'{b:02X}' for b in addr_bytes)
    ser.write(ser_addr.encode() + b'\r\n')
    print(f"Sent: {ser_addr}")
    wait_for_prompt(ser)

    # loop for length bytes, reading one byte at a time
    result = bytearray()
    for _ in range(length):

        ser.write(b'DATA 00\r\n')
        # read the response, which should be "XX"

        response = ser.read_until(b'\n').strip()
        data = ser.read_until(b'\n').strip()
        result.append(int(data, 16))
        logger.debug(f"Received response: {response.decode(errors='ignore')} -> {data.decode(errors='ignore')}")
        wait_for_prompt(ser)

    ser.write(b'RELEASE\r\n')
    wait_for_prompt(ser)
    ser.write(b'END\r\n')
    wait_for_prompt(ser)
    
    return bytes(result)

def flash_write(ser: serial.Serial, addr: int, data: bytes) -> None:
    logging.info(f"Writing {len(data)} bytes to address {addr:#06X}")
    ser.write(b'BEGIN\r\n')
    wait_for_prompt(ser)

    
    # Enable write mode
    ser.write(b'ASSERT\r\n')
    wait_for_prompt(ser)
    ser.write(b'DATA 06\r\n')
    wait_for_prompt(ser)
    ser.write(b'RELEASE\r\n')
    wait_for_prompt(ser)

    # Page write
    ser.write(b'ASSERT\r\n')
    wait_for_prompt(ser)
    
    # Seperate addr into 00 00 00 
    addr_bytes = addr.to_bytes(3, 'big')
    ser_addr = 'DATA 02 ' + ' '.join(f'{b:02X}' for b in addr_bytes)
    ser.write(ser_addr.encode() + b'\r\n')
    logger.debug(f"Sent: {ser_addr}")
    wait_for_prompt(ser)

    for byte in data:
        ser_data = f'DATA {byte:02X}'
        ser.write(ser_data.encode() + b'\r\n')
        logger.debug(f"Sent: {ser_data}")
        wait_for_prompt(ser)

    ser.write(b'RELEASE\r\n')
    wait_for_prompt(ser)
    ser.write(b'END\r\n')
    wait_for_prompt(ser)

...

Using the python interface, we can now dump the memory at 0x10000 to get message: 00 00 00 00 0e 05 13 07 36 0f 37 69 22 27 3f 65 2e 20 36 69 2f 3b 3f 24 26 61 2c 21 24 3a 7b 65 7d 39 6a 79 7d 79 6a 38 4d.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
ser = serial.Serial('/dev/ttyUSB0', baudrate=115200, timeout=1)

logging.basicConfig(level=logging.INFO)
logging.info("Serial port opened successfully")

logging.info("Waiting for prompt... (press the reset button on the device if needed)")
wait_for_prompt(ser)

flash_reboot(ser)
flash_reset(ser)

data = flash_read(ser, 0x10000, 64)
print(f"Read data: {data.hex()}")
INFO:root:Serial port opened successfully
INFO:root:Waiting for prompt... (press the reset button on the device if needed)
INFO:flash_commands:Sending REBOOT command
INFO:flash_commands:Sending RESET command
INFO:root:Reading <built-in function len> bytes from address 0X10000
Sent: DATA 03 01 00 00
Read data: 000000000e051307360f376922273f652e2036692f3b3f2426612c21243a7b657d396a797d796a384dffffffffffffffffffffffffffffffffffffffffffffff

Memory dump @ 0x10000

Great! Now we can discuss what we are about to do with the XOR operation. This type of encryption has a key which is used once-per-byte (i.e. byte 0 of the key encrypts byte 0 of the text) and if the text is longer then the key it just loops back to the start of the key and reuses it. The XOR operation itself is fully reversible, meaning that if A xor B = C then A xor C = B/B xor A = A. Using that, we can get the key if we know both the encrypted text and the original message, which we do.

    ┌────────────┐   ┌─────────────┐                          ┌───────────────┐            ┌────────────────┐   
    │Message[0:7]│   │Message[8:15]│                          │Cyphertext[0:7]│            │Cyphertext[8:15]│   
    └─────┬──────┘   └─────────────┘                          └───────┬───────┘            └────────┬───────┘   
          │                 │                                         │                             │           
          ▼                 ▼                                         ▼                             ▼           
        ┌───┐             ┌───┐        Reversible  ┌────────────┐   ┌───┐       ┌─────────────┐   ┌───┐         
  Key──►│XOR│       Key──►│XOR│    ... ─────────►  │Message[0:7]│──►│XOR│       │Message[8:15]│──►│XOR│     ... 
        └─┬─┘             └─┬─┘    etc             └────────────┘   └─┬─┘       └─────────────┘   └─┬─┘     etc 
          │                 │                                         │                             │           
          ▼                 ▼                                         ▼                             ▼           
  ┌───────────────┐ ┌────────────────┐                               Key                           Key          
  │Cyphertext[0:7]│ │Cyphertext[0:15]│                                                                          
  └───────────────┘ └────────────────┘                                                                          

XOR Operation Flow

From the flash dump, the first eight characters of the cyphertext are 00 00 00 00 0e 05 13 07, and we know the expected first eight characters are MAGICLIB (from int palisade() inside of FLAG_BANNER). Using both of these and XORing them together should get us the eight byte key it used to encrypt it.

1
2
3
4
5
6
data = flash_read(ser, 0x10000, 41)
print(f"Read data: {data.hex()}")

xor_key = xcrypt_xor(bytearray(data[0:8]), b"MAGICLIB")
print(f"XOR key: {xor_key.hex()} ({xor_key.decode(errors='ignore')})")
print(f"Decrypted message: {xcrypt_xor(bytearray(data), xor_key).decode(errors='ignore')}")
INFO:root:Serial port opened successfully
INFO:root:Waiting for prompt... (press the reset button on the device if needed)
INFO:flash_commands:Sending REBOOT command
INFO:flash_commands:Sending RESET command
INFO:root:Reading <built-in function len> bytes from address 0X10000
Sent: DATA 03 01 00 00
Read data: 000000000e051307360f376922273f652e2036692f3b3f2426612c21243a7b657d396a797d796a384d
XOR key: 4d4147494d495a45 (MAGIMIZE)
Decrypted message: MAGICLIB{Np one caq break khis! 0x-0000}

Result of XOR decryption using 'MAGICLIB'

Wrong key?

Well, that doesn’t look correct? Very strange, this should be pretty simple but it looks like one of the bytes are wrong. This led me to check out the initialization function for palisade. We see that the key does contain PARAPET_FLASH_ADDR which is the flash location to our next puzzle so we will have to keep that in mind, but, there is another another line of interest.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static void palisadeSetup()
{
    // The answer has the address of our next puzzle is in the message
    // We have more of the message then just the banner here; we can use this to
    // extract the correct key!
    char message[] = FLAG_BANNER "{No one can break this! " S(PARAPET_FLASH_ADDR) "}"; 
    size_t len;

    printf("Running %s...\r\n", __FUNCTION__);

    // Create the mesasage
    len = strlen(message) + 1;

    // Run XOR with the key in key.h
    xcryptXor((uint8_t *)message, len);

    // Oh noes! Something happened... X_X
    *((uint32_t *)(message)) = 0x00000000; // random(0xffffffff); // <-- What is going on here??????

    // Write the first flag to its corresponding address
    flash_erase_block(PALISADE_FLASH_ADDR);
    flash_write(PALISADE_FLASH_ADDR, message, len);
}

Palisade setup function

Looks like the setup script sets a bunch of the start of the message to zero before it writes it to flash. Not a problem. The XOR key is reused every eight bytes so we can just use the next eight bytes of the message (seen above as {No one ) and XOR it with the next eight bytes of the cypher text.

1
*((uint32_t *)(message)) = 0x00000000; // random(0xffffffff); // <-- What is going on here??????

Running the script with the next eight bytes gets us a much better result. We finally see the correct XOR key is MAXIMIZE. One thing to keep in mind is that since the first four bytes are wiped, the answer we get for those first four bytes will be wrong. Even with that issue, we can still use the key have to correct that to get our first flag: MAXICLIB{No one can break this! 0x20000}

1
2
3
4
5
6
data = flash_read(ser, 0x10000, 41)
print(f"Read data: {data.hex()}")

xor_key = xcrypt_xor(bytearray(data[8:16]), b"{No one ")
print(f"XOR key: {xor_key.hex()} ({xor_key.decode(errors='ignore')})")
print(f"Decrypted message: {xcrypt_xor(bytearray(data), xor_key).decode(errors='ignore')}")
INFO:root:Reading <built-in function len> bytes from address 0X10000
Sent: DATA 03 01 00 00
Read data: 000000000e051307360f376922273f652e2036692f3b3f2426612c21243a7b657d396a797d796a384d
XOR key: 4d4158494d495a45 (MAXIMIZE)
Decrypted message: MAXICLIB{No one can break this! 0x20000}

Since we now have the XOR key, we can re-encrypt the expected message (which should be MAGICLIB{No one can break this! 0x20000}, not MAXICLIB... because of the corrupted first four bytes) with the key and write it back to 0x10000 and try to run the SOLVE command. We aren’t getting This is not the right header... which means we are passing the PALISADE check and can move on to the next puzzle.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
data = flash_read(ser, 0x10000, 41)
print(f"Read data: {data.hex()}")

# Ignore the first eight bytes, because some of them are "lost"; we use the banner contents instead to determine the key
xor_expected = b"{No one "
xor_current = bytearray(data[8:8+len(xor_expected)])
print(f"XOR expected: {xor_expected}")
print(f"XOR current: {xor_current}")

xor_key = xcrypt_xor(xor_current, xor_expected)
print(f"XOR key: {xor_key.hex()} ({xor_key.decode(errors='ignore')})")


# Fix the flag banner
banner_expected = b"MAGICLIB"
print(f"Banner expected: {banner_expected}")
banner_fixed = xcrypt_xor(bytearray(banner_expected), xor_key)
print(f"Banner fixed: {banner_fixed.decode(errors='ignore')}")

# Insert the fixed banner back into the data
fixed_data = bytearray(data)
fixed_data[0:len(banner_expected)] = banner_fixed
print(f"Fixed data: {fixed_data.hex()}")

# Write the fixed data back to flash and read it again to verify
flash_sector_erase(ser, 0x10000)
flash_write(ser, 0x10000, fixed_data)

print("Running solve command...")
flash_solve(ser)
INFO:root:Reading <built-in function len> bytes from address 0X10000
Sent: DATA 03 01 00 00
Read data: 000000000e051307360f376922273f652e2036692f3b3f2426612c21243a7b657d396a797d796a384d
XOR expected: b'{No one '
XOR current: bytearray(b'6\x0f7i"\'?e')
XOR key: 4d4158494d495a45 (MAXIMIZE)
Banner expected: b'MAGICLIB'
Banner fixed: 
Fixed data: 00001f000e051307360f376922273f652e2036692f3b3f2426612c21243a7b657d396a797d796a384d
INFO:root:Erasing sector at address 0X10000
Sent: DATA 20 01 00 00
INFO:flash_commands:Received response: DATA 05 00 -> ff 03
INFO:flash_commands:Received response: DATA 05 00 -> 00 00
INFO:root:Writing 41 bytes to address 0X10000
Running solve command...
INFO:flash_commands:Sending SOLVE command
SOLVE
MAGICLIB{No one can break this! 0x20000}
Invalid Header

>> 

Running SOLVE with the uncorrupted XOR'd data

Solving PARAPET

We now have the address of the next section we need to check out, great. Lets check out the setup function for parapet to see if there is any hints like the last section.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static void parapetSetup()
{
    struct AES_ctx ctx;
    char message[128] = "Important message to transmit - " FLAG_BANNER "{53Cr37 5745H: " S(POSTERN_FLASH_ADDR) "}";
    size_t len;

    printf("Running %s...\r\n", __FUNCTION__);

    // Pad with PKCS#7 to prepare for encryption
    len = PKCS7Pad((uint8_t *)message, strlen(message));

    // Initialize AES context
    AES_init_ctx(&ctx, aes_key);

    // Encrypt
    for (unsigned i = 0; i < len / AES_BLOCKLEN; ++i)
    {
        AES_ECB_encrypt(&ctx, (uint8_t *)message + i * AES_BLOCKLEN);
    }

    // Write buffer to flash at PARAPET_FLASH_ADDR (0x20000)
    flash_erase_block(PARAPET_FLASH_ADDR);
    flash_write(PARAPET_FLASH_ADDR, message, len);
}

There isn’t a whole lot pointing us in the right direction here. We do know that we are dealing with AES-ECB which is a pretty big hint, but, it doesn’t tell us what our goal is. Going back to void challenges(char * challenge) and looking at int parapet() tells us a bit more. The code reads a 128 byte message at 0x20000 and tries to decrypt it with AES-ECB and a block size of 16 before finally comparing only the first bytes to make sure they are equal to the FLAG_BANNER.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
int parapet()
{
    int err = -1;
    struct AES_ctx ctx;
    // Message can't be more then 128 bytes
    char message[128];

    // Read 128 bytes at PARAPET_FLASH_ADDR (0x20000)
    flash_read(PARAPET_FLASH_ADDR, message, sizeof(message));

    AES_init_ctx(&ctx, aes_key);

    // Attempt to decrypt it
    for (unsigned i = 0; i < sizeof(message) / AES_BLOCKLEN; ++i)
    {
        AES_ECB_decrypt(&ctx, (uint8_t *)message + i * AES_BLOCKLEN);
    }

    // Only compare the FIRST EIGHT BYTES to make sure its the banner
    if (memcmp(message, FLAG_BANNER, strlen(FLAG_BANNER)))
    {
        goto error;
    }

    // Add a string terminator after 32 bytes???
    message[AES_BLOCKLEN * 2] = '\0';

    printf(message);
    printf("\r\n");

    err = 0;
error:
    return err;
}

Dumping the memory at 0x20000 shows us the message we have is 64 bytes long. In this case, if we go back to static void parapetSetup() we can see that the message starts with Important message to transmit - which happens to be exactly 32 bytes long.

1
char message[128] = "Important message to transmit - " FLAG_BANNER "{53Cr37 5745H: " S(POSTERN_FLASH_ADDR) "}";
1
2
data = flash_read(ser, 0x20000, 64)
print(f"Read data: {data.hex()}")
>> INFO:root:Serial port opened successfully
INFO:root:Reading <built-in function len> bytes from address 0X20000
Sent: DATA 03 02 00 00
Read data: 4b406fe6a3d4321b2628b7c6fff5fc9f6e617138483ef9869cb84c9cc0d272a3de90e7d0ae8338b07aac389475746900415d3941ded0e4e3adc5459842dca58d

AES-ECB happens to suffer from a similar issue as the XOR question where each block of cyphertext that is encrypted doesn’t rely on its position or previous input. This means we can decrypt the message at any 16 byte increment and still get back the same result as if it was decrypted in order.

                                                       Swapped & still valid AES-ECB            
                                                             ┌──────────┼────────┐              
                                                             ▼                   ▼              
  ┌─────────────┐   ┌──────────────┐                  ┌──────────────┐     ┌─────────────┐      
  │Message[0:15]│   │Message[16:32]│                  │Message[16:32]│     │Message[0:15]│      
  └─────┬───────┘   └──────────────┘                  └──────────────┘     └─────┬───────┘      
        │                  │                                 │                   │              
        ▼                  ▼              NOT                ▼                   ▼              
      ┌───────┐          ┌───────┐     Reversible          ┌───────┐           ┌───────┐        
Key──►│AES-CBC│    Key──►│AES-CBC│ ... ─────────►    Key──►│AES-CBC│     Key──►│AES-CBC│   ...  
      └─┬─────┘          └─┬─────┘ etc                     └─┬─────┘           └─┬─────┘   etc  
        │                  │           BUT block             │                   │              
        ▼                  ▼           order is              ▼                   ▼              
┌────────────────┐ ┌─────────────────┐ not importan  ┌─────────────────┐ ┌────────────────┐     
│Cyphertext[0:15]│ │Cyphertext[16:32]│               │Cyphertext[16:32]│ │Cyphertext[0:15]│     
└────────────────┘ └─────────────────┘               └─────────────────┘ └────────────────┘     

Because of that issue, if we just move the last 32 bytes to the first 32 bytes, then we should decrypt the FLAG_BANNER making it pass the memcmp and printing the flag. After setting that up in python I run the SOLVE command and we get the next flag: MAGICLIB{53Cr37 5745H: 0x30000}.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
data = flash_read(ser, 0x20000, 64)
print(f"Read data: {data.hex()}")

# Our expected result is "Important message to transmit - MAGICLIB{53Cr37 5745H: 0x????}" which the beginning half before
# the banner is exact 32 bytes. This means we can abuse the fact that AES-ECB is works in 16 byte blocks and drop the first
# 32 bytes, replacing it with the expected banner contents (which is in everything after the first 32 bytes), to trigger the solution.
data = data[32:]
print(f"Data after dropping first 32 bytes: {data.hex()}")

print("Erasing flash sector and writing modified data back to flash...")
flash_sector_erase(ser, 0x20000)
flash_write(ser, 0x20000, data)

print("Running solve command...")
flash_solve(ser)
NFO:root:Reading <built-in function len> bytes from address 0X20000
Sent: DATA 03 02 00 00
Read data: 4b406fe6a3d4321b2628b7c6fff5fc9f6e617138483ef9869cb84c9cc0d272a3de90e7d0ae8338b07aac389475746900415d3941ded0e4e3adc5459842dca58d
Data after dropping first 32 bytes: de90e7d0ae8338b07aac389475746900415d3941ded0e4e3adc5459842dca58d
Erasing flash sector and writing modified data back to flash...
INFO:root:Erasing sector at address 0X20000
Sent: DATA 20 02 00 00
INFO:flash_commands:Received response: DATA 05 00 -> ff 03
INFO:flash_commands:Received response: DATA 05 00 -> 00 03
INFO:flash_commands:Received response: DATA 05 00 -> 00 00
INFO:root:Writing 32 bytes to address 0X20000
Running solve command...
INFO:flash_commands:Sending SOLVE command
SOLVE
MAGICLIB{No one can break this! 0x20000}
MAGICLIB{53Cr37 5745H: 0x30000}
Invalid padding

>> 

The flag contains the address POSTERN_FLASH_ADDR (0x50000) which we will probably end up needing, but, we know in the meantime that we’ve passed this section since the output of SOLVE is now Invalid padding meaning that the code has passed parapet().

Solving POSTERN

Just like the last section, the first things we will check are int postern() and static void posternSetup() for any clues and ideas of where to look at next. It does look a lot like the last puzzle; the only big difference is the use of AES-CBC and the byte that is set to zero.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static void posternSetup()
{
    struct AES_ctx ctx;
    uint8_t iv[AES_BLOCKLEN] = { 0 }; // <----- initial IV of zero?
    char message[128] = FLAG_BANNER "{Passwd: " FINAL_PASSWORD "}";
    size_t len;

    printf("Running %s...\r\n", __FUNCTION__);

    // Initialize AES context
    AES_init_ctx_iv(&ctx, aes_key, iv);

    len = PKCS7Pad((uint8_t *)message, strlen(message));

    // Encrypt
    AES_CBC_encrypt_buffer(&ctx, (uint8_t *)message, len);

    // Oops... Something bad happened...
    message[len - AES_BLOCKLEN - 1] = '\0'; // <----- We have a byte set to zero, whoops

    // Write buffer to flash
    flash_erase_block(POSTERN_FLASH_ADDR);
    flash_write(POSTERN_FLASH_ADDR, &len, sizeof(len));
    flash_write(POSTERN_FLASH_ADDR + sizeof(len), message, len);
}

Checking out int postern() which is what we need to pass, we see start to get an idea of how the encrypted messaged is stored in the flash.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
int postern()
{
    int err = -1;
    struct AES_ctx ctx;
    uint8_t iv[AES_BLOCKLEN] = { 0 };
    char message[128];
    char response[128];
    size_t len;

    flash_read(POSTERN_FLASH_ADDR, &len, sizeof(len)); // <----- First bytes hold the message length

    len = MIN(len, sizeof(message)); // <----- Just here to prevent buffer overwrites

    flash_read(POSTERN_FLASH_ADDR + sizeof(len), message, len); // <---- The encrypted message 
    flash_read(POSTERN_FLASH_ADDR + sizeof(len) + len, response, sizeof(FINAL_PASSWORD) - 1); // <--- Some kind of expected response at the end of the message?

    AES_init_ctx_iv(&ctx, aes_key, iv);
    AES_CBC_decrypt_buffer(&ctx, (uint8_t *)message, len);

    if (checkPKCS7Pad((uint8_t *)message, len) < 0) // <--- Validate the padding 
    {
        err = 1;

        goto error;
    }

    message[len - message[len - 1]] = '\0'; // <--- Prevents OOB from string

    // Chet everything's OK!
    if (memcmp(response, FINAL_PASSWORD, strlen(FINAL_PASSWORD))) // <--- The response is used here
    {
        goto error;
    }

    printf(message);
    printf("\r\n");

    err = 0;
error:
    return err;
}

Looks like the first four bytes are the length of the message and the message comes directly after it, and then some kind of response that is in the encrypted message. Dumping the memory at 0x30000 and mapping it yields the below. We see that we have corrupted bytes, which right now just means we can’t print out the flag as the memcmp expects FINAL_PASSWORD in the response. Well, actually, we aren’t even failing here yet. We are actually failing the padding check if (checkPKCS7Pad((uint8_t *)message, len) < 0) meaning we need to check this out first.

1
2
3
4
5
6
flash_read(POSTERN_FLASH_ADDR, &len, sizeof(len));

len = MIN(len, sizeof(message)); 

flash_read(POSTERN_FLASH_ADDR + sizeof(len), message, len);  
flash_read(POSTERN_FLASH_ADDR + sizeof(len) + len, response, sizeof(FINAL_PASSWORD) - 1);
+========================+======================================+=====================+
| Message Size (4 bytes) |         The Message (32 bytes)       | Response (?? bytes) |
+========================+======================================+=====================+

Memory at 0x30000
20000000f7604a1f5e96397e96f59e31720bd900d76bedc8d1d1473481469a24bfaa9022FFFFFFFFFFFFFFFFFFFFFFFFFF
| Size |                           Message                             |  Response (unknown len) |
                                      ^^ Corrupted bytes

Memory structure at 0x30000

Fixing the corrupted bytes

To pass if (checkPKCS7Pad((uint8_t *)message, len) < 0) we need to somehow modify the unencrypted message without knowing what it is. We know we have a corrupted byte, so this is likely what is causing the padding check to fail.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
flash_read(POSTERN_FLASH_ADDR, &len, sizeof(len));

len = MIN(len, sizeof(message));

// Read the ENCRYPTED message
flash_read(POSTERN_FLASH_ADDR + sizeof(len), message, len);
flash_read(POSTERN_FLASH_ADDR + sizeof(len) + len, response, sizeof(FINAL_PASSWORD) - 1);;

// DECRYPT the message
AES_init_ctx_iv(&ctx, aes_key, iv);
AES_CBC_decrypt_buffer(&ctx, (uint8_t *)message, len);

// Check the DECRYPTED messages padding
// If we have an invalid byte (like our corrupted byte) this will fail.
if (checkPKCS7Pad((uint8_t *)message, len) < 0)
{
    err = 1;

    goto error;
}

Since we have no access to the decrypted message, don’t know what the key is, and AES-CBC doesn’t have anything we can abuse in this context, we have to resort to brute forcing. This script will take the contents of 0x30000 and just keep incrementing the corrupted byte until we pass the padding check, which we will know if we receive the message Error in response. instead of Invalid padding. This does potentially have the issue of injecting wrong garbage into the message which would ruin the answer, but, one of the bytes is bound to be correct so we can just check all of them if one answer turns out bad. Running this shows us that 0x32 is a valid byte to pass the padding, so, for now we can move on.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
raw_data = bytearray(flash_read(ser, 0x30000, 36))

print("---------------------------------------------------------")
print("Starting brute force to find correct byte value for PKCS7 padding...")
for i in range(0x100):
    print(f"Trying with byte value: {i:#02x}")
    data = raw_data.copy()
    data[19] = i
    print(f"Modified data: {data.hex()}")
    print("Erasing flash sector and writing modified data back to flash...")
    flash_sector_erase(ser, 0x30000)
    flash_write(ser, 0x30000, data)

    data = flash_read(ser, 0x30000, 36)
    print(f"Read data: {data.hex()}")

    print("Running solve command...")
    rslt = flash_solve(ser)
    # Check if the response contains "Invalid padding"

    if b"Invalid padding" not in rslt:
        print(f"\n\n\n\nFound correct byte value: {i:#02x}")
        print(f"Response: {rslt.decode(errors='ignore')}")
        # We now know the right value, put it into the raw data and break out of the loop
        raw_data[19] = i
        break
    else:
        print("Invalid padding, trying next byte value...")
---------------------------------------------------------
Starting brute force to find correct byte value for PKCS7 padding...
Trying with byte value: 0x0
Modified data: 20000000f7604a1f5e96397e96f59e31720bd900d76bedc8d1d1473481469a24bfaa9022
Erasing flash sector and writing modified data back to flash...
INFO:root:Erasing sector at address 0X30000
INFO:root:Writing 36 bytes to address 0X30000
Running solve command...
INFO:flash_commands:Sending SOLVE command
SOLVE
MAGICLIB{No one can break this! 0x20000}
MAGICLIB{53Cr37 5745H: 0x30000}
Invalid padding

>> 

>> Invalid padding, trying next byte value...

...
...
...

>> Invalid padding, trying next byte value...
Trying with byte value: 0x32
Modified data: 20000000f7604a1f5e96397e96f59e31720bd932d76bedc8d1d1473481469a24bfaa9022
Erasing flash sector and writing modified data back to flash...
INFO:root:Erasing sector at address 0X30000
INFO:root:Writing 36 bytes to address 0X30000
Running solve command...
INFO:flash_commands:Sending SOLVE command
SOLVE
MAGICLIB{No one can break this! 0x20000}
MAGICLIB{53Cr37 5745H: 0x30000}
Error in response.

>> 

Found correct byte value: 0x32

Solving the final password

Observing the next check in int postern(), we see we need to pass a memory comparison of our response against some unknown chunk of memory called FINAL_PASSWORD. This is pretty tricky, as it puts in a place where we need the answer FINAL_PASSWORD, but, to get it we need to extract it from decrypted text, but, to get the decrypted text we need FINAL_PASSWORD which brings us in a circle.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
///
/// keys.h
///
#define FINAL_PASSWORD      "XXXXXX" S(PLUNDER_ADDR_DATA)

///
/// armory.c
///
message[len - message[len - 1]] = '\0';

// Check the reponse for `FINAL_PASSWORD`, but, `FINAL_PASSWORD` is inside the encrypted message?? 
if (memcmp(response, FINAL_PASSWORD, strlen(FINAL_PASSWORD)))
{
    goto error;
}

I spent quite a while on this going into quite a few rabbit holes, none of which were right, until I realized something about AES-CBC. Unlike AES-ECB, the message needs to be decoded in order and can’t be swapped, BUT, our message is only 32 characters long. This means that if IV is all zeros (which is is), then the first block is essentially the same as AES-ECB. This means that if we can’t get the decrypted output here, we should be able to get it if we copy the encrypted message to the previous puzzle which does decrypt AES-ECB.

    ┌─────────────┐        ┌──────────────┐            
    │Message[0:15]│        │Message[16:32]│            
    └──────┬──────┘        └──────┬───────┘            
         ┌───┐                  ┌───┐                  
 IV─────►│XOR│         ┌───────►│XOR│          ┌──►    
         └───┘         │        └───┘          │       
           │           │          │            │       
           ▼           │          ▼            │       
         ┌──────┐      │        ┌───────┐      │ ..... 
   Key──►│AES-CC│      │  Key──►│AES-CBC│      │       
         └─┬────┘      │        └─┬─────┘      │       
           │           │          │            │       
           ▼           │          ▼            │       
  ┌────────────────┐   │  ┌─────────────────┐  │       
  │Cyphertext[0:15]├───┘  │Cyphertext[16:32]├──┘       
  └────────────────┘      └─────────────────┘          

Data flow of AES-CBC

Copying the message from 0x30000 to 0x20000 only works because the key is the same for both messages AND the message starts with FLAG_BANNER. Running the code which performs this operation for us though does give us text: MAGICLIB{Passwd:UyMmNB;3. This tell us two things, one is that our brute forced byte is correct, and, the second half of our message is still scrambled. Luckily, if we consult the flow chart for AES-CBC you might notice that once the text is decrypted, it is just XOR’d with the previous blocks encrypted message; this is why we can’t swap the decryption order or remove bytes from the message.

     "MAGICLIB{Passwd:"      ?????? need to solve                               
       ┌─────────────┐        ┌──────────────┐                                  
       │Message[0:15]│        │Message[16:32]│                                  
       └──────┬──────┘        └──────┬───────┘                                  
            ┌───┐                  ┌───┐             Just need to perform XOR   
    IV─────►│XOR│         ┌───────►│XOR│                 │ to get message[16:32]
{0,..0}     └───┘         │        └───┘               ──┤                      
              │           │          │ ───────►"UyMmNB;3"│                      
              ▼           │          ▼                 ──┘                      
            ┌──────┐      │        ┌───────┐                                    
      Key──►│AES-CC│      │  Key──►│AES-CBC│                                    
            └─┬────┘      │        └─┬─────┘                                    
              │           │          │                                          
              ▼           │          ▼                                          
     ┌────────────────┐   │  ┌─────────────────┐                                
     │Cyphertext[0:15]├───┘  │Cyphertext[16:32]│                                
     └────────────────┘      └─────────────────┘                                
    {F7 60 ..... D9 00}      {D7 6B ..... 90 22}                                

Data flow of AES-CBC with known variables

What this means for us, though, is that we just need to XOR the decrypted upper 16 bytes with the encrypted lower 16 bytes to get the message.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
data = raw_data[4:].copy() # remove the length, we just want the message since ECB runs for two blocks
flash_sector_erase(ser, 0x20000) # erase the flash sector where the ECB encrypted data is stored
flash_write(ser, 0x20000, data) # write the modified data back to flash at the ECB encrypted location

# Run the solve command to trigger a decryption
# This works since IV is zero so the flag banner will be decrypted as if it were ECB passing the memory check and printing the result
decrypted_data = flash_solve(ser)

# Get the location of "MAGICLIB{Passwd:" and pull out the next 16 bytes
flag_start = decrypted_data.find(b'MAGICLIB{Passwd:')
flag = decrypted_data[flag_start:flag_start+16]
print(f"Half decrypted flag: {flag.decode(errors='ignore')}")
print(f"Other half of still mangled flag (in hex): {decrypted_data[flag_start+16:flag_start+32].hex()}")

###
### Step 3: XOR the mangled second block with the first block cyphertext to get the correct second block, which is the flag
###

# XOR the mangled second block with the first block cyphertext to get the correct second block, which is the flag
first_block_ciphertext = data[:16]
mangled_second_block = decrypted_data[flag_start+16:flag_start+32]
correct_second_block = xcrypt_xor(bytearray(mangled_second_block), first_block_ciphertext)

print(f"Correct second block (the flag): {correct_second_block.decode(errors='ignore')}")

###
### Step 4: Combine the first block and second block to get the full flag
### 

full_flag = flag[:16] + correct_second_block
print(f"Full flag: {full_flag.decode(errors='ignore')}")

Easy enough, we can do the full operation with the python interface to get the key MAGICLIB{Passwd: 53R37 0x50000}. Looks like we also got FINAL_PASSWORD as 53R37 0x50000 meaning we should be able to finish this puzzle.

>> Half decrypted flag: MAGICLIB{Passwd:
Other half of still mangled flag (in hex): d755794d6da1194eeec0ae01423ba433
Correct second block (the flag):  53R37 0x50000}
Full flag: MAGICLIB{Passwd: 53R37 0x50000}

We now have the key and the full password 53R37 0x50000 which we can put into the top of the message located at the postern address and rerun the solve command.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
...

###
### Step 5: Put the final password at the end of the message at 0x30000 and run the solve command to pass postern()
###

data = raw_data.copy()
# Get the last 14 bytes minus one cause of the bracket of the full flag, which is the password
password = full_flag[len(full_flag)-15:len(full_flag)-2]
print(f"Password to put in flash: {password.decode(errors='ignore')}")

data.extend(password)
# data.extend(b'53R37 0x50000')
flash_sector_erase(ser, 0x30000)
flash_write(ser, 0x30000, data)

flash_solve(ser)
>> Half decrypted flag: MAGICLIB{Passwd:
Other half of still mangled flag (in hex): d755794d6da1194eeec0ae01423ba433
Correct second block (the flag):  53R37 0x50000}
Full flag: MAGICLIB{Passwd: 53R37 0x50000}
Password to put in flash: 53R37 0x50000
INFO:root:Erasing sector at address 0X30000
INFO:root:Writing 49 bytes to address 0X30000
INFO:root:Reading <built-in function len> bytes from address 0X30000
Sent: DATA 03 03 00 00
20000000f7604a1f5e96397e96f59e31720bd932d76bedc8d1d1473481469a24bfaa902235335233372030783530303030ffffffffffffffffffffffffffffff
INFO:flash_commands:Sending SOLVE command
SOLVE
MAGICLIB{No one can break this! 0x20000}
MAGICLIB{Passwd:UyMmNB;3
MAGICLIB{Passwd: 53R37 0x50000}
ERROR:flash_commands:Timeout waiting for SOLVE response

Awesome, we got it to print our flag MAGICLIB{Passwd: 53R37 0x50000} which contains PLUNDER_ADDR_DATA at 0x50000 meaning we’ve passed this puzzle so we are now ready for the final puzzle and to digForTreasure().

Digging For Treasure

We are finally on the last puzzle which starts off pretty different. There is a call to digForTreasure() followed by the final check if (treasuryVisit() < 0).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
digForTreasure();

if (treasuryVisit() < 0)
{
    printf("The secret is still safe!" "\r\n");
}
else
{
    printf("THE SECRET IS REVEALED!" "\r\n");
}

The function void digForTreasure() reads a length (of max 512 bytes) and a message from PLUNDER_ADDR_DATA (0x50000) before calling treasure((char *)data, len);. This is so far our only way of entering data into this puzzle.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void digForTreasure()
{
    size_t len;
    uint8_t data[512];

    flash_read(PLUNDER_ADDR_DATA, &len, sizeof(len));

    // Make sure it doesn't overflow
    len = MIN(sizeof(data), len);

    // Read the data
    flash_read(PLUNDER_ADDR_DATA + sizeof(len), data, len);

    // Dig!
    treasure((char *)data, len);
}

treasure((char *)data, len); is just obfuscated so we’ve first got to clean it up. After a quick formatting and removal of garbage code we see that we have a Brainfuck interpreter. Lovely. Right now there still isn’t anything to go off of, but, at least we know we can run Brainfuck by putting it on flash right now.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
/// Obfuscated
void treasure(char * inst, size_t len)
{
            int ip = 0, dp = 0, sp = 0;  int stack[STACK_SIZE]={ 0 };
            while (ip<(int)len) {    {  }      switch (inst[ip++])  {
            case     '\076':{      {      }      dp+=!!!0;  break;  }
            case '\053':  {      {          }     tape[dp]++; break;}
            case '\074': {        {        }   dp+=0xffffffff;break;}
            case   '\055':{        {      }       tape[dp]--; break;}
            case    '.':    {       {    }    printf("%x", tape[dp]);
            printf(" ");            {    }      break;  }  case  '[':
            { if  (tape[dp]) {      {    }          stack[sp++] = ip;
            } else   {              {    }  uint8_t tmp = ip, depth =
            0;while(inst[tmp]){     {    }              if (inst[tmp]
            == '['){depth++;     {       }       }    else  if  (inst
            [tmp] ==']'){        {/*___*/}      if(depth == 0){tmp++;
            break; } else    {      depth -- ;}}   tmp++; } ip = tmp;
            sp--;   }    break;   }    case   ('\066' +   39 ): {  if
            (tape[dp]      )    {   ip     =   stack  [sp - 1 ];    }
            else { sp--;   }    break;  }     default:   {   break; }
            case ',':  { tape[dp] = 0/*Serial.read(  )*/  ;   break;}
            }                                                       }
}

/// Cleaned up
void treasure(char *inst, size_t len)
{
    int ip = 0, dp = 0, sp = 0;
    int stack[STACK_SIZE] = {0};
    
    while (ip < (int)len) {
        switch (inst[ip++]) {
            case '>':
                dp++;
                break;
                
            case '+':
                tape[dp]++;
                break;
                
            case '<':
                dp--;
                break;
                
            case '-':
                tape[dp]--;
                break;
                
            case '.':
                printf("%x ", tape[dp]);
                break;
                
            case '[':
                if (tape[dp]) {
                    stack[sp++] = ip;
                } else {
                    uint8_t tmp = ip, depth = 0;
                    while (inst[tmp]) {
                        if (inst[tmp] == '[') {
                            depth++;
                        } else if (inst[tmp] == ']') {
                            if (depth == 0) {
                                tmp++;
                                break;
                            } else {
                                depth--;
                            }
                        }
                        tmp++;
                    }
                    ip = tmp;
                    sp--;
                }
                break;
                
            case ']':
                if (tape[dp]) {
                    ip = stack[sp - 1];
                } else {
                    sp--;
                }
                break;
                
            case ',':
                tape[dp] = 0; // Serial.read()
                break;
                
            default:
                break;
        }
    }
}

Checking out the actual final check int treasuryVisit() still doesn’t show us much, but, it does tell us that we are running code that came from flash.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
///
/// keys.h
///
#define SUBMIT_PASSWORD     "FLAG{XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX}"

///
/// armory.c
///
static size_t println_export(const char m[])
{
    int n = printf(m);
    return printf("\r\n") + n;
}

int treasuryVisit()
{
    // Prepare fptr
    int (* fptr)(void *, char *) = (int (*)(void *, char *))code;

    // Call
    return fptr((void *)println_export, (char *)SUBMIT_PASSWORD);
}

It might help to see what code is, which a search shows that upon restart void plunderLoad() is called which assigns code. Looks like it reads the encrypted code off the flash and then decrypts it. Hmmm, not looking so good right now. Lets see check out what writes the encrypted code to flash.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void plunderLoad()
{
    struct AES_ctx ctx;
    uint8_t iv[AES_BLOCKLEN] = { 0 };
    size_t len;

    flash_read(PLUNDER_ADDR, &len, sizeof(len));

    // No overflows!!!11
    len = MIN(len, sizeof(code));
    flash_read(PLUNDER_ADDR + sizeof(len), code, len);

    AES_init_ctx_iv(&ctx, aes_key, iv);

    AES_CBC_decrypt_buffer(&ctx, (uint8_t *)code, len);
}

This looks more useful. static void prizeSetup() is called on boot, where it copies the naked function theSwordOfSecrets into code, encrypts it, and then writes it to the flash at PLUNDER_ADDR (0x50000). Well, now we have a problem. It looks like we need to modify theSwordOfSecrets(printfptr ext_println, char * flag) inside of the code variable so that if (*(volatile uint32_t *)0x08000000 == 0) is true. This sounds easy if it wasn’t for the fact this causes a circular issue. We only have access to controlling the flash, but, the code on flash is encrypted, so we need to some how inject our code into encrypted code. That won’t work, it looks like we need to take a closer look at the Brainfuck code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
typedef size_t (* printfptr)(const char *);

int __attribute__((naked)) theSwordOfSecrets(printfptr ext_println, char * flag)
{
    __asm__("sw ra, 0(sp)");
    __asm__("addi sp, sp, -4");

    if (*(volatile uint32_t *)0x08000000 == 0)
    {
        ext_println(flag);

        __asm__("li a0, 0");
    }
    else
    {
        __asm__("li a0, -1");
    }

    __asm__("lw ra, 0(sp)");
    __asm__("addi sp, sp, 4");
    __asm__("ret");
}

static void prizeSetup()
{
    uint8_t code[128];
    uint8_t iv[AES_BLOCKLEN] = { 0 };
    size_t code_len = 50;
    struct AES_ctx ctx;

    printf("Running %s...\r\n", __FUNCTION__);

    memcpy(code, (void *)theSwordOfSecrets, code_len);

    // Initialize AES context
    AES_init_ctx_iv(&ctx, aes_key, iv);

    code_len = PKCS7Pad(code, code_len);

    // Encrypt
    AES_CBC_encrypt_buffer(&ctx, code, code_len);


    // Write buffer to flash
    flash_erase_block(PLUNDER_ADDR);
    flash_write(PLUNDER_ADDR, &code_len, sizeof(code_len));
    flash_write(PLUNDER_ADDR + sizeof(code_len), code, code_len);
}

Exploiting Brainfuck

The only real control we have over this final puzzle is the input to the Brainfuck interpreter, meaning there has to be something there we can abuse. A quick scan over for potenial bugs shows one very, very, critical error. the data pointer dp isn’t clamped within the STACK_SIZE. This means we can read AND write out of bounds (OOB).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void treasure(char *inst, size_t len)
{
    int ip = 0, dp = 0, sp = 0;
    int stack[STACK_SIZE] = {0};
    
    while (ip < (int)len) {
        switch (inst[ip++]) {
            case '>':
                dp++; // dp is not checked to make sure it is within the boundry
                break;
                
            case '+':
                tape[dp]++;
                break;
                
            case '<':
                dp--;
                break;
                
            case '-':
                tape[dp]--;
                break;
            ...
        }
    }
}

All global memory on the CH32V003F4P6 is stored in the heap, which is where tape resides and where code also resides. This means the variables are typically stored right beside eachother, so, if we just read out of bounds of the array we should be able to see the contents of code. An example of how the memory is structed is below:

                                                         Ideally, the code is right beside                  
                                                                 │  where the tape array is stored          
-0x010 ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ??  ◄────────┴─────────────────┐                        
        ┌─────tape[0]                                                              ┼─We can access          
 0x000[00]00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ◄───── Start of tape array │ these by using       
                                                               in memory           │ tape[-1] and lower     
 0x010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00                             │ or tape[256] and higher
  ...                     ...                                                      │                        
 0x100 ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ??  ◄──────────────────────────┘                        

Heap memory structure showing that code *should* be beside tape

Now, I’ve never written Brainfuck before, but, I do understand enough to read and write to different parts of memory. Since both code and tape are static global varibles, they should have been allocated right near eachother, we just need to figure out if it was above or below the tape array. I know you can use loops in Brainfuck, but, I am a bit too lazy to figure that out so I chose to manually adjust the data pointer and read out the chunks of memory in hopes that we eventually find something. After running the code, we come across something maybe useful:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# We can only write 512 bytes, so this will get all 256
# bytes under the tape array
brainfk = b"<." * 256

flash_sector_erase(ser, 0x50000)
flash_sector_erase(ser, 0x50100)
flash_sector_erase(ser, 0x50200)

# Calculate size + bf data to write
full_message = ((len(brainfk)).to_bytes(4, 'little')) + brainfk

# Write in 64 byte chunks to avoid any potential issues with writing too much at once
for i in range(0, len(full_message), 64):
    chunk = full_message[i:i+64]
    flash_write(ser, 0x50000 + i, chunk)

# Now we can execute the brainfk code at 0x50000
print("Running solve command...")
print("\n\n\n\n" + flash_solve(ser).hex())
INFO:root:Erasing sector at address 0X50000
INFO:root:Erasing sector at address 0X50100
INFO:root:Erasing sector at address 0X50200
INFO:flash_commands:Sending SOLVE command
SOLVE
MAGICLIB{No one can break this! 0x50000}
MAGICLIB{Passwd:UyMmNB;3
MAGICLIB{Passwd: 53R37 0x50000}
... aa 87 2e 85 06 c0 71 11 37 07 00 08 18 43 01 00 82 97 01 45 82 40 11 01 82 80 7d 55 e5 bf aa 87 03 c7 07 00 01 e7 33 85 a7 40 82 80 85 07 cd bf 13 01 0e 0e 0e 0e 0e 0e 0e 0e 0e 0e 0e 0e 0e 0e 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ... <--- Note: I've trunicated the data because 256 is a lot.
ERROR:flash_commands:Timeout waiting for SOLVE response

Well, we have found something by reading out of bounds (aa 87 2e 85 06 c0 71 11 37 07 00 08 18 43 01 00 82 ...). This took quite a bit of time and I found a lot of garbage, but, eventually I found something that looked promising. I had to consult the RISC-V manaul to validate it, but, there does look to be some compressed insturctions in the data. Using a decompile https://ret.futo.org/riscv/ with compression enabled, we see code! Not only that, but, if we look back at theSwordOfSecrets(printfptr ext_println, char * flag) we see __asm__("sw ra, 0(sp)");, __asm__("addi sp, sp, -4");, __asm__("li a0, 0");, and more, all of which are inside this decompiled code. It appears that we’ve found a way to a solution.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
c.mv a5, a0
c.mv a0, a1
c.swsp ra, 0(sp)
c.addi sp, -4
lui a4, 0x8000
c.lw a4, 0(a4)
c.nop 
c.jalr a5
c.li a0, 0
c.lwsp ra, 0(sp)
c.addi sp, 4
c.jr ra
c.li a0, -1
c.j -8
c.mv a5, a0
lbu a4, 0(a5)
c.bnez a4, 8
sub a0, a5, a0
c.jr ra
c.addi a5, 1
c.j -0xe
addi sp, t3, 0xe0

Writing an OOB Brainfuck Exploit

Consulting the RISC-V manual on compressed code, we can see the two-byte code for NOP is 00 01, which is great because in Brainfuck we can call .>.+ to set any index to 00 01 which is only possible because the . action sets a cell to zero instead of reading user input like normal Brainfuck. Now, lets see where the jump occures so we can NOP it out.

1
2
3
4
5
6
7
8
9
c.mv a5, a0 # Stack stuff
c.mv a0, a1 # Stack stuff
c.swsp ra, 0(sp) # More stack stuff
c.addi sp, -4 # Even more stack stuff

# Here we are; load 0x8000 into a4 and load then load whatever is at 0x8000 into a4
lui a4, 0x8000 
c.lw a4, 0(a4)
c.bnez a4, 0xc # Branch if a4 is not zero!!!

Well, we have our target, now we just need to write the exploit to overwrite c.bnez a4, 0xc with a NOP. I could lie and say I did something crazy, but, in reality I just kept guessing where to write the memory at and then dumping the code to see where I wrote too. Eventually, I got the following:

1
2
3
4
brainfk = b"<" * 114 + # Move the data pointer 114 bytes backwards
          b",+>,>" + # Write NOP
          b"<"* 64 + # Move the data pointer 64 bytes backwards
          b".>" * 100 # Dump the memory

Pretty simple, only took a lot of guessing. Would have probably been easier if I wrote a bit more python to help map everything out but I knew I was close to the end so I didn’t want to do anything crazy. Running the full exploit finally gets us the flag: FLAG{The secrets of the swords are revealed!}.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
brainfk = b"<" * 114 + b",+>,>" + b">>>>>>>>" + b"<"* 64 + b".>" * 100
flash_sector_erase(ser, 0x50000)
flash_sector_erase(ser, 0x50100)
flash_sector_erase(ser, 0x50200)

full_message = ((len(brainfk)).to_bytes(4, 'little')) + brainfk

# Write in 64 byte chunks to avoid any potential issues with writing too much at once
for i in range(0, len(full_message), 64):
    chunk = full_message[i:i+64]
    flash_write(ser, 0x50000 + i, chunk)

print("Running solve command...")
print("\n\n\n\n" + flash_solve(ser).hex())
INFO:root:Erasing sector at address 0X50000
INFO:root:Erasing sector at address 0X50100
INFO:root:Erasing sector at address 0X50200
INFO:flash_commands:Sending SOLVE command
SOLVE
MAGICLIB{No one can break this! 0x50000}
MAGICLIB{Passwd:UyMmNB;3
MAGICLIB{Passwd: 53R37 0x50000}
FLAG{The secrets of the swords are revealed!} 
FLAG{The secrets of the swords are revealed!} 
FLAG{The secrets of the swords are revealed!} 
FLAG{The secrets of the swords are revealed!} 
FLAG{The secrets of the swords are revealed!} 
FLAG{The secrets of the swords are revealed!} 
...

Well, appears that thats we’ve managed to obtain the secret of the sword! For the most part, anyway. My exploit does print the key, but, it never stops printing it; I still call it a victory since the solution to that would be to inject a RET inside of the exploit code.

Final Thoughts

This was a really fun puzzle. I have never done a CTF like this before, usually it is just a remote server or a program that you are provided, so it’s really cool to do something new. The difficulty of this wasn’t too bad either, although, I do have quite a bit of previous experience with this. I still think a beginner with a little bit of help could solve all of these. My only complaint is that I wish there was more hardware invovled in solving it, but, I guess I got most of that spoiled given I have worked with these chips previously.

Now, I’ve never done a CTF write-up before, so I tried to add some extra explanation around some thing and small figures, but if something is still a bit confusing I’m sorry. If you have any feedback on formatting, or any questions, or just want to chat, feel free to reach out to me thru any of the options at the bottom of this page. Finally, thank you to Gili for making the puzzle and to you for reading thru my solution.

If you want to view any of the code, it will be made avaiable here: https://github.com/quizque/sword_of_secrets_solution