Sword of Secrets Chat

This is a thread to discuss the Sword of Secrets virtual challenges.

Please put anything that might be a spoiler into collapsing blocks!

<hr/><details><summary>Title of collapsed sections</summary><P/>

Blank line above required, and here is the content

</details><hr/>

Title of collapsed sections

Blank line above required, and here is the content


3 Likes

Here’s a starting point: Enter the following into the prompt.

Starting point

DATA 99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999YOU WIN!

(Note: that is all a single, really long line)

That is a starting point. :slight_smile:

Other things to try

DATA 0 1 2 3
MAGICLIB 0 1 2 3
SOLVE

Coupled with the source at the Github page, that should get you started.

The above should expose you to more than one interesting feature.

Oh, and the all-important: RESET

Note: Not all the “commands” I listed are actual commands… but you should deduce that from the result of SOLVE by itself.

2 Likes

And if you want more structured reasoning on the why…


Where are the commands?


Example working command sequence (Major Spoiler!)

I would expect to be able to read the flash ID with the following sequence:

BEGIN
ASSERT
DATA 9F
DATA 0 0 0 0 0
RELEASE
END

This is based on the flash_read_id() in the Sword’s github repo.


References

BusPirate documentation on using SPI Flash Chips.


2 Likes

Major Steps Spoiler - Both Challenges
You can read from the (virtual) SPI EEPROM
  • Look at the source on github to discover the commands that are supported, and how they are processed.
  • The source on github will also show a sequence that can be used to read from the flash (in code … not the console).
  • Convert that into a a sequence of commands.
Precise command sequence to read from (virtual) SPI EEPROM

Let’s presume you want to start reading from the flash at address 0xAABBCC.

REM - `END` is optional, unless there was a prior command
END
REM - `RELEASE` is the opposite of `ASSERT` and ensures
REM - the SPI chip sees a transition from non-selected to selected
RELEASE
REM - `ASSERT` sets `/CS` low ... selecting the flash chip on the SPI bus
ASSERT
REM - `BEGIN` sends the start bit on the SPI bus
BEGIN
REM - `DATA` sends bytes over SPI
REM - First, send the four-byte command to read from the address
DATA 03 AA BB CC
REM - Then, read 32 bytes of data at a time
DATA 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
REM - and then the next 32 bytes of data ...
DATA 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
REM - Repeat ad nauseum as required

NOTE: REM is not a valid command, and used solely to comment the code

I used an AdaFruit MacroPad to generate a script that would dump all 128k of the (virtual) SPI EEPROM. Each run of the script took about an hour.

Then, I discovered I could not actually copy/paste from the text output. Luckily, the debug console had a copy of all the output, and could be copy/pasted into VSCode.

Couple that with a long sequence of search’n’replace, and I was able to find all locations in the (virtual) SPI EEPROM that stored data.


Major Steps Spoiler - Original Challenge
There is only data in one location:

( Address, ByteCount ) = 0x010000, 0x29

Actual Data at that location
0x010000 00 00 00 00 0e 05 13 07 36 0f 37 69 22 27 3f 65
0x010010 2e 20 36 69 2f 3b 3f 24 26 61 2c 21 24 3a 7b 65
0x010020 7d 39 6a 79 7d 79 6a 38 4d ff ff ff ff ff ff ff

Major Steps Spoiler - Golden Challenge
There is only data in one location:

( Address, ByteCount ) = 0x002000, 0xAA

Actual Data at that location
0x002000: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x002010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x002020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x002030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x002040: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x002050: 00 00 00 00 00 54 68 69 73 20 69 73 20 6e 6f 74
0x002060: 20 74 68 65 20 66 6c 61 67 2e 20 53 65 61 72 63
0x002070: 68 20 68 61 72 64 65 72 21 20 49 20 6b 6e 6f 77
0x002080: 20 79 6f 75 20 63 61 6e 20 64 6f 20 62 65 74 74
0x002090: 65 72 2e 2e 2e 20 54 72 75 73 74 20 69 6e 20 79
0x0020A0: 6f 75 72 73 65 6c 66 20 3a 29 ff ff ff ff ff ff

