CTF — AceBear Security Contest 2019

duudududduduud

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.

WEBSQL-INJECTIONAES-CBCBIT-FLIPPINGFILE-UPLOAD

Info

What do we have?

Landing Page

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.

Let's solve it

Backup file

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.)

Login logic

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.

AES stuff

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.

Critical flaw

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.

AES CBC diagram

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:

  • The block is decrypted using the secret key, producing garbage output.
  • That garbage is XORed with the ciphertext of the previous block - which we fully control and can see - to produce the real plaintext.

Which means: if we modify the ciphertext of block 1, we directly control the plaintext of block 2.

Simon Smells

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.

Payload works

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.

Confirmed as admin

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.

Upload page

Sure enough, the upload page works - confirmation we're genuinely an admin now.

Code review

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.

Got the flag

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.

Something extra

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 →