solstice-9

Three bench captures each hide the real component next to a more attractive decoy; assembling the 96-byte fixture and inverting boarddiag's lane routing yields the Ed25519 private key.

2026.09.19 DefCamp CTF 2026 Quals 450 pts Misc
FLAG DCTF{c7da32e39bd5727769742639fb9855971cb88cb952573182913b9155d7b1b286}

Category: Misc (Hard; the community rated it Insane) · Points 450 (475 initially) · Author thek0der · Solves 10

A solar controller was retired without explanation. Its field firmware survived, including archived bench captures. One static flag. Format: DCTF{sha256}

Q1. Retrieve the service receipt from the undocumented maintenance backdoor.

0. In one paragraph

Find the Modbus RTU backdoor function code in a solar controller’s firmware image, pull one component each out of three bench captures (an SPI journal dump, an I2C EEPROM read and an inverted UART line) to rebuild a 96-byte calibration fixture, then invert the lane routing in boarddiag to recover the Ed25519 private key and sign the server’s challenge.

The heart of the challenge is that each of the three components sits next to a decoy that looks more attractive than the real one.

1. Recon

The handout is a single 4.22 GB Raspberry Pi SD image.

part1: type=0x0c (FAT32) start_lba=8192                          → /boot
part2: type=0x83 (ext4)  start_lba=1056768 offset=541065216      → rootfs

macOS cannot mount ext4 and there was no disk space to unpack, so the image was read in place:

import ext4
vol = ext4.Volume(open("fw.img", "rb"), offset=541065216)

Tip. Downloading the 4 GB image naively fills the disk. Streaming it sparsely — seeking past zero-filled blocks (curl | python3 sparsedl.py) — brings 4.22 GB down to 3.0 GB on disk.

1.1 Finding the author’s files — sort by mtime

The base is the stock 2024-08-14-solar-assistant.rpi64.img (Solar Assistant), so there are over 89,000 files. Grepping for solstice|rtu|modbus|receipt, and excluding dpkg-owned files, both produced thousands of lines of noise.

Sorting inodes by mtime was the answer. Every stock file is dated 2024-08-14; the author’s files all share 2026-09-15 02:05:10, identical down to the second.

2026-09-15 02:05:10  /etc/systemd/system/solstice9.service
2026-09-15 02:05:10  /etc/controller.conf
2026-09-15 02:05:10  /etc/issue-token
2026-09-15 02:05:10  /usr/libexec/boarddiag              ← the second binary
2026-09-15 02:05:10  /usr/sbin/powerd                    ← the service itself
2026-09-15 02:05:10  /usr/share/board/storage.txt        ← SPI format spec
2026-09-15 02:05:10  /usr/share/board/fixture.txt        ← fixture assembly spec
2026-09-15 02:05:10  /usr/share/doc/solstice9/base-image.txt
2026-09-15 02:05:10  /var/lib/powerd/archive/{bench.txt,capture-01.csv,
                                              capture-02.csv,controller-spi.raw}
2024-08-14 20:54:10  /etc/machine-id                     ← everything from here is stock

Thirteen files, and that is the lot. Those two .txt specs are half the challenge — without them it cannot be solved.

/etc/issue-token is a decoy:

DCTF{local_fixture_only_use_the_remote_bridge}

base-image.txt helpfully spoils the layout:

CTF additions are stored under /var/lib/powerd, /usr/share/board,
/usr/libexec/boarddiag, /usr/sbin/powerd, and solstice9.service.

2. Reversing the bridge (/usr/sbin/powerd)

aarch64 PIE, stripped, 67 KB, started by solstice9.service.

nc <host> <port>
Solstice 9 RTU bridge / HELP for transport
HELP
XFER <hex RTU frame>; CRC16/Modbus low byte first; unit 17 hex; FC03 telemetry

Only two commands, HELP and XFER; all the logic lives inside the Modbus layer.

2.1 Where the flag comes from (main)

path = getenv("SOLSTICE_FLAG_FILE")
     ?: access("/dev/sda", F_OK) == 0 ? "/dev/sda"
      : access("/dev/vdb", F_OK) == 0 ? "/dev/vdb"
      : "/etc/issue-token";
