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}

Tags: misc firmware reverse-engineering i2c uart ed25519 modbus