2 Likes

Update:

I’ve solved Stage 1 using the online emulator.
I believe I’ve solved Stage 2 also, but … something
is still off.


To reduce scrolling, spoilers hidden

The decrypted message indicates the location
on the flash where the second stage’s data is.

Updated 2026-01-18: Workaround for emulator bug
(or misconfiguration).

The emulated flash chip does not properly support all the erase commands. I filed an issue in the SoS github repo, but it’s probably not their own code for emulating that device.

The only erase command that appears to work for the emulated device is the sector erase command.

Updated thoughts on Stage 2:

Stage 2 should be much easier.
Solving Stage 2 also requires writing to the flash chip.
I know the data to write, can erase the chip, and write
the updated data. However, for reasons I haven’t
understood yet, my updated data is not causing the
behavior I expected.

Initial thoughts on Stage 3:

This is where the cryptography portion gets interesting.
In addition, without understanding and writing a customized
attack script of some sort, this stage will take significant time.

It’s non-trivial to script the website interaction. I did some
bits of the earlier stages using a programmable keyboard
(the Adafruit MacroPad), but for stage 3, I think I’ll wait for
the physical board, so I can open the COM port directly
from Python, which should allow me to react programmatically
to the returned data.

For the Golden challenge …

I’ve also noticed a nice “naked” hack for the Golden firmware. Looking forward to that challenge as well…


1 Like

Major corrections to "Major Steps Spoiler"

For the virtualized version of the challenge, after dumping the flash
(via scripts), there is data at multiple locations, not just one.

Earlier, I had a bug in my flash dump script that made it unreliable.

That said, the two locations I listed data for did contain the data I originally listed.

For the Golden challenge, it also likely has more data somewhere.

I would need to re-dump both of the flash chips to be sure. However, I’m getting the physical product in a couple weeks, so likely will just wait until then, as it’ll be much easier to simply sniff the SPI bus with the BusPirate.


1 Like

If someone wants:

  • A full explanation and walk-through for Stage 1 / Stage 2
  • List of particular skill needed for Stage 1, Stage 2, or Stage 3, or endgame
  • Some other particular (and related) questions answered

Then just post your question here. I have not only the intended solutions mapped out for stages 1-3, but I also have some … likely unintended / creative … solutions. :smiling_face_with_horns:

Please remember to use collapsible sections (as described in the first post), as even questions may expose hints.

1 Like

I enjoy following your spoilers :slight_smile: Really don’t have a clue how challenges like this are supposed to work.

Turns out the website version doesn’t have later stage solution detection implemented, even though the flash includes the relevant data. That explains why my Stage 2 solution wasn’t working. :joy:

I should get the physical Sword of Secrets near the start of Feb, where I should be able to fast-forward through Stages 1 and 2, and hopefully stage 3.


Spoilers - Command List

First, the five commands needed to send commands on the SPI bus:

  • "BEGIN" - enables the SPI controller (directly maps to SPI_init(), SPI_begin_8())
  • "END" - disables the SPI controller (directly maps to SPI_end())
  • "ASSERT" - Sets PC4 (connected to SPI flash /CS) low, so SPI flash chip interprets and responds to data
  • "RELEASE" - Sets PC4 (connected to SPI flash /CS) high, so SPI flash chip ignores data
  • "DATA" - Converts rest of input to hex bytes, and calls SPI_transfer_8() for each byte. For each byte sent, prints the data received from SPI flash chip.

The additional commands used:

  • "RESET" - Start over. Restores flash data to original data and resets count of SOLVE attempts.
  • "REBOOT" - Reboots the Sword of Secrets mcu, without rebooting the SPI flash chip. :smiling_face_with_horns:
  • "SOLVE" - Sends all characters after "SOLVE" to challenges() (extra characters ignored for original, used for GOLD)

Spoiler - Internal name for Stage 1

Stage 1 corresponds to Palisade


Spoilers - Flash locations for Stage 1

Stage 1 - It’s in the opening message:

0x0001'0000


