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.
message withdraw 100 to attacker ·
curve secp256k1
r — malleability never touches r
s
txid = sha256(str(r) + str(s))
n − s
txid = sha256(str(r) + str(n − s))
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.
dedup key: sha256(str(r) + str(s))
same dedup, but s ≤ n // 2 is checked first
(BIP-62 low-S)
403 bad signature from both banks: they are
arithmetic, not authorizations.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