//RustRoll Writeup
>TL;DR
-
The rollup truncates account IDs to 20 bits from the low bits of
blake3(pubkey), but verifies signatures with the full Ed25519 key. By brute-forcing Ed25519 keys untilblake3(pubkey) mod 2^20matched the Vault address (622488), we forged a sender identity. -
Once we found such a key, we signed a transaction from the Vault with the correct nonce and the magic amount, sending funds to our own account. The node accepted it and returned the flag:
nexus{Th1s-Is_A-Hard#Fl4g!}.
>Recon
- Connect to the service:
```bash
nc 4.211.248.144 8080
```
Banner shows commands HELP | INFO | LIST | TX <hex> with a 120-byte transaction layout: from_addr | to_addr | amount | nonce | pubkey (32) | signature (64).
- Inspect parameters:
```
INFO
```
- vault_addr = 622488
- magic_amount = 13371337
- address_bits = 20
- Check known accounts:
```
LIST
```
Example early state: Vault at 622488 with a high balance and nonce 4 (later 7 after on-chain activity).
>Vulnerability
Addresses are derived from truncated identities. The server computes an address from a public key using only 20 bits. The signature check uses the full Ed25519 key, so any key whose truncated address matches a target account is accepted as that account. Formally:
-
Let $h = \operatorname{blake3}(\text{pubkey})$.
-
Address $= h \bmod 2^{20}$.
With only $2^{20}$ possibilities, we can brute-force keys until the derived address equals the Vault address (622488).
>Finding a colliding key
We brute-forced Ed25519 keys and tested blake3(pubkey) low 20 bits until it equaled 622488.
import nacl.signing, blake3, time
mask = (1 << 20) - 1
TARGET = 622488
start = time.time()
tries = 0
while True:
sk = nacl.signing.SigningKey.generate()
pk = sk.verify_key.encode()
addr = int.from_bytes(blake3.blake3(pk).digest(), 'little') & mask
tries += 1
if addr == TARGET:
print('found after', tries, 'in', time.time() - start, 's')
print('seed', sk._seed.hex())
print('pub', pk.hex())
break
Result (example):
-
seed = 6e71110e265c2821e4a055af275cf47da12d0390bc6e0b737e00ea6cd3da34e1 -
pub = 75176a35bd142cd5472b9b9338bd21f804fd67c8177753d7d0a6ecacae1d948f -
Address from this key: 622488 (matches the Vault)
-
Search time: a few seconds (~200k trials)
>Crafting the transaction
The transaction format is 120 bytes: <IIQQ | pubkey(32) | sig(64)> with little-endian integers.
Set fields:
-
from_addr = 622488(Vault) -
to_addr = 0(our receiver) -
amount = 13371337(magic amount) -
nonce = 8(current Vault nonce observed via LIST)
Sign the 24-byte prefix with the forged key and append pubkey + signature:
import struct, socket, nacl.signing
seed = bytes.fromhex('6e71110e265c2821e4a055af275cf47da12d0390bc6e0b737e00ea6cd3da34e1')
sk = nacl.signing.SigningKey(seed)
msg = struct.pack('<IIQQ', 622488, 0, 13371337, 8)
sig = sk.sign(msg).signature
tx_hex = (msg + sk.verify_key.encode() + sig).hex()
s = socket.create_connection(('4.211.248.144', 8080))
s.recv(1024) # banner
s.sendall(b'TX ' + tx_hex.encode() + b'\n')
print(s.recv(4096).decode())
The server replied OK and immediately printed the flag.
>Flag
nexus{Th1s-Is_A-Hard#Fl4g!}
>Key takeaways
-
Truncating identities to save space is dangerous: collisions are easy to brute-force when only 20 bits are checked.
-
Signature verification must bind to the exact public key, not a lossy projection of it.
-
Always re-derive addresses from the same function used to authorize ownership, or include the full public key in the authenticated message.