read(fd, .data+0x20, 0x100);          // kept after a DCTF{...} format check

The flag lives on a block device on the remote server. It is not in the local image.

2.2 Function-code dispatch

strings already gives the game away — EVP_DigestVerify, EVP_PKEY_new_raw_public_key, RAND_bytes, and the context string SOLSTICE9/maintenance/v1.

Anything whose unit is not 0x17 is ignored. Beyond that there are three function codes (everything else is exception 01):

FCConditionBehaviour
0x03frame len 8telemetry; addr ≤ 7, qty ≤ 8-addr — no OOB
0x41 'A'frame len 8, addr 0x5339, qty 2issues a RAND_bytes(32) challenge, TTL 30 s
0x42 'B'frame len 0x44Ed25519 verify → the flag on success

The magic behind 0x41 is plain in the disassembly:

ldur w1, [sp, #0xc2]      ; frame[2..5]
mov  w0, 0x3953           ; 'S','9'
movk w0, 0x200, lsl 16    ; qty = 2
cmp  w1, w0

Register address 0x5339 is ASCII "S9", quantity 2 — deliberately easy to confuse with the board marking 5319.

2.3 Signature verification

add x1, sp, 0xc2      ; sig = frame[2..66]  (64 bytes)
mov x2, 0x40          ; siglen
mov x3, x27           ; tbs = sp+0x80
mov x4, 0x38          ; tbslen = 56
bl  EVP_DigestVerify

The message is "SOLSTICE9/maintenance/v1" (24) ‖ challenge (32) = 56 bytes. The public key sits at .rodata 0x1f30:

8bc22f9dad276d4302d75f5bf0dc6ab30a50ff102d9d7b96de69fec36048ed4c

On success it returns .data+0x20 (the flag) as 17 42 <len> <flag> <crc>.

Dead-end check:

  • the public key is a proper prime-order point → no small-subgroup forgery
  • FC03 has no OOB → no memory leak to extract the key
  • challenges come from OpenSSL RAND_bytes, reseeded per forked child → not predictable
  • 0x17 is the only valid unit (confirmed by scanning all 256)

There is no route other than genuinely recovering the private key.

3. Bench captures — rebuilding the 96-byte fixture

/usr/share/board/fixture.txt gives the assembly rules:

Revision 2 fixture assembly note
Component order: controller journal, EEPROM, supervisor.
EEPROM 24C02 address 50h: rev1 calibration at 20h; rev2 at 40h (32 bytes).
Supervisor: 8 data bits, no parity, 1 stop. Packet = 55 aa, revision LE16,
length U8, component bytes, CRC16/Modbus LE16 over preceding packet bytes.
Use a valid packet for the PCB revision, not the last packet seen.
The 96-byte fixture is a concatenation in component order. Lane routing is
implemented by /usr/libexec/boarddiag, which prints the fixture public identity.

and /etc/controller.conf pins the board down:

unit=0x17
board=0x5319
revision=2
telemetry_registers=0..7

revision = 2. All three components must be filtered on that value.

3.1 Controller journal — controller-spi.raw (32 B)

The file is 1,114,112 bytes = 4096 × (256 + 16), exactly matching storage.txt:

spare (LE): magic[2] kind:u8 flags:u8 generation:u16 length:u16
            board_revision:u32 crc32:u32
CRC32 = standard reflected CRC32 over spare[0:12] + 256 de-whitened data bytes
whitening: x = 0x9E3779B9 ^ page_index   (reset per page)
  per data byte: x ^= x<<13; x ^= x>>17; x ^= x<<5   (uint32)
                 byte ^= x & 0xFF
Only flags bit 0 commits a page.
Kinds 31h and 32h are a paired 16+16 byte fixture component.
Generation is a wrapping 16-bit counter: forward deltas below 8000h are newer.
Retired, torn, and uncommitted records remain in field-service dumps.

xorshift32 de-whitening plus the CRC32 check leaves 16 of the 4096 pages alive, all with magic 0x3953 (“S9”) and board_revision = 0x53190002.

kind=0x31 gen=0x0000 COMMIT           kind=0x32 gen=0x0000 COMMIT
kind=0x31 gen=0x0001 COMMIT           kind=0x32 gen=0x0001 COMMIT
kind=0x31 gen=0x0002 COMMIT ★         kind=0x32 gen=0x0002 COMMIT ★
kind=0x31 gen=0x0004 uncommitted      kind=0x32 gen=0x0004 uncommitted  ← decoy
kind=0x31 gen=0xfffc..0xffff COMMIT (pre-wrap, therefore older)

Decoy ① — the highest-generation pair, 0x0004, has flags = 0: it is uncommitted (torn). Taking max(gen) walks straight into it. The 0xfffc..0xffff records are pre-wrap and therefore older than 0x0002 ((0x0002 - 0xffff) & 0xffff = 3 < 0x8000).

The answer is the newest committed pair, gen = 0x0002:

f07fb429318b400792c47765179ff564    (kind 0x31)
02ad956f22989e77868b65099f095d66    (kind 0x32)

3.2 EEPROM — capture-01.csv (32 B)

Two channels, scl/sda — textbook I2C. Sample on the SCL rising edge and take SDA transitions while SCL is high as START/STOP.

Device address 0xA0 (24C02), four random-read patterns:

0x20  32 bytes  65a0e679...3dea7257
0x40  16 bytes  3fefc049a89a612a3542d30ecc3952c4
0x50  16 bytes  b4171fe14e33a98c0dbad42301529530
0x70   8 bytes  0efc4bc3b35d4109

Decoy ② — the 32 bytes at 0x20 look the most like “the key”, but that is the rev1 calibration. The board is rev2, so the right component is the 32 bytes starting at 0x40 (0x40 + 0x50).

3.3 Supervisor — capture-02.csv (32 B)

From bench.txt:

Capture 02: two probes on the supervisor header; a transistor sits between the MCU TX pin and the test point. Times are nanoseconds; rows are edges.

One transistor means inversion. The smallest edge delta is 17,361 ns → 1e9/17361 ≈ 57600 baud. ch0 carries the data; ch1 is a 500 Hz heartbeat on a 1 ms period with no payload.

Decoding inverted 57600 8N1 yields three packets with zero stop-bit errors:

rev=1 len=32 crc=OK   34679a88...f8850ab
rev=2 len=32 crc=OK   6c8737ba...ea05225  ★
rev=2 len=32 crc=BAD  0a835185...d865c0

Decoy ③ — there are two rev2 packets, and the last one has a one-bit CRC16 error (0x18da vs 0x19da), exactly as fixture.txt warned: “Use a valid packet for the PCB revision, not the last packet seen.”

The answer is the second packet, 6c8737ba...ea05225.

3.4 Assembly

fixture = journal(32) ‖ eeprom(32) ‖ supervisor(32)
= f07fb429318b400792c47765179ff564 02ad956f22989e77868b65099f095d66
  3fefc049a89a612a3542d30ecc3952c4 b4171fe14e33a98c0dbad42301529530
  6c8737ba4c7a107a4c01f44dc0f42df5 8a36735d3aca1bcaba67191ffea05225

4. Lane routing (/usr/libexec/boarddiag)

It takes the 96-byte file, derives a 32-byte seed, and prints the “fixture identity” via EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, ...)EVP_PKEY_get_raw_public_key. It is aarch64, so it cannot be run here — static analysis only.

