Summary
Solstice 9 started with a firmware image and ended with a signed request to a remote controller. The image contained a local flag, but the description made it clear that the real one had to come from the remote bridge.
I needed to reconstruct a 96-byte fixture from three sources: an SPI journal, an EEPROM capture, and a supervisor serial capture. Reversing the supplied binaries then showed how to turn those bytes into an Ed25519 key and authenticate a maintenance request.
Analysis
Inspecting the image
The supplied file, solstice9-firmware(1).img, was an MBR disk image. Its Linux filesystem started at sector 1056768, so I listed it with Sleuth Kit:
1
fls -o 1056768 'solstice9-firmware(1).img'
These were the useful files recovered from the filesystem:
1
2
3
4
5
6
7
8
9
/etc/controller.conf
/var/lib/powerd/archive/bench.txt
/usr/share/board/storage.txt
/usr/share/board/fixture.txt
/var/lib/powerd/capture-01.csv
/var/lib/powerd/capture-02.csv
/var/lib/powerd/controller-spi.raw
/usr/libexec/boarddiag
/usr/sbin/powerd
The image also contained this token:
1
DCTF{local_fixture_only_use_the_remote_bridge}
It was a decoy. I kept looking for the information needed to talk to the bridge.
controller.conf identified the target:
1
2
3
unit=0x17
board=0x5319
revision=2
The bench notes described capture 01 as an EEPROM capture and capture 02 as a supervisor capture. Both CSV files stored edge timestamps in nanoseconds.
Recovering the SPI journal
storage.txt described the flash dump as 4,096 pages of 272 bytes: 256 data bytes followed by 16 bytes of spare metadata. The metadata fields were:
1
2
magic[2], kind:u8, flags:u8, generation:u16,
length:u16, board_revision:u32, crc32:u32
The multibyte fields were little-endian. Only records with flag bit 0 set were committed. The CRC32 covered the first 12 spare bytes followed by all 256 dewhitened data bytes.
The data was whitened separately for each physical page. This reverses the operation:
1
2
3
4
5
6
7
8
9
10
def dewhiten(data, page_index):
x = (0x9e3779b9 ^ page_index) & 0xffffffff
result = bytearray()
for value in data:
x ^= (x << 13) & 0xffffffff
x ^= x >> 17
x ^= (x << 5) & 0xffffffff
x &= 0xffffffff
result.append(value ^ (x & 0xff))
return bytes(result)
After checking the commit flag and CRC, I filtered for board revision 0x53190002. The fixture component was split between record kinds 0x31 and 0x32, each containing 16 bytes. Both halves had to belong to the same generation.
The generation counter wraps at 16 bits. A forward delta below 0x8000 means a newer record, so simply sorting the generation numbers would pick the wrong pair near a wrap.
The newest complete pair was generation 0x0002:
1
2
kind 0x31: f07fb429318b400792c47765179ff564
kind 0x32: 02ad956f22989e77868b65099f095d66
Together, those gave me the first 32 bytes of the fixture.
Decoding the EEPROM capture
The fixture note placed the revision 2 calibration data at offset 0x40 in a 24C02 EEPROM at I²C address 0x50. I needed 32 bytes.
The capture contained reads beginning at 0x40 and 0x50. On the wire, address bytes 0xa0 and 0xa1 identify writes and reads to the same 7-bit address. After the address and ACK bits were removed, the two reads gave:
1
2
0x40: 3fefc049a89a612a3542d30ecc3952c4
0x50: b4171fe14e33a98c0dbad42301529530
That was the second 32-byte component.
Decoding the supervisor capture
The supervisor data used inverted 57600-baud serial with eight data bits, no parity, and one stop bit. The packet layout was documented in fixture.txt:
1
55 aa | revision LE16 | length U8 | component bytes | CRC16/Modbus LE16
There were three packets: one for revision 1 and two for revision 2. The note specifically said to select a valid packet for the board revision. The last packet had a one-bit CRC error, so taking the final packet blindly would have spoiled the fixture.
The valid revision 2 packet contained:
1
6c8737ba4c7a107a4c01f44dc0f42df58a36735d3aca1bcaba67191ffea05225
Exploitation
Deriving the signing key
The fixture order was controller journal || EEPROM || supervisor. That gave me 96 bytes, but the signing key still needed to be derived from them.
boarddiag was an AArch64 binary. Its lane-routing code rotated the EEPROM bytes and XORed them with bytes from the other two components. The following reproduces the transformation and checks the resulting identity. It needs the Python cryptography package.
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
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
journal = bytes.fromhex(
"f07fb429318b400792c47765179ff564"
"02ad956f22989e77868b65099f095d66"
)
eeprom = bytes.fromhex(
"3fefc049a89a612a3542d30ecc3952c4"
"b4171fe14e33a98c0dbad42301529530"
)
supervisor = bytes.fromhex(
"6c8737ba4c7a107a4c01f44dc0f42df5"
"8a36735d3aca1bcaba67191ffea05225"
)
fixture = journal + eeprom + supervisor
assert len(fixture) == 96
def rol8(value, amount):
return ((value << amount) | (value >> (8 - amount))) & 0xff
seed = bytes(
rol8(fixture[32 + j], j & 7)
^ fixture[95 - j]
^ fixture[(7 + 13 * j) & 31]
for j in range(32)
)
key = Ed25519PrivateKey.from_private_bytes(seed)
public_key = key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
print("Seed:", seed.hex())
print("Public key:", public_key.hex())
The output was:
1
2
Seed: 1dafdc419cd8a71b9b0041d4b856ab9f36322592cc14e09f1172a215c58d7d11
Public key: 8bc22f9dad276d4302d75f5bf0dc6ab30a50ff102d9d7b96de69fec36048ed4c
The public key matched the one embedded in powerd. That was a useful check on all three recovered components before trying the remote service.
Requesting a nonce
The bridge greeted a connection with:
1
Solstice 9 RTU bridge / HELP for transport
It accepted frames as XFER <hex frame>. Reversing powerd showed two custom maintenance functions:
| Function | Purpose |
|---|---|
0x41 |
Request a nonce |
0x42 |
Submit an Ed25519 signature |
The frames used CRC16/Modbus, with the low byte sent first. For this unit, the nonce request was:
1
XFER 1741533900027fbb
The response contained a 32-byte nonce. I had to keep the same TCP connection open for the signed request: a fresh connection belonged to another forked process and didn’t share that nonce.
Signing the maintenance request
powerd verified a signature over this exact message:
1
SOLSTICE9/maintenance/v1 || nonce
Using key from the derivation above and the 32-byte nonce extracted from the response, I built the reply like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
def crc16_modbus(data):
crc = 0xffff
for value in data:
crc ^= value
for _ in range(8):
crc = (crc >> 1) ^ (0xa001 if crc & 1 else 0)
return crc.to_bytes(2, "little")
assert len(nonce) == 32
signature = key.sign(b"SOLSTICE9/maintenance/v1" + nonce)
frame = b"\x17\x42" + signature
frame += crc16_modbus(frame)
print("XFER " + frame.hex())
The frame was 17 42, followed by the 64-byte signature and the two-byte CRC. Sending it through the connection that issued the nonce returned the flag.
Flag
DCTF{c7da32e39bd5727769742639fb9855971cb88cb952573182913b9155d7b1b286}