Spoilers - Key skills for Stage 1

Optional but may provide some shortcuts with a physical board:

  • SPI protocol analyer
  • SPI flash dump tool

Useful for all stages

  • Ability to read and understand C source code
  • Ability to encode and decode data transfers to/from an SPI flash module.
  • A keen mind

Particularly useful for the stage:

  • Binary algebra (binary AND, binary OR, binary XOR, binary NOR, etc.).

1 Like

(SPOILERS!) Stage 1 - Full Walkthrough (SPOILERS!)

Expectations for Stage 1

Expectations for Stage 1

You interact with this via a terminal. (USB serial port; virtual has a web-based CLI).
It is expected that you will review the source code, and thus find the commands that
can be sent, and what they do.

It is also expected that you will have the datasheet for the SPI flash chip,
which shows what commands are supported by that chip.


Verify communication with SPI flash chip

Verify communication with SPI flash chip

First thing to do is very basic connectivity to the SPI flash chip.
Let’s try to send the 9Fh command, to get the chip ID from the chip.
This requires a sequence of commands:

  1. BEGIN – this enables the SPI controller, preparing it for one-byte transfers.
  2. ASSERT – tells the flash chip to pay attention to the data line … essentially enabling the chip (/CS)
  3. DATA 9F – Sends the command bytes to the SPI flash chip. Returned data should be zeros.
  4. DATA 0 0 0 – Sends dummy bytes via SPI, allowing the flash chip to send back the data for the command.
  5. RELEASE – tells the flash chip to stop paying attention to the data line. (/CS)
  6. END – disable the SPI controller

At step 4, you should see the flash chip’s ID.

Example console log from website version:

>> BEGIN

>> ASSERT

>> DATA 9F
00 

>> DATA 0 0 0
ef 40 18 

>> RELEASE

>> END


Review the Source Code

Review the Source Code

Start with parsing of your input. You’ve found the commands, so what code
is run when you type SOLVE? We’re focusing on the first challenge, so
the relevant code is in palisadeSetup() and palisade() Take a moment
to go though those, and also review the function, xcryptXor().


Review your Binary Algebra

Review your Binary Algebra

Using a single bit value, XOR is defined simply as true when one or the
other inputs is true, but false if both inputs are true. One interesting
facet is that XOR is reversible … which allows the xcryptXor() function
to perform the same process for both encryption and decryption.


What is secret? What is not?

What is secret? What is not?

For stage 1, the address where stage 1 (palisade) data is stored
is NOT a secret. In fact, the opening screen flat out tells you
that you are at 0x10000.

In addition, FLAG_BANNER is not a secret … it’s defined as the
eight-byte value "MAGICLIB".

The message in palisadeSetup() is converted to ciphertext by
xcryptXor(), and then written to the stage 1 flash address.


Problem to be solved

Problem to be solved

Your job is to get the palisade() function to validate the
data, which will cause it to print the plaintext version of that
message.

In palisadeSetup(), the original message is:

char message[] = FLAG_BANNER "{No one can break this! " S(PARAPET_FLASH_ADDR) "}";
// which becomes:
char message[] = "MAGICLIB{No one can break this! " S(PARAPET_FLASH_ADDR) "}";
//                ....-....1....-....2....-....3..  ?????????????????????  ..
char message[] = "MAGICLIB{No one can break this! 0x*****}";
//                ....-....1....-....2....-....3....-....4 == 40 bytes (0x38)

If that was written to flash, should decrypt and palisade() to print the
plaintext. However, the first four bytes of the ciphertext get (intentionally)
corrupted, just before being written:

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

Our job is therefore to fix the ciphertext on the flash, so that
when palisade() reads and decrypts it, the string will start
with FLAG_BANNER, aka "MAGICLIB".


Read the stage 1 ciphertext

Read the stage 1 ciphertext