The core loop at 0xb100xb64 (x19 = fixture, x20 = out, x0 = i = 1..32, w6 starts at 7 and gains 0xd each pass):

add  x1, x0, x19
sub  x4, x19, x0
sub  w3, w0, 1              ; j   = i-1
and  w2, w6, 0x1f           ; idx = (7 + 13j) & 31
and  w3, w3, 7              ; r   = j & 7
ldrb w1, [x1, 0x1f]         ; b   = fixture[j + 32]
neg  w5, w3
ldrb w8, [x4, 0x60]         ; c   = fixture[95 - j]
and  w5, w5, 7              ; (8 - r) & 7
ldrb w2, [x19, w2, uxtw]    ; a   = fixture[idx]
lsl  w1, w1, w3             ; b << r
eor  w2, w2, w8             ; a ^ c
lsr  w3, w4, w5             ; b >> (8-r)
orr  w1, w1, w3             ; = rotl8(b, r)
add  w6, w6, 0xd
eor  w1, w2, w1
strb w1, [x7, -1]           ; out[j]

which is:

seed[j] = fixture[(7 + 13*j) % 32] ^ fixture[95 - j] ^ rotl8(fixture[32 + j], j & 7)

Each component is consumed in a different role — a (journal), b (EEPROM), c (supervisor) — so getting any one of them wrong destroys the whole seed:

