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.
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):
| FC | Condition | Behaviour |
|---|---|---|
0x03 | frame len 8 | telemetry; addr ≤ 7, qty ≤ 8-addr — no OOB |
0x41 'A' | frame len 8, addr 0x5339, qty 2 | issues a RAND_bytes(32) challenge, TTL 30 s |
0x42 'B' | frame len 0x44 | Ed25519 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 0x17is 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 0xb10–0xb64 (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 satisfiesS < 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→ onlypowerditself
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