Post

BreakTheSyntaxCTF - LED

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, 可以看到0x014f00x3fce2820数据. 而第一个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")

得到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")
This post is licensed under CC BY 4.0 by the author.