RouteGuard Structural Audit CTF: CBC Bit-Flipping for Privilege Escalation Without a MAC

Challenge: RouteGuard Structural Audit

Platform: LevelUpCTF

Category: Cryptography / CBC Bit-Flipping

Difficulty: Medium

Reading Time: 12 min

RouteGuard Structural Audit: Challenge Brief

VeloGrip Logistics recently deployed the RouteGuard middleware to secure manifest data for high-value cargo. However, a whistleblower suggests that the session tokens, which lack a digital signature layer, can be structurally manipulated by drivers to gain unauthorized access to restricted manifests.

RouteGuard is a middleware that issues encrypted session tokens to drivers. The tokens are presented at weigh stations to unlock cargo manifests. The objective of the audit is to confirm whether an unauthorized (guest) driver can tamper with their issued token to escalate privileges and read a restricted manifest.

API Surface

Method Endpoint Purpose
GET/api/v1/request_tokenIssue a new guest manifest access token
POST/api/v1/get_manifestRetrieve a manifest; body {"token": "iv_hex:ct_hex"}
POST/submitVerify the recovered flag; body {"flag": "LEVELUP{...}"}

1. Reconnaissance

A single call to the token endpoint reveals the token format:

GET /api/v1/request_token  ->  HTTP 200
{"status":"success",
 "token":"d2400cd09cdbef7fbae88d7c96646d9e:1d0fa3f2d46891c78ef315cfcf8ee1b7e170ea38cf475dfeea24ce35d79a0c5b3e7b034b406f7dec7956e89ed9f00f40"}

Breaking the token apart on the : delimiter:

Component Hex length Bytes Notes
IV3216One AES block
Ciphertext9648Exactly 3 AES blocks

So the token is a classic AES-CBC blob: IV : C0 C1 C2, decrypting to 48 bytes of plaintext (3 blocks). Critically, the briefing confirms there is no MAC or digital signature wrapping the ciphertext - the server will decrypt whatever bytes we hand it.

Submitting the unmodified guest token tells us what we are escalating toward:

POST /api/v1/get_manifest  (unmodified token)  ->  HTTP 403
{"message":"Access denied. Requires admin privileges.","status":"denied"}

Probing with an empty token, a missing token field, and an obviously malformed token all returned the exact same generic 403. That is an important negative result: the service does not leak distinct errors for bad padding or failed decryption, so there is no padding oracle here. The intended path is purely structural manipulation, i.e. CBC bit-flipping, exactly as the challenge tags (cbc, iv-manipulation, bit-flipping) advertise.

Two facts to carry forward:

  1. The decrypted plaintext contains a role/access field whose current value is the guest role.
  2. The privileged value we need is named outright in the error message: admin.

2. Vulnerability Analysis: CBC Bit-Flipping

In CBC mode, each plaintext block is the block-cipher decryption of the corresponding ciphertext block XOR-ed with the previous ciphertext block (the IV serves as the "previous block" for block 0):

P[0] = Decrypt(C[0]) XOR IV
P[n] = Decrypt(C[n]) XOR C[n-1]      for n >= 1

The consequence an attacker cares about: whatever change you XOR into the IV appears, bit-for-bit, in plaintext block 0 after decryption. Likewise, a change XOR-ed into ciphertext block n-1 appears bit-for-bit in plaintext block n, but at the cost of completely scrambling block n-1 (because Decrypt(C[n-1]) now produces garbage).

Because RouteGuard never authenticates the token (no signature/MAC), nothing stops us from editing the IV. If the role string lives in plaintext block 0, we can surgically rewrite it through the IV without corrupting any other byte of the plaintext.

The guest role is the 5-byte string guest; the target is the 5-byte string admin. Equal length, so the edit is a pure in-place byte flip with no need to touch padding. The required XOR delta is:

guest = 67 75 65 73 74
admin = 61 64 6d 69 6e
delta = 06 11 08 1a 1a      (guest[i] XOR admin[i])

XOR-ing delta into the five IV bytes that line up with guest flips the decrypted block-0 plaintext from ...guest... to ...admin....

The only unknown was the offset of the role string within the plaintext, so the exploit brute-forces every possibility: 3 candidate blocks × 12 candidate offsets = 36 attempts against a single issued token.

3. Exploitation

The exploit requests one token, then for every (block, offset) pair applies the delta to the appropriate keystream-controlling block (the IV for block 0, or ciphertext block n-1 for block n) and submits the tampered token. Any response that is not the standard 403 / denied is a hit.

#!/usr/bin/env python3
"""RouteGuard CBC bit-flipping exploit."""
import requests

BASE  = "http://target:5000"
BLOCK = 16
OLD, NEW = b"guest", b"admin"
DELTA = bytes(a ^ b for a, b in zip(OLD, NEW))      # 06 11 08 1a 1a

def get_token():
    return requests.get(f"{BASE}/api/v1/request_token", timeout=10).json()["token"]

def try_token(token):
    r = requests.post(f"{BASE}/api/v1/get_manifest", json={"token": token}, timeout=10)
    return r.status_code, r.text

def main():
    iv_hex, ct_hex = get_token().split(":", 1)
    iv, ct = bytearray.fromhex(iv_hex), bytearray.fromhex(ct_hex)
    nblocks = len(ct) // BLOCK

    for target_block in range(nblocks):
        for off in range(0, BLOCK - len(DELTA) + 1):
            iv2, ct2 = bytearray(iv), bytearray(ct)
            if target_block == 0:                       # edit block 0 via the IV
                for i, d in enumerate(DELTA):
                    iv2[off + i] ^= d
            else:                                       # edit block n via C[n-1]
                base = (target_block - 1) * BLOCK
                for i, d in enumerate(DELTA):
                    ct2[base + off + i] ^= d
            tok = iv2.hex() + ":" + ct2.hex()
            code, body = try_token(tok)
            if code != 403 or "denied" not in body:
                print(f"[!!] HIT block{target_block} off{off}  HTTP {code}")
                print(f"     {body}")

