Chaining AES-CBC bit-flipping into a blind SQL injection to log in as admin, then abusing a broken upload manifest check to get a webshell and read the flag.
backup.bak?AceBear{From_Crypt0_m1sus3_t0_Rc3_______}We land on a login page. Registering an account works fine, and so does logging in with it - but the site's quick to remind us we're not an admin. Clearly, that's the actual goal here.
Following the hint gets us exactly what it promises - a backup.bak containing the full source. From here it's just a matter of working out what does what. (I've stripped a stray .git directory out of the archive linked above - it wasn't relevant and was adding 60MB for nothing.)
Since logging in as admin is the whole point, login.php is the obvious first stop. Line 10 shows a SQL injection sitting in the username parameter - and that username is actually sourced from the session_remember cookie, which gets passed into a check_cookie function.
That leads straight into lib/connection.php. Line 10 jumps out immediately - that's clearly where the flag lives, so reading this file is the end goal. More interesting for now is check_cookie itself: it takes a string to decrypt and a secret key we don't have. The string gets base64-decoded, then AES-decrypted - and critically, the IV and the key are the same value. If we can recover the IV, we've recovered the key.
Back in login.php, there's a second flaw worth flagging: if the SQL query doesn't return exactly one row - for any reason - the server helpfully prints the AES-decrypted string right back at us. Worst case, that's a brute-forceable oracle. Best case, it's an opening for some AES-CBC trickery.
Quick refresher on how AES-CBC decryption actually works, since it matters a lot here. AES is a block cipher - data gets split into fixed-size blocks and processed one at a time. From the second block onward, decryption goes:
Which means: if we modify the ciphertext of block 1, we directly control the plaintext of block 2.
Put into practice - a small script that flips bytes in block 1's ciphertext in a controlled way, letting us dictate block 2's plaintext. As proof, I got block 2 to decrypt to Simon Smells. From there, dropping a SQL injection payload into block 2 is trivial in principle.
The real constraint: the injection I need is way longer than a single 16-byte block. So the plan becomes injecting into every second block (2, 4, 6, 8...). Put part of the payload in block 2, close it out with /*, and it no longer matters what block 3 decrypts to - it's commented out. Block 4 opens with */ to resume, adds more of the payload, closes with /* again, and so on, until the full injection is built - finished off with a -- to comment out whatever junk is left over.
To calibrate this, I sent the server a block of As and recorded the decrypted result for use inside the script. I wrote a small Node.js tool to generate the full token automatically (linked above). The final injected payload across the blocks worked out to:
' UNION /XXXXXXXXXXXXXXXX/ SELECT /XXXXXXXXXXXXXXXX/ 'lol',1 -- /XXXXXXXXXXXXXXXX/a/*
(The XXXXXXXXXXXXXXXX segments are the uncontrollable blocks.) This performs a UNION SELECT returning username = 'lol', admin = 1 - matching the query structure we already saw in login.php. The server hands back a cookie with admin access.
Using that cookie confirms we're in as admin, and comes with a hint that something isn't implemented correctly for admin accounts. The upload button sitting at the top of the screen is the obvious next lead.
Sure enough, the upload page works - confirmation we're genuinely an admin now.
Back to the source to see how uploads are validated. Line 47 checks for a manifest.json inside the uploaded package. Line 48 requires that file to contain type: "h4x0r", with name allowed to be anything. Trivial to satisfy - {"type": "h4x0r", "name": "pwned"} - and from there, upload whatever webshell you like.
With a webshell in place, it's just a cat on the original lib/connection.php path - which hands over both the key and the flag itself.
Technically, this broken AES-CBC implementation lets you recover the original IV directly, just by XORing the right values together with a specially crafted payload - no SQL injection required. I went the SQL injection route instead because it felt more fun, and the same attack path would still have worked even if the IV and key hadn't been identical.
Genuinely one of the more enjoyable challenges I've done.
Need help with security testing?
CONTACT US →