Sword of Secrets writeup

Created at , last modified at in «Computers»

The Sword of Secrets is a physical capture-the-flag challenge launched on CrowdSupply last year. It consists of a custom-built physical device with a microcontroller that we are supposed to hack into and solve a series of challenges to get the final flag.

First, we are given a webpage with a simple Getting Started guide. This essentially just points to the repository. This repository itself contains the hardware design files and what seems to be the entire firmware source code for the device.

Connecting the device to a computer yields a CH34x USB-to-serial adapter interface. On this interface, we are greeted with a splash screen

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                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?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Reading the main.c file reveals that there is a main loop that reads commands from a shell on the serial interface and then executes them. Browsing through the code for the "SOLVE" subcommand reveals that all challenges read something from the onboard flash memory.

There is also a bunch of raw SPI commands available that allow us to rather mechanically drive the SPI interface.

  • BEGIN - initializes the SPI interface,
  • ASSERT - asserts the chip select line,
  • DATA ... - this performs a full-duplex SPI transfer, printing out the received data
  • RELEASE - deasserts the chip select line,
  • END - deinitializes the SPI interface.

It is clear that I need to learn to dump the flash memory. As a first test, I found the flash_read_id function and attempted to replicate it using the SPI commands:

>> BEGIN

>> ASSERT

>> DATA 9F 00 00 00
ff ef 40 18

The ef byte response corresponds to Winbond, which checks out with the actual flash chip installed on the device.

I wrote a simple tool that allows me to read out the entire flash memory and dumped it to a file. It's worth noting that there is no input buffering in the UART of the MCU, so it is necessary to shove commands slowly to avoid overflowing the input.

Palisade

Each challenge consists of a "setup" function, which writes the challenge data to the flash memory and a "solve" function which gets executed on each SOLVE command. The solve function usually reads data from the flash memory, processes it in some way and if the result matches it prints out the flag and resumes to the next challenge.

For the first challenge, the setup code is as follows:

static void palisadeSetup()
{
    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;

    xcryptXor((uint8_t *)message, len);

    // Oh noes! Something happened... X_X
    *((uint32_t *)(message)) = 0x00000000; // random(0xffffffff);

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

and the solve code is as follows:

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

    flash_read(PALISADE_FLASH_ADDR, message, sizeof(message));

    xcryptXor((uint8_t *)message, sizeof(message));

    if (memcmp(message, FLAG_BANNER, strlen(FLAG_BANNER)))
    {
        goto error;
    }

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

    err = 0;
error:
    return err;
}

This challenge reads out 128 bytes from the flash memory, XORs them with a key (which is not provided in the source dump), checks whether the result starts with MAGICLIB and if so, prints it out. The flag also contains the address of the flash memory where the next challenge data is located (the addresses are also redacted from the flash dump).

So, we simply have to

  1. Read out the 128 bytes from flash
  2. xor the known parts with the acquired data to extract the key
  3. figure out what the first four bytes of the message should have been (we know that the plaintext starts with MAGI, so we just need to encrypt it)
  4. write the altered message back to the flash memory

This also reveals that we have to and are expected to alter the data in the flash memory in order to progress.

Performing these steps yields the address for the next challenge (and the flag of the first challenge).

Parapet

Here we get setup that writes an AES-ECB encrypted message to the flash memory:

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
    flash_erase_block(PARAPET_FLASH_ADDR);
    flash_write(PARAPET_FLASH_ADDR, message, len);
}

and code that reads it out and validates that the decrypted message starts with the MAGICLIB flag banner.

int parapet()
{
    int err = -1;
    struct AES_ctx ctx;
    char message[128];

    flash_read(PARAPET_FLASH_ADDR, message, sizeof(message));

    AES_init_ctx(&ctx, aes_key);

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

    if (memcmp(message, FLAG_BANNER, strlen(FLAG_BANNER)))
    {
        goto error;
    }

    message[AES_BLOCKLEN * 2] = '\0';

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

    err = 0;
error:
    return err;
}