if __name__ == "__main__":
    main()

Result

A single candidate succeeded - block 0, offset 7 - meaning the role string sits in the very first plaintext block and was edited cleanly through the IV alone:

[*] token issued. iv=16B ct=48B (3 blocks)
[*] delta(guest->admin) = 0611081a1a
[!!] HIT  block0 off 7  HTTP 200
     token: 840a42cd3f9c2be7111c80d95f968099:04c59bbe955660540283deddf10c1167dc2fadffaadf086a55f557be2c075004eb0d5b5660e19d36218827618e834b9d
     body : {"flag":"LEVELUP{REDACTED}",
             "manifest":"VGL-9921 Restricted Manifest: Priority High. Verified Admin Access.",
             "status":"success"}

The fact that the hit was a clean block-0 IV flip (and that all block-1 / block-2 attempts failed) tells us the plaintext begins with the access field, a layout consistent with something like access=guest;..., where access= occupies the first 7 bytes and guest begins exactly at offset 7. Flipping those five IV bytes turned the decrypted token into access=admin;..., which RouteGuard accepted as a privileged session.

RouteGuard Structural Audit CTF Flag

LEVELUP{REDACTED}

Submit it for verification:

curl -s -X POST http://target:5000/submit \
     -H 'Content-Type: application/json' \
     -d '{"flag":"LEVELUP{REDACTED}"}'

RouteGuard Attack Chain

GET /api/v1/request_token issues a guest token (iv_hex:ct_hex)
    |
    v
Token is AES-CBC: 16-byte IV + 3 ciphertext blocks (48 bytes plaintext)
There is no MAC / signature wrapping the ciphertext
    |
    v
POST /api/v1/get_manifest with the unmodified token returns 403,
revealing the privileged role name: "admin"
    |
    v
CBC algebra: P[0] = Decrypt(C[0]) XOR IV, so changing the IV
edits plaintext block 0 byte-for-byte without scrambling any block
    |
    v
delta = b"guest" XOR b"admin" = 06 11 08 1a 1a (equal-length, no padding edit)
    |
    v
Brute-force 3 candidate blocks x 12 offsets per block = 36 tampered tokens
    |
    v
HIT at block 0, offset 7 (IV flip) -> 200 OK with the manifest and flag

4. Root Cause & Remediation

Root cause. RouteGuard encrypts its session tokens for confidentiality but never authenticates them. AES-CBC alone provides no integrity guarantee, so a token holder can flip bits in the IV (or ciphertext) and the server will trust the resulting decrypted role. Encryption was mistaken for tamper-proofing.

Remediation:

  • Authenticate the token. Use an authenticated encryption mode such as AES-GCM or AES-CBC + HMAC (encrypt-then-MAC). Reject the token before decryption if the tag/MAC does not verify. This single change defeats the entire attack.
  • Do not trust the client for authorization state. Keep the role/access level server-side, keyed by an opaque, unforgeable session identifier, rather than embedding it in a client-held blob.
  • Use a fresh, random IV per token (RouteGuard does this) and cover the IV with the MAC so IV tampering is detected.
  • Sign or version tokens so structural changes are detectable and old formats can be retired.

A correctly authenticated token would have caused get_manifest to reject the modified blob outright, and the privilege-escalation path would not exist.

RouteGuard Structural Audit: Lessons Learned

  1. Encryption is not integrity. A common mistake is to ship a value encrypted to the client and treat it as untouchable on the way back in. AES-CBC (and CTR, OFB, CFB) provide confidentiality only. Without a MAC over the ciphertext, every byte the server decrypts is attacker-controlled in a predictable way.
  2. Uniform error responses still leak structure. The generic 403 for every failure case ruled out a padding oracle and pointed the audit at structural manipulation. The error message itself ("Requires admin privileges") then named the target role - error text intended to be reassuring to legitimate users handed the attacker the exact string to flip toward.
  3. Equal-length role strings are a gift to bit-flipping attackers. guest and admin are both 5 bytes, so the edit is a pure XOR with no padding considerations. Variable-length role names, or fixed-width padded role fields, would have forced multi-block manipulation and probably required a more invasive (and noisier) attack.
  4. IV manipulation is the cleanest flip. Editing the IV rewrites plaintext block 0 with zero collateral damage. Editing ciphertext block n-1 to control plaintext block n scrambles block n-1, which usually breaks the surrounding structure. Always probe block 0 first when the role/header field is likely up top.

RouteGuard Structural Audit: Final Thoughts

The challenge name says it all: a structural audit. There is no fancy cryptanalysis, no novel weakness in AES, no padding oracle to grind through. The vulnerability is the same one that has haunted unauthenticated CBC for two decades: ciphertext malleability against an attacker who can submit modified tokens and watch the server's reaction.

The fix is not to invent a new protocol, it is to use the one cryptography spent years standardising for exactly this scenario: an authenticated encryption mode that rejects tampered ciphertext before the server even looks at the plaintext. AES-GCM, ChaCha20-Poly1305, or AES-CBC plus an HMAC-SHA256 over the full IV || ciphertext blob all stop this attack at the door.

Challenge hosted on LevelUpCTF. Writeup completed: May 2026.