Console log showing the 0x29 bytes of ciphertext generated
by palisadeSetup(). Maybe more bytes, as cannot tell the
difference between data of 0xFF vs. unused flash (default
for erased flash is 0xFF. That’s Ok, we actually need
quite a bit less.

>> BEGIN

>> ASSERT

>> REM send read (03h) for 0x010000

>> DATA 03 01 00 00
00 00 00 00 

>> REM following lines read 16 bytes each

>> DATA 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
00 00 00 00 0e 05 13 07 36 0f 37 69 22 27 3f 65 

>> DATA 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
2e 20 36 69 2f 3b 3f 24 26 61 2c 21 24 3a 7b 65 

>> DATA 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
7d 39 6a 79 7d 79 6a 38 4d ff ff ff ff ff ff ff 

>> RELEASE

>> END

Exploiting weakness to get xor_key

Exploiting weakness to get `xor_key`

We know most of the plaintext. Out of 0x29 values,
only five bytes near the end (as the flag) have unknown values.

00000000 4D 41 47 49 43 4C 49 42 7B 4E 6F 20 6F 6E 65 20  MAGICLIB{No one
00000010 63 61 6E 20 62 72 65 61 6B 20 74 68 69 73 21 20  can break this!
00000020 30 78 __ __ __ __ __ 7D 00                       0x?????}

Similarly, the ciphertext is nearly completely known,
except for the first four bytes that were intentionally
corrupted.

00000000 __ __ __ __ 0E 05 13 07 36 0F 37 69 22 27 3F 65
00000010 2E 20 36 69 2F 3B 3F 24 26 61 2C 21 24 3A 7B 65
00000020 7D 39 6A 79 7D 79 6A 38 4D

Recall that XOR is reversible:

A ^ B ^ B === A, for all values of A and B.

Let:

  • P stands for plaintext
  • C stands for ciphertext
  • K stands for the key

The flash stores the ciphertext:

  • C[0..3] === 00h due to intentional corruption
  • C[N] === P[N] ^ K[N%8], for N between 0x04 and 0x29

And what we want to get is the eight-byte key, K.

Let’s take a look at the byte at offset 0x28 first:

C[0x28] == P[0x28] ^ K[0x28%8]
0x4D    == 0x00 ^ K[0]
0x4D    == K[0]

Well, that was almost too easy …
and we now know the first byte of the key is 0x4d == 'M'

Let’s now take a look at … byte offset 0x11:

C[0x11]     == P[0x11] ^ K[0x11%8]
0x20        == 0x61 ^ K[1]
// apply same operation to both sides of equal sign...
0x20 ^ 0x61 == 0x61 ^ K[1] ^ 0x61
// simplify
0x41        == K[1] == 'A'

In fact, if you simply XOR the ciphertext-on-flash
with the plaintext, you get:

00000000 4D 41 47 49 4D 49 5A 45 4D 41 58 49 4D 49 5A 45  MAGIMIZEMAXIMIZE
00000010 4D 41 58 49 4D 49 5A 45 4D 41 58 49 4D 49 5A 45  MAXIMIZEMAXIMIZE
00000020 4D 41 58 49 4D 49 5A 45 4D                       MAXIMIZEM

Which quickly makes the xorcrypt() eight-byte key obvious.


Generating the corrected ciphertext

Generating the corrected ciphertext

As you recall, palisadeSetup() overwrote the first four
bytes of ciphertext with zero bytes.

You simply need to generate the corrected first four bytes.
Since the key and plaintext are known, this is trivial:

Plaintext  4D 41 47 49 43 4C 49 42 7B 4E 6F 20 6F 6E 65 20  MAGICLIB{No one
xor_key    4D 41 58 49 4D 49 5A 45 4D 41 58 49 4D 49 5A 45  MAXIMIZEMAXIMIZE
RESULT     00 00 1F 00 0E 0E 05 13 07 36 0F 37 69 22 27 3F 65

Now we know the values to write to that location in the
flash chip, and palisade() should be happy!

00000000  00 00 1F 00 0E 05 13 07 36 0F 37 69 22 27 3F 65
00000010  2E 20 36 69 2F 3B 3F 24 26 61 2C 21 24 3A 7B 65
00000020  7D 39 6A 79 7D 79 6A 38 4D FF FF FF FF FF FF FF

Writing to SPI Flash

Writing to SPI Flash

Read through the table listing all the commands the W25Q
supports. Notice the write command (02h = PROGRAM PAGE).

That won’t work (yet) because flash can only shift bits
from 1 -> 0. So you’ll have to erase that area of
the flash first, resetting it to 0xFF.

If you’ve not played with flash before, note that while
any bit can be changed from 1 -> 0 at any time,
erase operations operate on larger sections at a time.

Thus, erasing the first four bytes will also erase the
remaining ciphertext.


First try erasing the sector

First try erasing the sector

Let’s send the Sector Erase command, and then re-read
the data. Here’s a console log:

>> BEGIN

>> REM send SECTOR ERASE (20h)

>> ASSERT

>> DATA 20 01 00 00
00 00 00 00 

>> RELEASE

>> REM send READ DATA (03h)

>> ASSERT

>> DATA 03 01 00 00
00 00 00 00 

>> DATA 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
00 00 00 00 0e 05 13 07 36 0f 37 69 22 27 3f 65 

>> DATA 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
2e 20 36 69 2f 3b 3f 24 26 61 2c 21 24 3a 7b 65 

>> DATA 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
7d 39 6a 79 7d 79 6a 38 4d ff ff ff ff ff ff ff 

>> RELEASE

>> END

Bummer, the erase didn’t work,
since the data is still there.


Erasing the sector, take two

Erasing the sector, take two

Is there another command in that table that might need
to be look at? Status registers might indicate some type
of locking of the pages … but they all read as 00h, so
that’s not it.

Aha! Check out the ENABLE WRITE (06h)
command. That description sounds promising. Here’s another
console log showing the commands:

>> BEGIN

>> REM send that command

>> ASSERT

>> DATA 06
00 

>> RELEASE

>> REM immediately send SECTOR ERASE (20h)

>> ASSERT

>> DATA 20 01 00 00
00 00 00 00 

>> RELEASE

>> REM and verify the data is gone

>> REM send READ DATA (03h)

>> ASSERT

>> DATA 03 01 00 00
00 00 00 00 

>> DATA 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF

>> DATA 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF

>> DATA 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF

>> RELEASE

Success! That sector is now erased, and
all bytes are 0xff.


Writing the updated data

Writing the updated data

So the flash is successfully erased. By now, you should
have this well in hand. Note that the ENABLE WRITE
command has to be sent prior to EVERY write … because
it auto-disables after each such write.

Here’s a console log writing the updated data:

>> ASSERT

>> DATA 02 01 00 00
00 00 00 00 

>> DATA 00 00 1F 00 0E 05 13 07
00 00 00 00 00 00 00 00 

>> DATA 36 0F 37 69 22 27 3F 65
00 00 00 00 00 00 00 00 

>> DATA 2E 20 36 69 2F 3B 3F 24
00 00 00 00 00 00 00 00 

>> DATA 26 61 2C 21 24 3A 7B 65
00 00 00 00 00 00 00 00 

>> DATA 7D 39 6A 79 7D 79 6A 38
00 00 00 00 00 00 00 00 

>> DATA 4D FF FF FF FF FF FF FF
00 00 00 00 00 00 00 00 

>> RELEASE

>> END

Solve!

Solve!

Use the SOLVE command to see if parapet() was happy
with your changes.

>> SOLVE
MAGICLIB{No one can break this! 0x20000}
It is now time to reveal the REAL secrets... :)



1 Like

Wow, what an adventure! I don’t think I would be able to solve that.

But that’s only the first stage!


SPOILERS - Second stage skills

The second stage is actually almost trivial, once you understand how the “ECB” mode of AES works:

  • Each 0x10-byte block is independently encrypted / decrypted.
  • Only the last block is required to have the PKCS7 padding.
  • Thus, you can shuffle all except the last 0x10-byte blocks around.
  • If you can alter the expected data length to be decrypted, you can even move the last block to match that altered data length.

Imagine if payroll records had the following format:

typedef struct _payroll_record_t {
    uint64_t employeeId;
    uint64_t banking_details;
    uint64_t payment; // simplicity ... in cents
    uint64_t zero; // padding
    date_time64_t dateEarned;
    date_time64_t datePaid;
} payroll_record_t;

If an array of those were formatted using AES in ECB mode:

employeeId + banking_details would be the same 0x10 bytes of plaintext for a given employee, and thus would always encrypt to the same ciphertext.

payment + zero would be the next 0x10 byte block of plaintext, and thus could be swapped.

I might not initially know which ciphertext corresponds to the president of the company’s employeeId, but maybe the IDs were assigned sequentially and the records are sorted by that ID.

Change your own banking details once, and you reduce the set of possible ciphertext for your ID to those folk who changed banking details at the same time. Do it 2-3 times, and you’ve found the ciphertext for your employeeID + banking_details.

Swap the payment block with someone else’s payment block before payroll cuts checks, and get their pay instead of yours. Swap payment block back to your original ciphertext before year-end summaries go out, and havoc for the accountants.

In short, ECB mode for AES is a very, very, very bad idea to use.


That’s all you need to understand, in order to solve Stage 2.

1 Like

I finally received confirmation that my Sword of Secrets is on its way. [[US shipments got delayed for … reasons folks can guess.]] I can’t wait to validate my solutions, and hear of others’ journey. It’s quite a nice set of flags to be captured…

2 Likes

Saving on erase cycles

Sometimes, you just need to cycle through all 256 possible one-byte values … on flash. Brute force would take 255 erase cycles (slow). Let’s make it take 70 erase cycles (fast!):


For the die-hard math geeks...

the search phrases you want are:

  • 8‑bit Boolean lattice
  • symmetric chain decomposition
  • **monotone constraints (unidirectional change of bits in all chains)

It was already a solved problem, nothing really new from me.


Efficient flash walk

FLASH_WALK = bytes([
    0xFF, 0xFE, 0xFC, 0xF8, 0xF0, 0xE0, 0xC0, 0x80, 0x00, 0x7F, 0x7E, 0x7C, 0x78, 0x70, 0x60, 0x40,
    0xBF, 0xBE, 0xBC, 0xB8, 0xB0, 0xA0, 0x20, 0xDF, 0xDE, 0xDC, 0xD8, 0xD0, 0x90, 0x10, 0xEF, 0xEE,
    0xEC, 0xE8, 0xC8, 0x88, 0x08, 0xF7, 0xF6, 0xF4, 0xE4, 0xC4, 0x84, 0x04, 0xFB, 0xFA, 0xF2, 0xE2,
    0xC2, 0x82, 0x02, 0xFD, 0xF9, 0xF1, 0xE1, 0xC1, 0x81, 0x01, 0x3F, 0x3E, 0x3C, 0x38, 0x30, 0x5F,
    0x5E, 0x5C, 0x58, 0x50, 0x6F, 0x6E, 0x6C, 0x68, 0x48, 0x77, 0x76, 0x74, 0x64, 0x44, 0x7B, 0x7A,
    0x72, 0x62, 0x42, 0x7D, 0x79, 0x71, 0x61, 0x41, 0x9F, 0x9E, 0x9C, 0x98, 0x18, 0xAF, 0xAE, 0xAC,
    0xA8, 0x28, 0xB7, 0xB6, 0xB4, 0xA4, 0x24, 0xBB, 0xBA, 0xB2, 0xA2, 0x22, 0xBD, 0xB9, 0xB1, 0xA1,
    0x21, 0xCF, 0xCE, 0xCC, 0x8C, 0x0C, 0xD7, 0xD6, 0xD4, 0x94, 0x14, 0xDB, 0xDA, 0xD2, 0x92, 0x12,
    0xDD, 0xD9, 0xD1, 0x91, 0x11, 0xE7, 0xE6, 0xC6, 0x86, 0x06, 0xEB, 0xEA, 0xCA, 0x8A, 0x0A, 0xED,
    0xE9, 0xC9, 0x89, 0x09, 0xF3, 0xE3, 0xC3, 0x83, 0x03, 0xF5, 0xE5, 0xC5, 0x85, 0x05, 0x1F, 0x1E,
    0x1C, 0x2F, 0x2E, 0x2C, 0x37, 0x36, 0x34, 0x3B, 0x3A, 0x32, 0x3D, 0x39, 0x31, 0x4F, 0x4E, 0x4C,
    0x57, 0x56, 0x54, 0x5B, 0x5A, 0x52, 0x5D, 0x59, 0x51, 0x67, 0x66, 0x46, 0x6B, 0x6A, 0x4A, 0x6D,
    0x69, 0x49, 0x73, 0x63, 0x43, 0x75, 0x65, 0x45, 0x8F, 0x8E, 0x0E, 0x97, 0x96, 0x16, 0x9B, 0x9A,
    0x1A, 0x9D, 0x99, 0x19, 0xA7, 0xA6, 0x26, 0xAB, 0xAA, 0x2A, 0xAD, 0xA9, 0x29, 0xB3, 0xA3, 0x23,
    0xB5, 0xA5, 0x25, 0xC7, 0x87, 0x07, 0xCB, 0x8B, 0x0B, 0xCD, 0x8D, 0x0D, 0xD3, 0x93, 0x13, 0xD5,
    0x95, 0x15, 0x0F, 0x17, 0x1B, 0x1D, 0x27, 0x2B, 0x2D, 0x33, 0x35, 0x47, 0x4B, 0x4D, 0x53, 0x55,
])

Using the flash walk table

Simply start at the first entry (0xFF), do what needs to be done, and when you need the next value, just grab the next index. If the next value continues the chain (old & new == new), you can skip the erase cycle. Just write the one byte of data (or write a page of all 0xFF except the byte you’re changing) … and you’ve just saved an erase cycle. Rinse, lather, repeat … and you can test all 256 values in just 70 erase cycles.


That’s all folks. Why in this thread? Why indeed…

2 Likes

That’s really cool! Makes perfect sense but it wasn’t obvious to me at all.

1 Like

This is a spoiler-free post.

Well… I’ve had a day to poke at the real hardware. Not everything went well.


Status updates

  1. Created a python-based terminal using LLM
    • 1st prompt to Copilot.microsoft.com … to get list of questions that I should answer
    • 2nd prompt to Copilot.microsoft.com … answering ~10 questions in detail, requesting it generate a prompt for Claude to generate the project automagically
    • 3rd prompt in VSCode to Claude Opus 4.6 in agent mode
    • … and it worked!
  2. Stage 1 solution confirmed
  3. Stage 2 solution confirmed
  4. Used one of the LLMs help reduce erase cycles
  5. Minor improvements to the terminal
    • I manually refactored / deduplicated some helper functions for reading from the serial stream
    • No pass-through of unrecognized commands
    • Configure line ending as ‘\n’ instead of ‘\r\n’
    • Added first SoS-specific command: read_flash
  6. Diagnosed hardware issue
    • Although must connect at 115200, mcu was unstable when interacting with it at that high speed.
    • Adding configuration to “drip-feed”, with configurable per-byte delay, initially set to 1ms
  7. Added wrapper to simplify sending commands to SoS, and getting the output from those commands. I can’t stress how important this was!
  8. Added command to solve stages 1 & 2, and initialize stage3 data to have valid encrypted data
  9. Frustrating learning opportunities while writing solver for stage3
    • Learned to always start flash writes at page boundary (256 bytes)
    • Stability of MCU … Increased per-byte tx delay to 10ms
    • eventually got it all working… but slower than expected due to 10ms per-byte tx delay…

It’s been running now solving stage3 for about 2 hours, and is almost halfway done. Hopefully, I won’t have any more hardware issues, so I can break this thing open today!

And … success! Working through stage4 (the final stage) now…

And … success! My pre-work from the online emulator bore much fruit, and I verified at least two of the stage 4 solutions.

Very nice CTF … with nothing but flags and encryption keys hidden. Strongly thumbs-up! :+1: :+1:

1 Like