By default, it obviously won't start with the desired prefix, since the setup code prefixes everything with the Important... string.

This all looks a bit more daunting, but the trick is to simply observe that

len("Important message to transmit - ") == 32

This means that we can simply drop the first two blocks in the flash, move the second two blocks to the beginning of the flash and then, after the solve code decodes the message, it is automatically going to start with the flag banner and get printed out.

Postern

This is a variation of the previous challenge, but now the two-block message is encrypted using AES-CBC and the first block is slightly corrupted by the setup code.

static void posternSetup()
{
    struct AES_ctx ctx;
    uint8_t iv[AES_BLOCKLEN] = { 0 };
    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';

    // 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);
}

The solve code does not check for the flag banner, but requires us to load a part of the decrypted ciphertext into the flash and check that it matches the expected plaintext.

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));

    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);;

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

    if (checkPKCS7Pad((uint8_t *)message, len) < 0)
    {
        err = 1;

        goto error;
    }

    message[len - message[len - 1]] = '\0';

    // Chet everything's OK!
    if (memcmp(response, FINAL_PASSWORD, strlen(FINAL_PASSWORD)))
    {
        goto error;
    }

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

    err = 0;
error:
    return err;
}

The solution here is to observe that the previous challenge uses the same key! This means that we can decrypt any block (well, hoping the plaintext does not contain the zero byte) by shoving it in the previous challenge as the second block. This then gets printed out to the terminal as raw bytes. Of course we are in CBC mode, so we have to XOR the decrypted second block with the first block to get the actual plaintext.

The only problem is that we are missing the last byte of the first block (otherwise the PKCS7 padding check does not even pass). But, PKCS7 padding only depends on the length of the plaintext (which is conveniently 31 bytes), so we can figure out what the last byte of the first block is supposed to be by XORing the expected PKCS7 padding byte (0x01) with the last byte of the decrypted second block.

Treasure

The last challenge does not exactly follow the same pattern as the previous ones and is a bit more misdirecting.

The main point is that the following code is executed on raw (unencrypted!) data from the flash:

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;}
    }                                                       }
}

I am not going to replicate the deobfuscated code, but it decodes to a simple brainfuck interpreter. After setup, the brainfuck code part in the flash memory is empty, so the interpreter just executes nothing and then continues to the code that prints out the challenge. There are some bugs in the interpreter, notably in the loop handling, but that turned out to be unimportant for this challenge.

Most notably, it does not perform any bounds check on the statically allocated tape, meaning it is possible to dump nearby memory. This is all somewhat non-trivial, since in brainfuck, a forever loop requires non-zero value on the tape, meaning we also corrupt the memory when dumping it.

I was able to use various variants of

,+[>.,+]

to dump at least nearby memory of the tape variable (the processor gets stuck after a few hundred bytes, probably after the brainfuck interpreter corrupts something important). Some experimentation showed that the linker does tend to place the AES key nearby, so I was hoping to be able to dump it (the reason for wanting the key will become apparent in the next section).

I just collected the dumped bytes and then brute-forced the key offset by attempting to decrypt the Parapet challenge data until I got the expected plaintext.

Sword of Secrets

After running the brainfuck code, the next part just calls a function pointer:

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

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

This code gets loaded from flash memory and AES-decrypted using the same key as the previous two challenges. The reference code that is loaded to the flash memory by the setup looks like this:

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");
}

The 0x08000000 check is just a red herring. This is the microcontroller flash memory where the reset vectors are stored. Even if we were somehow able to overwrite it, it would make the system no longer startable.

Luckily, we got the AES key used to encrypt this code from the previous challenge, so I can replace it with a simple function that just unconditionally prints the flag.

int theSwordOfSecrets(printfptr ext_println, char * flag)
{
    ext_println(flag);
    return 0;
}

Now, running the SOLVE command completes successfully and prints out the final flag.

It's worth noting that the address of the shellcode is not actually provided anywhere, but considering the patterns so far, I was able to take a reasonable guess.