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!

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.

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.

| Name | Description |
|---|---|
| AMS1117-3.3 | 3.3v LDO regulator |
| CH32V003F4P6 | The core of the puzzle; contains quite a few accelerators and other neat features in a very small package |
| Windbond NOR 128MBIT | NOR flash for storing secrets; attached to the CH32V003F4P6 thru SPI |
| WCH CH340N | USB 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.
| |
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.
| |
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.
| |
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.
| |
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.
| |
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.
| |
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.
| |
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}
| |
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.
| |
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.
| |
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.
| |
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.
| |
| |
>> 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}.
| |
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.
| |
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.
| |
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.
| |
+========================+======================================+=====================+
| 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.
| |
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.
| |
---------------------------------------------------------
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.
| |
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.
| |
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.
| |
>> 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).
| |
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.
| |
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.
| |
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.
| |
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.
| |
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.
| |
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).
| |
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:
| |
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.
| |
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.
| |
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:
| |
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!}.
| |
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