Practice Math!
Introduction
The NorthSec 2023 CTF (Tie) Badge is an Xtensa ESP32 based system. How to reverse that is covered in the Intro.
When we connect to the serial console of the badge, we see the following (trimmed to fit):
:::. :::. ... :::::::.. :::::::::::: :: .: .::::::..,:::::: .,-:::::
`;;;;, `;;; .;;;;;;;. ;;;;``;;;;;;;;;;;;'''',;; ;;,;;;` `;;;;'''' ,;;;'````'
[[[[[. '[[,[[ \[[,[[[,/[[[' [[ ,[[[,,,[[['[==/[[[[,[[cccc [[[
$$$ "Y$c$$$$$, $$$$$$$$$c $$ "$$$"""$$$ ''' $$$"""" $$$
888 Y88"888,_ _,88P888b "88bo, 88, 888 "88o88b dP888oo,__`88bo,__,o,
MMM YM "YMMMMMP" MMMM "W" MMM MMM YMM "YMmMY" """"YUMMM "YUMMMMMP"
nsec> help
<...>
math
Practice math!
<...>
[0](Ghroth)❯ miniterm.py -f direct /dev/ttyUSB0 115200
nsec> math
9 - 4 = 5
Correct!
3 - 2 = 1
Correct!
2 + 2 = 77
Incorrect. The correct answer is 4.
nsec>
Strings
IDA has a string search function, and we have a bunch of strings to search for:
seg000:3F41D3A1 0000000B C %d + %d =
seg000:3F41D3AC 0000000B C %d - %d =
seg000:3F41D3B7 0000000B C %d * %d =
seg000:3F41D3C2 00000009 C Correct!
seg000:3F41D3CB 00000026 C Incorrect. The correct answer is %d.\n
seg000:3F41D3F7 0000000A C {;'\b|!~:k
seg000:3F41D406 0000000F C Practice math!
seg000:3F41D415 00000015 C void register_math()
seg000:3F41D42A 00000023 C ./components/cmd_math/cmd_math.cpp
Which leads us to
.rodata:3F40D3A1 aDD .ascii "%d + %d = ",0 ; DATA XREF: .flash.text:40105B68↓o
.rodata:3F40D3AC aDD_0 .ascii "%d - %d = ",0 ; DATA XREF: .flash.text:40105B6C↓o
.rodata:3F40D3B7 aDD_1 .ascii "%d * %d = ",0 ; DATA XREF: .flash.text:40105B70↓o
.rodata:3F40D3C2 aCorrect .ascii "Correct!",0 ; DATA XREF: .flash.text:40105B80↓o
.rodata:3F40D3CB aIncorrectTheCo .ascii "Incorrect. The correct answer is %d.\n",0
.rodata:3F40D3CB ; DATA XREF: .flash.text:40105B84↓o
.rodata:3F40D3F1 byte_3F40D3F1 .byte 9, 3, 0xE, 8, 0x62, 2, 0x7B, 0x3B,
0x27, 8, 0x7C, 0x21, 0x7E, 0x3A, 0x6B, 0
.rodata:3F40D3F1 ; DATA XREF: .flash.text:40105B88↓o
.rodata:3F40D401 aMath .ascii "math",0 ; DATA XREF: .flash.text:40105B90↓o
.rodata:3F40D406 aPracticeMath .ascii "Practice math!" ; DATA XREF: .flash.text:40105B94↓o
XOR
That byte stream in the middle is mighty suspicious…
[0](Ghroth)❯ pry
[1] pry(main)> sus = '9, 3, 0xE, 8, 0x62, 2, 0x7B, 0x3B, 0x27, 8, 0x7C, 0x21, 0x7E, 0x3A, 0x6B'
=> "9, 3, 0xE, 8, 0x62, 2, 0x7B, 0x3B, 0x27, 8, 0x7C, 0x21, 0x7E, 0x3A, 0x6B"
[2] pry(main)> sus = sus.split(', ').map{|s|s.to_i(16)}
=> [9, 3, 14, 8, 98, 2, 123, 59, 39, 8, 124, 33, 126, 58, 107, 0]
[3] pry(main)> 'FLAG-'.bytes.zip(sus).map{|a,b|a^b}
=> [79, 79, 79, 79, 79]
[4] pry(main)> sus.map{|x|(x^79).chr}.join
=> "FLAG-M4thG3n1u$O"
That was too easy. Let’s try to do it The Hard Way™.
Disassembly
We can chase the string XRefs back to a function easily enough: A couple things stick out immediately:
- There’s a loop, it runs
a3=sub_40136488(1)
, anda8=sub_40136488("%d",d11)
- If
a8 != a3
, it prints “Incorrect…”, and the function returns. - If
a8 == a3
, it prints “Correct”, and decrementsa2
a2
is initialized to 1024.- That’s way more times than we want to do it manually.
Slightly deeper analysis reveals that on success:
- We load
off_40105B88
->byte_3F40D3F1
(the flag array above) - We pass that to
off_4010593C
->sub_4000C2C8
- And to
sub_4011B3B4
, which is a xor decoder loop - And to
sub_401361D0
, which is used above to print “Correct!”, so probablyputs()
- (in case you wanted another path to find the flag array)
Exploit
So, we’re doing this The Hard Way™. And The Hard Way™ is to patch the program.
Enabling “Options->Genera->opcode bytes = 4”, shows us this:
.flash.text:4011B467 22 A4 00 movi a2, 0x400
From the ISA, we know that the immediate here is the final 12 bits, so we can patch that to
.flash.text:4011B467 22 A0 01 movi a2, 1
And it’s off to the races!
[0](Ghroth)❯ esptool.py --chip esp32 --baud 2000000 write_flash 0x0 patched.bin
esptool.py v4.5.1
Serial port /dev/ttyUSB0
Connecting.....
Chip is ESP32-D0WDQ6 (revision v1.0)
Features: WiFi, BT, Dual Core, 240MHz, VRef calibration in efuse, Coding Scheme None
Crystal is 40MHz
MAC: c8:f0:9e:b1:82:78
Uploading stub...
Running stub...
Stub running...
Changing baud rate to 2000000
Changed.
Configuring flash size...
Flash will be erased from 0x00000000 to 0x003fffff...
Compressed 4194304 bytes to 1381652...
Wrote 4194304 bytes (1381652 compressed) at 0x00000000 in 27.4 seconds (effective 1226.3 kbit/s)...
Hash of data verified.
Leaving...
Hard resetting via RTS pin...
Great! But…
Checksums
E (346) esp_image: Checksum failed. Calculated 0x63 read 0x66
E (347) boot: Factory app partition is not bootable
E (347) boot: No bootable app partitions in the partition table
Oh No! There’s checksums! Of course there are, and they’re a good thing, but we need to fix them. Fortunately, they tell us what it is and what it should be, but it doesn’t tell us where it is. I actually found the right one by changing all the nearby 66
s to unique hex values and trying again. The right one is at 0x7b930:
E (346) esp_image: Image hash failed - image is corrupt
E (347) boot: Factory app partition is not bootable
E (347) boot: No bootable app partitions in the partition table
Oh No! The image is corrupt, and this time it doesn’t give details. We need esptool.py image_info
, but that doesn’t work on firmware dumps, it works on partitions. We need to slice it up. I found tenable / esp32_image_parser, which is somewhat old and had some problems, but was able to dump the partition table for me. That reports the factory
partition at 0x10000
. Now to see what it contains:
[0](Ghroth)❯ dd if=patched.bin of=image.bin iflag=skip_bytes skip=$((0x10000))
8064+0 records in
8064+0 records out
4128768 bytes (4.1 MB, 3.9 MiB) copied, 0.0313044 s, 132 MB/s
[0](Ghroth)❯ esptool.py --chip esp32 image_info p2.bin
esptool.py v4.5.1
Image version: 1
Entry point: 400814b4
5 segments
Segment 1: len 0x1e0e0 load 0x3f400020 file_offs 0x00000018 [DROM]
Segment 2: len 0x01f10 load 0x3ffbdb60 file_offs 0x0001e100 [BYTE_ACCESSIBLE,DRAM]
Segment 3: len 0x80f38 load 0x400d0020 file_offs 0x00020018 [IROM]
Segment 4: len 0x0606c load 0x3ffbfa70 file_offs 0x000a0f58 [BYTE_ACCESSIBLE,DRAM]
Segment 5: len 0x180a8 load 0x40080000 file_offs 0x000a6fcc [IRAM]
Checksum: 66 (valid)
Validation Hash: d60370e14f3fae73c5f8b41b3c6758a85460d172e5b77b16788e5b990df9b43b (invalid)
And this is what we need to see. We can repeat the same procedure on the unmodified image to find the original checksum be260f1ed5fab0d4f4d8a11cfbcbe98e351d6073092fce90d8734042638b9105
. With this, we can patch the SHA256:
[0](Ghroth)❯ grep -abo "$(echo be260f1ed5fab0d4f4d8a11cfbcbe98e351d6073092fce90d8734042638b9105 | xxd -ps -r)" patched.bin
848000:�&�����ء���5`s /ΐ�s@Bc��
[0](Ghroth)❯ echo d60370e14f3fae73c5f8b41b3c6758a85460d172e5b77b16788e5b990df9b43b | xxd -ps -r | dd of=patched.bin seek=848000 bs=1
32+0 records in
32+0 records out
32 bytes copied, 0.000141329 s, 226 kB/s
[0](Ghroth)❯ esptool.py --chip esp32 --baud 2000000 write_flash 0x0 patched.bin
esptool.py v4.5.1
...
Wrote 848032 bytes (493810 compressed) at 0x00000000 in 7.2 seconds (effective 939.1 kbit/s)...
Hash of data verified.
[0](Ghroth)❯ miniterm.py -f direct /dev/ttyUSB0 115200
nsec>
nsec> math
5 * 10 = 50
Correct!
FLAG-M4thG3n1u$