BreakTheSyntaxCTF - LED
固件初步分析
题目附件有blink.ino,属于ESP固件,会被加载到flash中的0x0位置.。 使用esp_tool查看:
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
(esptool) ~\Tool\IoT\esptool> python esptool.py image_info ~/Desktop/crack_the_syntax_CTF/led/blink.ino
Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.
Warning: Deprecated: Command 'image_info' is deprecated. Use 'image-info' instead.
esptool v5.2.0
Image size: 4194304 bytes
Detected image type: ESP32-S3
ESP32-S3 Image Header
=====================
Image version: 1
Entry point: 0x403c8908
Segments: 3
Flash size: 2MB
Flash freq: 80m
Flash mode: DIO
ESP32-S3 Extended Image Header
==============================
WP pin: 0xee (disabled)
Flash pins drive settings: clk_drv: 0x0, q_drv: 0x0, d_drv: 0x0, cs0_drv: 0x0, hd_drv: 0x0, wp_drv: 0x0
Chip ID: 9 (ESP32-S3)
Minimal chip revision: v0.0, (legacy min_rev = 0)
Maximal chip revision: v0.99
Segments Information
====================
Segment Length Load addr File offs Memory types
------- ------- ---------- ---------- ------------
0 0x014f0 0x3fce2820 0x00000018 BYTE_ACCESSIBLE, MEM_INTERNAL, DRAM
1 0x00da0 0x403c8700 0x00001510 MEM_INTERNAL, IRAM
2 0x02f58 0x403cb700 0x000022b8 MEM_INTERNAL, IRAM
ESP32-S3 Image Footer
=====================
Checksum: 0xfb (valid)
Validation hash: 9431b0972544c4af9113f75825dbdd3618aad38e28a0c3d46824911e264d08d9 (valid)
Bootloader Information
======================
Bootloader version: 1
ESP-IDF: v6.0.1-dirty
Compile time: May 2 2026 22:20:15
先来研究segment. 和hex数据对比:
文件的开头属于esp image文件头. 从0x18开始,是第一个segment.(segment = segment-header + segment-data). 首先是8字节的seg-header, 可以看到0x014f0和0x3fce2820数据. 而第一个segment的有效数据从0x20才开始,它会被映射到 0x3fce2820(注意:segment header是不参与映射的), 在ida中检查,确实如此: 
segments是线性存储的,通过0x20 + size = 0x1510算出下一个segment的起始地址(注意:这里的length值得是segment-data的长度,因此需要跳过segment-header, 从0x20开始). 检查一下第二个segment的数据,file offse显示0x1510, 数据吻合.
简言之, length和load_addr都是针对segment-data而言的,但是file offs确是针对整个segment来说的.
esptool包含的另一个信息是:入口代码在0x403c8908. 回过头来看固件的前8个字节:E9 03 02 1F 08 89 3C 40 E9: esp magic number 03: segments count (吻合) 02: SPI flash mode 1F: SPI config 08 89 3C 40: entry addr(小端)
启动流程分析
2nd stage bootloader
上电后,flash读取开头的元信息,完成3个segments的内存映射后,跳转到entry指向的入口点代码(数据位于第二个segment的范围).
这三个段分工大致为:
1
2
3
0x3fce2820 <- DRAM 数据、rodata、字符串等
0x403c8700 <- IRAM 代码
0x403cb700 <- IRAM 代码
而入口点还不是ctf题目的真实逻辑,而是2nd stage bootloader代码. 没有很好的反编译工具,所以读起来比较吃力。但是幸运的是,代码中引用了很多字符串:
1
2
3
4
5
6
IRAM:403C8944 0D0 BD 0A mov.n a11, a10
IRAM:403C8946 0D0 C1 71 FF l32r a12, off_403C870C ; "boot"
IRAM:403C8949 0D0 A1 71 FF l32r a10, off_403C8710 ; "E (%lu) %s: load partition table error!"...
IRAM:403C894C 0D0 81 73 FF l32r a8, off_403C8718
IRAM:403C894F 0D0 E0 08 00 callx8 a8 ; ets_printf
IRAM:403C894F
这些字符串存放在第一个segment中:
这些字符串能够暗示其工作流程. 交给AI分析,发现流程大致为:
1
2
3
4
5
6
7
1. 初始化更完整的 flash / cache / clock
2. 读取 partition table
3. 找到应该启动的 app 分区
4. 读取 app image header
5. 加载 app 的 segments
6. 校验 checksum/hash
7. 跳转到 app entry point
分区表读取
AI进行分析后,总结其规律:从flash的0x8000偏移处读取分区表(可以把flash理解成硬盘,程序运行的过程中扮演内存角色的是RAM) 事实上,这也是esp的标准工作流程. 
1
2
3
4
5
6
7
8
9
struct partition_entry {
uint16_t magic; // 0x50aa
uint8_t type;
uint8_t subtype;
uint32_t offset;
uint32_t size;
char label[16];
uint32_t flags;
};
用脚本解析:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import struct
with open("blink.ino", "rb") as f:
data = f.read()
for i in range(0, 0xC00, 32):
off = 0x8000 + i
ent = data[off:off + 32]
magic = struct.unpack("<H", ent[:2])[0]
if magic != 0x50AA:
break
magic, typ, subtype, poffset, size, label, flags = struct.unpack(
"<HBBLL16sL", ent
)
print(i // 32, hex(typ), hex(subtype), hex(poffset), hex(size), label.split(b"\0")[0])
得到:
1
2
3
0 type=0x1 subtype=0x2 offset=0x9000 size=0x6000 label=nvs
1 type=0x1 subtype=0x1 offset=0xf000 size=0x1000 label=phy_init
2 type=0x0 subtype=0x0 offset=0x10000 size=0x100000 label=factory
其中factory就是ctf程序的主逻辑. 我们需要对齐进行提取. 从文件的0x10000开始,大小为0x10000. 可以看到,这也是一个完整的ESP映像.
接着,2nd bootloader会解析这个子映像,将其segments加载到特定的内存中.
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
(esptool) ~\Tool\IoT\esptool> python esptool.py image_info ~/Desktop/crack_the_syntax_CTF/led/factory_app.bin
Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.
Warning: Deprecated: Command 'image_info' is deprecated. Use 'image-info' instead.
esptool v5.2.0
Image size: 1048576 bytes
Detected image type: ESP32-S3
ESP32-S3 Image Header
=====================
Image version: 1
Entry point: 0x4037564c
Segments: 6
Flash size: 2MB
Flash freq: 80m
Flash mode: DIO
ESP32-S3 Extended Image Header
==============================
WP pin: 0xee (disabled)
Flash pins drive settings: clk_drv: 0x0, q_drv: 0x0, d_drv: 0x0, cs0_drv: 0x0, hd_drv: 0x0, wp_drv: 0x0
Chip ID: 9 (ESP32-S3)
Minimal chip revision: v0.0, (legacy min_rev = 0)
Maximal chip revision: v0.99
Segments Information
====================
Segment Length Load addr File offs Memory types
------- ------- ---------- ---------- ------------
0 0x32288 0x3c020020 0x00000018 DROM
1 0x032f4 0x3fc91c00 0x000322a8 BYTE_ACCESSIBLE, MEM_INTERNAL, DRAM
2 0x0aa6c 0x40374000 0x000355a4 MEM_INTERNAL, IRAM
3 0x157c4 0x42000020 0x00040018 IROM
4 0x03098 0x4037ea6c 0x000557e4 MEM_INTERNAL, IRAM
5 0x00024 0x50000000 0x00058884 RTC_DATA
ESP32-S3 Image Footer
=====================
Checksum: 0xd5 (valid)
Validation hash: e0b481bab666c0535936148140e41d41ffdcf7877db9beaba4d10eb585cb1df1 (valid)
Application Information
=======================
Project name: ctf_chall
App version: 1
Compile time: May 2 2026 22:19:50
ELF file SHA256: 031a143f9c6de089e78c27c60bb8321c5ce74ac47a527205abf8bfd76957ee54
ESP-IDF: v6.0.1-dirty
Minimal eFuse block revision: 0.0
Maximal eFuse block revision: 1.99
MMU page size: 64 KB
Secure version: 0
可以看到实际上不存在重合的内存区域.
app image
进入第二阶段的entry后,仍然会执行一些初始化代码。接着我们要寻找核心的app main函数. 第二阶段的固件,有下列字符串:
1
2
3
app_main
./main/main.c
led_strip_new_rmt_device(...)
通过xref可以找到:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
IROM:4200983D 0A0 C2 C1 4C addi a12, sp, 0xA0+var_54
IROM:42009840 0A0 BD 06 mov.n a11, a6
IROM:42009842 0A0 AD 07 mov.n a10, fp
IROM:42009844 0A0 65 42 00 call8 sub_42009C6C
IROM:42009844
IROM:42009847 0A0 9C 0A beqz.n a10, loc_4200985B
IROM:42009847
IROM:42009849 0A0 E1 CB DC l32r a14, off_42000B78 ; "led_strip_new_rmt_device(&strip_cfg, &r"...
IROM:4200984C 0A0 D1 CC DC l32r a13, off_42000B7C ; "app_main"
IROM:4200984F 0A0 C2 A1 05 movi a12, 0x105
IROM:42009852 0A0 B1 CB DC l32r a11, off_42000B80 ; "./main/main.c"
IROM:42009855 0A0 81 09 DC l32r a8, off_4200087C
IROM:42009858 0A0 E0 08 00 callx8 a8 ; loc_4037BD5C
IROM:42009858
IROM:4200985B
IROM:4200985B loc_4200985B: ; CODE XREF: sub_42009748+FF↑j
IROM:4200985B 0A0 A2 21 13 l32i a10, sp, 0xA0+var_54
IROM:4200985E 0A0 65 0E 00 call8 sub_42009944
IROM:4200985E
AI分析后判断这是某个 ESP_ERROR_CHECK / assert 失败路径正在引用:
1
2
3
4
表达式: led_strip_new_rmt_device(&strip_cfg, &rmt_cfg, &strip)
函数名: app_main
文件名: ./main/main.c
行号: 0x105
所以这就是app_main函数.(函数0x42009748)
Decription
接着就让AI分析main逻辑,写脚本解密:
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
from pathlib import Path
from PIL import Image
data = Path("blink.ino").read_bytes()
seed_data = data[0x40b94:0x40b94 + 32]
payload = data[0x19e90:0x19e90 + 0xcf01 * 3]
base_mac = bytes.fromhex("f0 f5 bd 77 59 3e")
def gen16(mac):
seed = 0xa5a5a5a5
out = []
for i in range(16):
seed = (seed * 0x19660d + 0x3c6ef35f) & 0xffffffff
out.append((mac[i % 6] ^ ((seed >> 16) & 0xff) ^ ((55 * i) & 0xff)) & 0xff)
return bytes(out)
def make_schedule(inp, key16):
c = 0
d = 0
for i in range(8):
high = (c >> 24) & 0xff
d = (d << 8) & 0xffffffff
c = (c << 8) & 0xffffffff
c |= key16[i]
d = (high + d) & 0xffffffff
out = []
for j in range(32):
a8 = (((d << 13) & 0xffffffff) + ((c >> 19) & 0x1fff)) & 0xffffffff
c = (((c << 13) & 0xffffffff) ^ c) & 0xffffffff
d = (a8 ^ d) & 0xffffffff
a8 = (((d << 25) & 0xffffffff) + (c >> 7)) & 0xffffffff
a10 = (d >> 7) ^ d
a8 ^= c
d = ((a8 >> 15) + ((a10 << 17) & 0xffffffff)) & 0xffffffff
c = (((a8 << 17) & 0xffffffff) ^ a8) & 0xffffffff
d ^= a10
out.append(((a8 & 0xff) ^ key16[j & 15] ^ inp[j]) & 0xff)
return bytearray(out)
key16 = gen16(base_mac)
schedule = make_schedule(seed_data, key16)
for i in range(32):
schedule[i] ^= base_mac[i % 6]
decoded = bytearray(len(payload))
for i, b in enumerate(payload):
k = (
schedule[i & 31]
^ schedule[((7 * i + 13) & 31)]
^ ((i >> 5) & 0xff)
^ 0x5a
)
decoded[i] = b ^ k
# 0xcf00 = 52992 = 269 * 197
pixels = decoded[:269 * 197 * 3]
img = Image.frombytes("RGB", (269, 197), bytes(pixels))
img.save("final_269x197.png")
附加:用ghidra脚本手动加载segments
ghidra基础版本不能像ida自动解析并装载segments,需要安装插件,或者手动装载.
比如下列脚本,将factory的6个segments加载到特定内存位置:
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
#@category ESP32
# -*- coding: utf-8 -*-
from ghidra.program.model.address import Address
from java.io import FileInputStream
from jarray import array
from java.io import ByteArrayInputStream
fw_path = r"C:\\Users\\woc\\Desktop\\crack_the_syntax_CTF\\led\\factory_app.bin"
segments = [
("DROM", 0x00000020, 0x3c020020, 0x32288, True, False, False),
("DRAM", 0x000322b0, 0x3fc91c00, 0x032f4, True, True, False),
("IRAM0", 0x000355ac, 0x40374000, 0x0aa6c, True, False, True),
("IROM", 0x00040020, 0x42000020, 0x157c4, True, False, True),
("IRAM1", 0x000557ec, 0x4037ea6c, 0x03098, True, False, True),
("RTC_DATA", 0x0005888c, 0x50000000, 0x00024, True, True, False),
]
mem = currentProgram.getMemory()
addr_factory = currentProgram.getAddressFactory()
space = addr_factory.getDefaultAddressSpace()
with open(fw_path, "rb") as f:
data = f.read()
for name, file_off, load_addr, length, read, write, execute in segments:
block_data = data[file_off:file_off + length]
if len(block_data) != length:
raise Exception("Segment %s length mismatch" % name)
start = space.getAddress(load_addr)
# 如果同名 block 已存在,先跳过,避免重复创建
if mem.getBlock(start) is not None:
print("Block already exists at %s, skip %s" % (start, name))
continue
from java.io import ByteArrayInputStream
# stream = ByteArrayInputStream(bytearray(block_data))
stream = ByteArrayInputStream(array(block_data, 'b'))
block = mem.createInitializedBlock(
name,
start,
stream,
length,
monitor,
False
)
block.setRead(read)
block.setWrite(write)
block.setExecute(execute)
print("Loaded %s: file 0x%x -> addr 0x%x, len 0x%x" %
(name, file_off, load_addr, length))
# 标记入口点
entry = space.getAddress(0x4037564c)
createFunction(entry, "entry")
disassemble(entry)
print("Entry point: 0x4037564c")