seed   = 1dafdc419cd8a71b9b0041d4b856ab9f36322592cc14e09f1172a215c58d7d11
pubkey = 8bc22f9dad276d4302d75f5bf0dc6ab30a50ff102d9d7b96de69fec36048ed4c  ✔ matches powerd's .rodata

5. Exploit

challenge = xfer(0x17, 0x41, addr=0x5339, qty=2)[3:35]
sig  = Ed25519(seed).sign(b"SOLSTICE9/maintenance/v1" + challenge)
flag = xfer(0x17, 0x42, sig)
$ python3 exploit.py 34.179.245.69 31737
[*] Solstice 9 RTU bridge / HELP for transport
[*] challenge: d91dabd1fe8b814a327c10ae092498df2a47a406d9ef01fedd934cbb81795910
[+] FLAG: DCTF{c7da32e39bd5727769742639fb9855971cb88cb952573182913b9155d7b1b286}

6. The dead end — MT19937 in controller-spi.raw

Before finding the spec files, this 1 MB blob looked like uniform randomness (entropy 7.99 bits/byte, no repeated 16/32/64-byte blocks) and swallowed a lot of time. Untempering the first 624 words did recover a state — it really is MT19937 output — and running the state backwards and forwards produced something interesting:

[... earlier stream ...] A(16) B(16) f2(32) │ DUMP │ [... 4908 B ...] E20(32) N8(8) f1(32) f3(32)

The EEPROM values and the UART payloads are all sitting in the same MT stream: the author generated every “random” artefact in one go with Python’s random.

So, on the theory that the Ed25519 seed must be in there too, every 32-byte window of the dump, of 300 KB backwards and of 7.4 MB forwards was tried as a seed — plus reversed, word-swapped, and XORed against each blob. It is not there. The key is derived from the fixture by lane routing, not drawn from the random stream.

Recovering MT19937 was entirely wasted effort. The only thing it established is that the EEPROM and UART values are meaningless filler — which reading the spec first would have made moot.

Lesson: when a large file looks random, find the format spec for it inside the image before starting any crypto brute force. The author usually wrote one.

Other things tried and dropped

  • decrypting the EEPROM blob as AES-128/256 (ECB/CBC/CTR/XTS) keys and IVs, 4,560 combinations → nothing
  • feeding E20 / A+B / UART payloads in as seeds, clamped scalars and hash outputs, 2,637 candidates → nothing
  • reading the three UART frames as (challenge, R, S) and verifying, 600 combinations → nothing (no blob satisfies S < L, so none of them can be a signature in the first place)
  • scanning the raw 4.2 GB image for the public-key bytes and for BEGIN PRIVATE KEY → only powerd itself

7. Files

solstice-9/
├── WRITEUP.md
├── powerd                      # extracted service binary
├── boarddiag                   # extracted lane-routing binary
├── controller.conf fixture.txt storage.txt base-image.txt
├── archive/                    # verbatim /var/lib/powerd/archive
│   ├── bench.txt capture-01.csv capture-02.csv controller-spi.raw
└── solve/
    ├── decode_i2c.py           # capture-01 → EEPROM word map
    ├── decode_uart.py          # capture-02 → supervisor packets (inverted UART + CRC)
    ├── parse_spi.py            # controller-spi.raw → journal component (de-whiten + CRC32)
    ├── recover_key.py          # fixture assembly + lane routing → Ed25519 private key
    └── exploit.py              # sign the challenge → flag
pip install ext4 cryptography
python3 solve/recover_key.py            # verifies the public key matches
python3 solve/exploit.py <host> <port>  # flag
#firmware#modbus#spi#i2c#uart#ed25519