← Back to Writeups
CUSTOMMediumCrypto

Lazarus - Rewind 2

XESXOR9/2/20265 min read
#crypto#custom#medium#deterministic_beacon_replay#probability_sampling_reconstru#elliptic_curve_public_key_veri

Lazarus - Rewind 2

Platform: CUSTOM | Category: Crypto | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: deterministic_beacon_replay, probability_sampling_reconstruction, elliptic_curve_public_key_verification

Summary

Walkthrough of the CUSTOM Crypto Lazarus - Rewind 2. Task: A secp256k1 wallet key comes from a supposedly device-independent CHSH quantum beacon whose public generation block seeds every trial. Solution: replay the hash-derived sampling, hash all 1024 o

Description

A supposedly unbreakable device-independent quantum RNG generated a secp256k1 wallet key; spot the implementation's lazy corner and recover the wallet.

The challenge exposed an HTTP documentation site and a TCP wallet service. The goal was to recover the private scalar corresponding to the advertised secp256k1 public point, then provide it to the wallet service.

Analysis

Service surface

The two services had distinct roles:

  • http://154.57.164.74:31041/docs disclosed the complete deterministic CHSH beacon algorithm. The same web application exposed an encrypted /onion archive.
  • 154.57.164.74:30958 accepted case, observe <nonce>, and seize <hexkey> commands. case returned the wallet generation block and its public point. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The public wallet data was:

text
gen_block = 41a2ab27e654e682142d5bc4fcfd876001d891333b5ee81fc5623b8092cc5790
pubkey_x  = 5521380e6e7c2181e33baaf28a0836eea739df2676d0e36c2186a3b19368a48b
pubkey_y  = dc40f3cc07c63e2833e6631082d20a227300702bd86ef4e449d3d8c328e6889c

The lazy corner: deterministic "quantum" sampling

The documentation defines the 32-byte generation block directly as the beacon seed H. For every trial i from 0 through 1023, it computes

text
tb = SHA256(H || "|trial|" || i.to_bytes(4, "big"))
a  = tb[0] & 1
b  = (tb[0] >> 1) & 1
lambda = int(tb[8:16], "big") / 2^64

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The CHSH correlation is +1/sqrt(2) for settings (0,0), (0,1), and (1,0), and -1/sqrt(2) for (1,1). For each candidate outcome (x,y), its probability is

text
P(x,y | a,b) = (1 + (-1)^(x+y) * E(a,b)) / 4.

The implementation visits outcomes in the fixed order (0,0), (0,1), (1,0), (1,1) and selects the first one whose cumulative probability exceeds lambda.

This models the desired CHSH distribution, but it does not obtain a fresh random draw from a quantum device. Both the settings and lambda come from SHA-256 of the public H and trial number. Therefore every one of the purportedly quantum outcomes is publicly reproducible. The private scalar is then simply ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

text
W = SHA256(concatenated ASCII x/y outcome bits for all 1024 trials).

There is no unknown entropy to brute-force. There is also no reason to solve the secp256k1 discrete-log problem: replaying the published generation procedure produces the scalar directly.

Solution

1. Replay the beacon

The following solver is the working task script. It reproduces the server's floating-point cumulative sampling exactly, derives W, and implements secp256k1 point arithmetic without external packages. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
#!/usr/bin/env python3
import hashlib
import math
import sys

P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
GX = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
GY = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8

EXPECTED_X = 0x5521380E6E7C2181E33BAAF28A0836EEA739DF2676D0E36C2186A3B19368A48B
EXPECTED_Y = 0xDC40F3CC07C63E2833E6631082D20A227300702BD86EF4E449D3D8C328E6889C
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍


def beacon(seed: bytes):
    outcomes = []
    counts = {(a, b): [0, 0] for a in range(2) for b in range(2)}
    pairs = [(0, 0), (0, 1), (1, 0), (1, 1)]

    for i in range(1024):
        tb = hashlib.sha256(
            seed + b"|trial|" + i.to_bytes(4, "big")
        ).digest()
        a, b = tb[0] & 1, (tb[0] >> 1) & 1
        lam = int.from_bytes(tb[8:16], "big") / 2**64
        correlation = (-1 if (a, b) == (1, 1) else 1) / math.sqrt(2)

        cumulative = 0.0
        selected = pairs[-1]
        for x, y in pairs:
            cumulative += (
                1 + ((-1) ** (x + y)) * correlation
            ) / 4
            if lam < cumulative:
                selected = (x, y)
                break
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

        x, y = selected
        outcomes.append(f"{x}{y}")
        counts[(a, b)][0] += 1 if x == y else -1
        counts[(a, b)][1] += 1

    private_key = hashlib.sha256("".join(outcomes).encode()).hexdigest()
    observed = {
        ab: total / number for ab, (total, number) in counts.items()
    }
    chsh = (
        observed[(0, 0)]
        + observed[(0, 1)]
        + observed[(1, 0)]
        - observed[(1, 1)]
    )
    return private_key, chsh


def add(q, r):
    if q is None:
        return r
    if r is None:
        return q
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    x1, y1 = q
    x2, y2 = r
    if x1 == x2 and (y1 + y2) % P == 0:
        return None

    if q == r:
        slope = (3 * x1 * x1) * pow(2 * y1, -1, P) % P
    else:
        slope = (y2 - y1) * pow(x2 - x1, -1, P) % P

    x3 = (slope * slope - x1 - x2) % P
    return x3, (slope * (x1 - x3) - y1) % P


def mul(k, q=(GX, GY)):
    result = None
    while k:
        if k & 1:
            result = add(result, q)
        q = add(q, q)
        k >>= 1
    return result


if __name__ == "__main__":
    seed_hex = (
        sys.argv[1]
        if len(sys.argv) > 1
        else "41a2ab27e654e682142d5bc4fcfd876001d891333b5ee81fc5623b8092cc5790"
    )
    key, chsh = beacon(bytes.fromhex(seed_hex))
    point = mul(int(key, 16) % N)
    assert point == (EXPECTED_X, EXPECTED_Y)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    print(f"chsh={chsh:.6f}")
    print(f"private_key={key}")
    print(f"pubkey_x={point[0]:064x}")
    print(f"pubkey_y={point[1]:064x}")

Running it against the public generation block recovered this private scalar:

text
8741d975bc3cf1e0a7e5d92d7d0f3c4c1cecc52e119bb0da6a87ac2972b85aa9

2. Verify the seed interpretation and key

Scalar multiplication of the recovered value by the secp256k1 generator produced the exact advertised (pubkey_x, pubkey_y). This equality independently confirms both important implementation details: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  1. gen_block is the exact 32-byte H used for wallet generation.
  2. The outcome serialization and SHA-256 derivation match the wallet service.

An incorrect seed, probability order, comparison boundary, or outcome encoding would produce an unrelated scalar and public point.

3. Submit the recovered key

Connect to the TCP service and use the hexadecimal scalar returned by the solver:

text
$ nc 154.57.164.74 30958
seize <RECOVERED_PRIVATE_KEY>

The service accepted the key and returned the challenge flag. The same key also unlocked /onion; its archived chat explicitly stated that the operator rebuilt the wallet key from public beacon data whenever it was needed, corroborating the intended flaw. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering deterministic_beacon_replay, probability_sampling_reconstruction, elliptic_curve_public_key_verification was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-08-14 • CUSTOM • Lazarus - Rewind 2 • Difficulty: Medium