One signature, two forms: (r, s) and (r, n − s)

The lab's bank names a transaction by hashing the signature: txid = sha256(str(r) + str(s)). Replace s with n − s and you hold a second, equally valid signature for the same message and the same key — with a different txid.

Pick a signature

The signature you hold

message withdraw 100 to attacker  ·  curve secp256k1

r — malleability never touches r

Form A — (r, s)

s

txid = sha256(str(r) + str(s))

Form B — (r, n − s), the twin

n − s

txid = sha256(str(r) + str(n − s))

Submit them to the bank

Each button replays the same request against a model of both banks at once, so the divergence is on one screen. Statuses, bodies and the order the checks run in match the lab's apps. The one thing the model stands in for is VK.verify(): a pair counts as verifying here exactly when the real bank would accept it — a signature it issued, or that signature's twin. Invent an s and you get 403 bad signature, because malleability reshapes a signature you already hold; it does not forge one.

Vulnerable bank · port 8102

dedup key: sha256(str(r) + str(s))

Fixed bank · port 8103

same dedup, but s ≤ n // 2 is checked first (BIP-62 low-S)

Why it works, in one line each

Real here: n, n − s, the low-S test, and every txid — the digests match hashlib.sha256((str(r)+str(s)).encode()).hexdigest() exactly. Modelled here: the bank's control flow, including where VK.verify() sits in it. No elliptic-curve verification runs on this page; the two sample presets were verified with python-ecdsa beforehand, in both forms.

Do not paste these sample numbers into your own container. Both apps run SigningKey.generate(...) at startup, so your instance has a different key and will answer 403 bad signature to a signature made under someone else's. Get your own pair from GET /sign first — these presets are a captured example to look at, not a payload to replay.

n (secp256k1 order)

n // 2 — the low-S ceiling