HARDWARE WALLET SUDOKU: ELLIPTIC RELATIONS LEDGER DONJON CTF WRITEUP

2Nzd...aHc7
8 Mar 2024
57




ELLIPTIC RELATIONS

We found this binary, which seems to come from a well known hardware wallet. We were told it contains a secret. Can you retrieve it?

First looking at this, there are too many syscalls (SVC 1) which are unknown. After searching, we found out that thereโ€™s an emulator for Ledger wallets called speculos, and it was able to figure out how those syscalls work.
For example, look at this syscall function:

.text:C0D012C0 cx_rng_ID_IN                            ; CODE XREF: sub_C0D01020+8Aโ†‘p
.text:C0D012C0
.text:C0D012C0 var_18          = -0x18
.text:C0D012C0 var_14          = -0x14
.text:C0D012C0
.text:C0D012C0                 PUSH    {R3-R7,LR}
.text:C0D012C2                 STR     R0, [SP,#0x18+var_18]
.text:C0D012C4                 STR     R1, [SP,#0x18+var_14]
.text:C0D012C6                 LDR     R0, =0x6000052C
.text:C0D012C8                 MOV     R1, SP
.text:C0D012CA                 BL      call_svc_1
.text:C0D012CE                 LDR     R0, [SP,#0x18+var_14]
.text:C0D012D0                 ADD     SP, SP, #0x10
.text:C0D012D2                 POP     {R7,PC}

It set R0 = 0x600052C, and R1 = arguments. If you look at bolos_syscalls_1.6.h in speculos, you can find out that this function is cx_rng_ID_IN, and also figure out how the syscall works by searching cx_rng.

// bolos_syscalls_1.6.h
...
#define SYSCALL_cx_rng_u32_ID_IN  0x600089ecUL
#define SYSCALL_cx_rng_u32_ID_OUT  0x900089d4UL
#define SYSCALL_cx_rng_ID_IN  0x6000052cUL
#define SYSCALL_cx_rng_ID_OUT  0x90000567UL
#define SYSCALL_cx_hash_ID_IN  0x6000073bUL
...
// emu_cx.c
/* not secure, but this is clearly not the goal of this emulator */
unsigned long sys_cx_rng(uint8_t *buffer, unsigned int length)
{unsigned int i;

  if (!initialized) {srand(get_rng_seed_from_env("RNG_SEED"));initialized = true;}

  for (i = 0; i < length; i++) {buffer[i] = rand() & 0xff;}

  return 0;
}

After renaming all the syscall functions, we found a suspicious function:

int __fastcall sub_C0D000D4(int a1)
{char *v2; // r7int v3; // r1int i; // r5int v5; // r0int v6; // r6int v7; // r0int v9; // [sp+8h] [bp-98h]cx_ecfp_256_public_key_s pub_key; // [sp+18h] [bp-88h] BYREFcx_ecfp_256_private_key_s priv_key; // [sp+64h] [bp-3Ch] BYREF

  v2 = &byte_C0D02C29[1];v3 = 0;while ( v3 != 27 ){v9 = v3;cx_ecfp_init_private_key_ID_IN(CX_CURVE_SECP256K1, a1 + byte_C0D02C29[9 * v3], 1, (int)&priv_key);cx_ecfp_generate_pair_ID_IN(CX_CURVE_SECP256K1, (int)&pub_key, (int)&priv_key, 1);if ( !pub_key.W_len )return 0;for ( i = 0; i != 8; ++i ){v5 = v2[i];if ( !*(_BYTE *)(a1 + v5) )return 0;cx_ecfp_scalar_mult_ID_IN(LOBYTE(pub_key.curve), (int)pub_key.W, pub_key.W_len, a1 + v5, 1);}v6 = 0;if ( pub_key.W_len == 65 ){v7 = sub_C0D02A80(pub_key.W, dword_C0D02D1C, 65);v2 += 9;v3 = v9 + 1;if ( !v7 )continue;}return v6;}return 1;
}

It calculates (a1 + byte_C0D02C29[9 * i + 0]) * ... * (a1 + byte_C0D02C29[9 * i + 8]) * G for each i = 0..26, and then compare with the point dword_C0D02D1Ca1 is byte_20001800, which is 81-bytes. It seems like Sudoku, doesnโ€™t it?
Next, letโ€™s see the setter of byte_20001800.

int __fastcall set_byte_20001800(unsigned int idx, char value)
{int v2; // r4int v6; // [sp+4h] [bp-1Ch] BYREF_BYTE v7[24]; // [sp+8h] [bp-18h] BYREF

  v2 = -1;if ( idx <= 0x50 && !byte_20001800[idx] ){LOBYTE(v6) = 64;v7[0] = value;if ( cx_math_is_prime_ID_IN((int)v7, 1) ){if ( cx_math_cmp_ID_IN(v7, &v6, 1) < 0 ){byte_20001800[idx] = value;v2 = 0;}}}return v2;
}

Okay, every value should be a prime number less than 64. From this, we can brute-force to get a that dword_C0D02D1C = a * G.

from ecdsa import VerifyingKey, SECP256k1
from ecdsa.ellipticcurve import Point
from ecdsa.util import sigencode_string
from ecdsa.numbertheory import inverse_mod

with open('elliptic_relations.elf', 'rb') as f:data = f.read()

byte_C0D02C29 = data[0x12C29:0x12C29 + 9 * 27]
dword_C0D02D1C = data[0x12D1C:0x12D1C + 65]
dword_C0D02C14 = data[0x12C14:0x12C14 + 21]

# primes under 64
primes = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61]
assert max(byte_C0D02C29) == 80

arr = dword_C0D02C14 + b'\x01' * 60

x = 1
unknown = []
for v in byte_C0D02C29[18:27]:if v <= 20:print(arr[v])x *= arr[v]else:unknown.append(v)

import itertools

for x1, x2, x3, x4, x5 in itertools.product(primes, repeat=5):print(x1, x2, x3, x4, x5)y = x * x1 * x2 * x3 * x4 * x5

    point = SECP256k1.generator * yif dword_C0D02D1C == bytes.fromhex("04{:064x}{:064x}".format(point.x(), point.y())):print("WOW")break

After running the script, we got dword_C0D02D1C = 5 * 11 * 13 * 19 * 23 * 31 * 37 * 43 * 53 * G. Nine different primes. So, Sudoku confirmed.
Flag-printing logic is here (run after sub_C0D000D4):

int sub_C0D00180()
{int i; // r0char v2[124]; // [sp+Ch] [bp-7Ch] BYREF

  cx_sha256_init_ID_IN(v2);cx_hash_ID_IN((int)v2, 1, (int)load_sth, 81, (int)byte_20001A6C, 32);for ( i = 0; i != 26; ++i )byte_20001A6C[i] ^= *((_BYTE *)dword_C0D02EFC + i);return sub_C0D001D0(26, 1);
}

Finally, here is the solver that uses z3 to solve the Sudoku puzzle:

from ecdsa import VerifyingKey, SECP256k1
from ecdsa.ellipticcurve import Point
from ecdsa.util import sigencode_string
from ecdsa.numbertheory import inverse_mod

with open('elliptic_relations.elf', 'rb') as f:data = f.read()

byte_C0D02C29 = data[0x12C29:0x12C29 + 9 * 27]
assert max(byte_C0D02C29) == 80
dword_C0D02D1C = data[0x12D1C:0x12D1C + 65]
dword_C0D02C14 = data[0x12C14:0x12C14 + 21]
dword_C0D02EFC = data[0x12EFC:0x12EFC + 26]

from z3 import *

primes_to_int = {5: 1, 11: 2, 13: 3, 19: 4, 23: 5, 31: 6, 37: 7, 43: 8, 53: 9}
int_to_primes = [0, 5, 11, 13, 19, 23, 31, 37, 43, 53]

arr = [Int('Arr{}'.format(i)) for i in range(81)]
s = Solver()

for i in range(21):s.add(primes_to_int[dword_C0D02C14[i]] == arr[i])
for i in range(81):s.add(And(1 <= arr[i], arr[i] <= 9))

for v3 in range(27):s.add(Distinct([arr[v] for v in byte_C0D02C29[9 * v3:9 * v3 + 9]]))

if s.check() == unsat:exit(0)

m = s.model()
ans = bytes(int_to_primes[m[v].as_long()] for v in arr)

import hashlib

hasher = hashlib.sha256()
hasher.update(ans)
hsh = bytearray(hasher.digest())

for i in range(26):hsh[i] ^= dword_C0D02EFC[i]

print(hsh)

The flag is CTF{r4nD0m1Z3d_sUD0ku_FTW}.

Get fast shipping, movies & more with Amazon Prime

Start free trial

Enjoy this blog? Subscribe to Ates.eth

1 Comment