BlackBank¶
Flag: MCTF{v8_1s_n0t_SEcur3_4t_4l7}
Overview¶
BlackBank exposed two related apps: a bank panel and a matching mail system. The challenge gave me access to a low-level member account, but the flag only appeared on the boss account's dashboard.
What made this challenge interesting is that I did not get the solve by chaining normal web bugs all the way through. I spent a long time trying to break the application layer first, got nowhere, stepped away, and came back with a different question:
if I cannot force the app to skip 2FA, can I predict the next 2FA code instead?
That turned out to be the real path.
The final chain was:
- exploit SQL injection in the bank login
- extract the boss credentials and 2FA mail routing
- fail to land any practical web-app-side 2FA bypass
- notice the stack is Express, so the randomness is likely coming from Node and V8
- collect enough 2FA codes from my own account
- recover the V8
Math.random()state - predict the next boss 2FA code and log in as
Vladizlow
Step 1: Extract the boss credentials¶
The bank login accepted SQL injection in the username field. Union probing showed the query expected four columns, and blind extraction revealed the backing table:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
emailUser TEXT NOT NULL
)
The interesting rows were:
| id | username | password | emailUser |
|---|---|---|---|
| 1 | katarina | Kathax0r_sk1d0s | katarina |
| 2 | Vladizlow | xeAgQ8dJcc0hUVCm2EV9 | admin |
That immediately explained why the challenge was not solved after dumping the password. Vladizlow did not receive 2FA codes in the same mailbox as the low-privilege account.
Step 2: Everything I tried on the web app first¶
After getting Vladizlow's password, I assumed the rest of the solve would still be in the web layer. I tried basically every angle that looked remotely plausible:
- broken access control on the bank and mail routes
- response manipulation and parameter tampering around
/bank/2fa,/bank/resend, and/bank/profile - IDOR-style pivots around anything user-linked in bank or mail flows
- race conditions during login, resend, and 2FA verification
- second-order SQLi ideas, especially anything that might poison
emailUseror change where the code was delivered - session confusion, like validating one user then swapping to another
- brute-forcing the admin mailbox and other obvious credential guesses
None of it worked.
That was the turning point. I had real credentials for the boss account, but the web app would not give me the second factor no matter how I poked at it. So I stopped asking "how do I bypass this page?" and started asking "how is this code generated?"
The first clue was the stack. The app looked like Express, which strongly suggested Node.js on the backend. Once I was thinking in Node terms, the next thought was obvious: if they used weak randomness for the 2FA code, then that randomness probably comes from V8.
The second clue was the shape of the codes themselves. They looked exactly like:
That was the moment the challenge stopped looking like a pure web exploit and started looking like a runtime / PRNG problem.
Step 3: Why Express led me to V8 Math.random()¶
Express meant Node, and Node meant V8. If the developer had used the lazy option for code generation, there was a good chance the 2FA values came from Math.random() rather than a cryptographic API like crypto.randomInt().
Once I started treating the 2FA code as a PRNG output instead of a web token, the rest of the path made sense:
- I could generate more codes for my own account with
/bank/resend - those codes gave me observations from the same backend RNG
- if the implementation was really V8
Math.random(), then I could try recovering the generator state and predicting the next output forVladizlow
Node's Math.random() is powered by V8's xorshift128+ generator. For performance, V8 fills a 64-value cache and serves the numbers in LIFO order, which means the observed outputs have to be reversed before feeding them into a forward solver.
That mattered for two reasons:
Math.random()is not suitable for security-sensitive values like 2FA codes- observing a small batch of outputs from one cache segment is enough to recover the 128-bit internal state
In this challenge, around ten consecutive codes from a single session were enough to recover a unique state candidate with a Z3-based model.
Step 4: Collect codes from a single session¶
The important operational detail was keeping all observed codes inside one cache segment. I used one mail session to read the inbox, and one bank session to repeatedly trigger resend:
import requests, re, sys, time
BASE = sys.argv[1]
mail = requests.Session()
mail.post(f"{BASE}/mail/login", data={
"username": "katarina",
"password": "Kathax0r_sk1d0s",
})
bank = requests.Session()
bank.post(f"{BASE}/bank/login", data={
"username": "katarina",
"password": "Kathax0r_sk1d0s",
})
codes = []
for _ in range(10):
bank.post(f"{BASE}/bank/resend")
time.sleep(0.3)
seen = re.findall(r'⚡ (\d+)', mail.get(f"{BASE}/mail/inbox").text)
if seen and (not codes or seen[0] != codes[-1]):
codes.append(seen[0])
print(codes)
A sample batch looked like:
Step 5: Recover the state and predict the next code¶
Feeding those observations into a V8 xorshift128+ model produced a single candidate state and a short list of future outputs. The next relevant prediction was:
The key modeling detail was accounting for both:
- the
Math.floor(100000000 * x)truncation - the reversed output order caused by the 64-value LIFO cache
Without the cache reversal, the constraints do not line up with V8's real output stream.
Step 6: Log in as the boss¶
Once I had Vladizlow's password from SQLi and the next 2FA prediction from the solver, the last step was just a normal login:
import requests, re, sys
BASE = sys.argv[1]
boss = requests.Session()
boss.post(f"{BASE}/bank/login", data={
"username": "Vladizlow",
"password": "xeAgQ8dJcc0hUVCm2EV9",
})
boss.post(f"{BASE}/bank/2fa", data={"code": "67610404"})
profile = boss.get(f"{BASE}/bank/profile").text
print(re.findall(r'MCTF\\{[^}]+\\}', profile)[0])
That redirected to the boss dashboard and revealed:
Full chain¶
- Use SQLi on
/bank/loginto dump theuserstable. - Recover
Vladizlow's password and the fact that his codes are routed to theadminmailbox. - Try the normal web-app routes first: BAC, response manipulation, IDOR ideas, race conditions, second-order SQLi, and admin brute-force.
- Conclude that the app layer is not giving a practical bypass.
- Notice the backend is Express, infer Node/V8, and pivot to the RNG behind the 2FA codes.
- Log in as
katarinaand collect consecutive 2FA codes from one bank session. - Recover the V8 xorshift128+ state from those observed codes.
- Predict the next valid boss 2FA code.
- Log in as
Vladizlow, submit the predicted code, and read the flag from/bank/profile.
Takeaways¶
Math.random()should never be used for authentication or 2FA.- Output caching details matter just as much as the PRNG itself when you are modeling a real implementation.
- SQLi only got me to the interesting part. The actual solve came from recognizing that the web app was not budging and shifting the attack to the runtime's randomness instead.