Challenge: Flip Dat Bit · Platform: TryHackMe · Category: Crypto · Difficulty: Medium


1. Introduction / TL;DR

“Flip off!” — the server, before I understood what was happening.
“No way! You got it!” — the server, moments after.

This challenge drops you in front of a TCP service that asks you to log in as admin. Sounds simple enough — until you notice it both leaks the ciphertext of your own input and asks you to submit a valid ciphertext back. It’s handing you a loaded gun and betting you don’t know how to aim it.

The vulnerability is a textbook AES-CBC bit-flipping attack: by strategically corrupting one byte in the leaked ciphertext, you can surgically alter one byte in the decrypted plaintext — turning bdmin into admin and walking right through the door.

TL;DR: Send a username with a single character off from admin, receive the ciphertext, XOR one byte with 0x03, send it back, collect flag.


2. Initial Recon

Connecting to the service on port 1337 with netcat gives an immediate greeting:

1
2
3
$ nc 10.128.176.38 1337
Welcome! Please login as the admin!
username:

It asks for a username, then a password. When you enter credentials that don’t look suspicious, something interesting happens:

1
2
3
4
5
Welcome! Please login as the admin!
username: hello
hello's password: world
Leaked ciphertext: 3a7f2c94b1e8f042d6a35c9e20b1f7a832c4d5e6f8a9b0c1d2e3f4a5b6c7d8e9
enter ciphertext:

The server encrypts our input and gives us the ciphertext, then asks us to provide a ciphertext of our own. If our ciphertext decrypts to something containing admin&password=sUp3rPaSs1, we get the flag.

The challenge is already showing its hand — this is a chosen-ciphertext scenario powered by the oracle of the service itself.


3. Analysis / Source Code Review

We’re given the server’s Python source. Let’s walk through it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def start(server):
    key = get_random_bytes(16)   # fresh random key per connection
    iv  = get_random_bytes(16)   # fresh random IV per connection

    send_message(server, 'Welcome! Please login as the admin!\n')
    send_message(server, 'username: ')
    username = server.recv(4096).decode().strip()

    send_message(server, username + "'s password: ")
    password = server.recv(4096).decode().strip()

    message = 'access_username=' + username + '&password=' + password

    if "admin&password=sUp3rPaSs1" in message:
        send_message(server, 'Not that easy :)\nGoodbye!\n')  # ← guard #1
    else:
        setup(server, username, password, key, iv)

Guard #1 rejects any raw input that already contains the magic string admin&password=sUp3rPaSs1. So you can’t just type the right credentials directly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def setup(server, username, password, key, iv):
    message = 'access_username=' + username + '&password=' + password
    send_message(server, "Leaked ciphertext: " + encrypt_data(message, key, iv) + '\n')
    send_message(server, "enter ciphertext: ")

    enc_message = server.recv(4096).decode().strip()

    try:
        check = decrypt_data(enc_message, key, iv)
    except Exception as e:
        send_message(server, str(e) + '\n')
        server.close()

    if check:
        send_message(server, 'No way! You got it!\nA nice flag for you: ' + flag)

The server encrypts the message using AES-128-CBC and leaks it. Then it decrypts whatever we send back using the same key and IV.

1
2
3
4
5
6
7
def decrypt_data(encryptedParams, key, iv):
    cipher = AES.new(key, AES.MODE_CBC, iv)
    paddedParams = cipher.decrypt(unhexlify(encryptedParams))
    if b'admin&password=sUp3rPaSs1' in unpad(paddedParams, 16, style='pkcs7'):
        return 1   # ← success condition
    else:
        return 0

Guard #2 (the win condition): after decryption, the plaintext just needs to contain admin&password=sUp3rPaSs1 as a substring. It doesn’t have to be a clean, perfectly-formed message — junk bytes elsewhere are fine.

Two guards. One cipher. One very exploitable mode of operation.


4. Vulnerability Identification

The critical detail is buried in the encryption call:

1
AES.new(key, AES.MODE_CBC, iv)

AES-CBC — Cipher Block Chaining. And the IV is fixed for the session (same IV used to encrypt and to decrypt our returned ciphertext). This is the exact setup that enables a bit-flipping attack.

CBC has a well-known property: flipping a bit in ciphertext block N corrupts block N’s decryption but predictably flips the corresponding bit in block N+1’s decryption. If you control what’s in block N+1’s plaintext, you can use the ciphertext of block N as a lever to change it.

The plaintext structure is:

1
access_username=<USERNAME>&password=<PASSWORD>

The prefix access_username= is exactly 16 bytes — one AES block. That means our username starts at the beginning of block 1. This alignment is not a coincidence; it’s the key that makes the attack clean.


5. Exploitation Strategy — What Is CBC Bit-Flipping?

Let’s build the intuition from scratch.

How AES-CBC Decryption Works

In CBC mode, each plaintext block is produced by:

1
Plaintext[n] = AES_Decrypt(Ciphertext[n]) XOR Ciphertext[n-1]

For the first block, Ciphertext[-1] is the IV.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
      Ciphertext[0]        Ciphertext[1]        Ciphertext[2]
           │                    │                    │
      ┌────▼────┐          ┌────▼────┐          ┌────▼────┐
      │AES Dec. │          │AES Dec. │          │AES Dec. │
      └────┬────┘          └────┬────┘          └────┬────┘
           │                    │                    │
      ⊕ IV │               ⊕ C[0]│              ⊕ C[1]│
           │                    │                    │
      ┌────▼────┐          ┌────▼────┐          ┌────▼────┐
      │Plaintext│          │Plaintext│          │Plaintext│
      │ Block 0 │          │ Block 1 │          │ Block 2 │
      └─────────┘          └─────────┘          └─────────┘

Notice: Plaintext[1] depends directly on Ciphertext[0]. The AES decryption of Ciphertext[1] produces some intermediate bytes; those bytes are then XOR’d with Ciphertext[0] to produce the final plaintext.

The Lever

If we know what Plaintext[1] currently is, and we want to change byte i of Plaintext[1] from A to B, we simply:

1
Ciphertext[0][i] ^= (A XOR B)

This is because:

1
2
3
4
5
New_Plaintext[1][i] = AES_Decrypt(Ciphertext[1])[i] XOR New_Ciphertext[0][i]
                    = AES_Decrypt(Ciphertext[1])[i] XOR (Old_Ciphertext[0][i] XOR A XOR B)
                    = Old_Plaintext[1][i]            XOR A XOR B
                    = A                              XOR A XOR B
                    = B  ✓

The price: modifying Ciphertext[0] makes Plaintext[0] decrypt to garbage. But since the win condition only checks for a substring anywhere in the decrypted output, a corrupted block 0 doesn’t matter.

Why This Challenge Is Vulnerable

  1. The prefix access_username= aligns our username to the start of block 1.
  2. We control the username, so we control what block 1 decrypts to.
  3. The server leaks the ciphertext, giving us Ciphertext[0] to manipulate.
  4. The win condition is a substring check — corrupted blocks elsewhere don’t matter.

Every ingredient is present.


6. Building the Exploit

Step 1 — Choose the Right Username

We need a username that:

  • Fails Guard #1 (the raw string check)
  • Places the target string admin&password=sUp3rPaSs1 in block 1 of plaintext after a single bit-flip

The target string admin&password=sUp3rPaSs1 is 25 bytes. If it starts at byte 16 (the beginning of block 1), it spans into block 2:

1
2
3
4
Block 0 (bytes  0–15): access_username=
Block 1 (bytes 16–31): admin&password=s   ← 16 bytes
Block 2 (bytes 32–47): Up3rPaSs1&passwo   ← continues here
Block 3 (bytes 48–63): rd=anything + pad

We want block 1 to say admin&password=s. Currently, we can make the server encrypt a username that starts with bdmin&password=sUp3rPaSs1:

1
Block 1 (bytes 16–31): bdmin&password=s   ← 'b' instead of 'a'

Does bdmin&password=sUp3rPaSs1 trigger Guard #1?

1
Full message: access_username=bdmin&password=sUp3rPaSs1&password=anything

Searching for admin&password=sUp3rPaSs1 in this string: the only place admin could appear is at position 17, but position 16 is b, not a. Guard #1 does not trigger.

The XOR value needed to flip b (0x62) → a (0x61):

1
0x62 XOR 0x61 = 0x03

Step 2 — Verify the Block Layout

With username bdmin&password=sUp3rPaSs1 and password anything:

1
2
3
Plaintext: access_username=bdmin&password=sUp3rPaSs1&password=anything
           |_____ B0 _____|_____ B1 _____|_____ B2 _____|_____ B3 _____|
           access_username= bdmin&password=s Up3rPaSs1&passwo rd=anything\x05x5
  • B0 (bytes 0–15): access_username=
  • B1 (bytes 16–31): bdmin&password=s
  • B2 (bytes 32–47): Up3rPaSs1&passwo
  • B3 (bytes 48–63): rd=anything + \x05\x05\x05\x05\x05 (PKCS7 padding)

After flipping Ciphertext[0][0] ^= 0x03:

  • B0 → garbage (doesn’t matter)
  • B1admin&password=s
  • B2Up3rPaSs1&passwo (untouched)
  • B3rd=anything\x05… (untouched, valid padding)

unpad strips the trailing \x05 bytes from B3. The resulting plaintext contains:

1
[garbage]admin&password=sUp3rPaSs1&password=anything

The substring admin&password=sUp3rPaSs1 is present. Guard #2 passes.

Step 3 — The Solve Script

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#!/usr/bin/env python3
"""
AES-CBC Bit-Flipping Attack — THM Challenge Solve Script

Vulnerability: The server leaks AES-CBC ciphertext of our input, then
decrypts whatever we send back. By flipping one bit in Ciphertext[0],
we predictably alter one byte in Plaintext[1], changing 'b' -> 'a'
and turning 'bdmin' into 'admin'.
"""

from pwn import *
from binascii import unhexlify, hexlify

# ── Connection ────────────────────────────────────────────────────────────────

r = remote('10.128.176.38', 1337)

# ── Step 1: Craft the username ────────────────────────────────────────────────
# We want block 1 of the plaintext to read 'admin&password=s'
# The prefix 'access_username=' is exactly 16 bytes, so our username
# starts at byte 16 (block 1, offset 0).
#
# We use 'bdmin' instead of 'admin':
#   - bypasses the plaintext guard (no literal 'admin&password=sUp3rPaSs1')
#   - 'b' XOR 'a' = 0x03 → one bit flip away from our target
#
# Full username embeds the rest of the target string so that after block 1
# is corrected, B1+B2 together read 'admin&password=sUp3rPaSs1...'

r.recvuntil(b'username: ')
username = 'bdmin&password=sUp3rPaSs1'   # 25 bytes; 'b' is the decoy char
r.sendline(username.encode())

# ── Step 2: Send a dummy password ────────────────────────────────────────────
# The password just fills out block 3; any value works because we only
# need the substring to appear, not the full message to be well-formed.

r.recvuntil(b"'s password: ")
r.sendline(b'anything')

# ── Step 3: Receive the leaked ciphertext ─────────────────────────────────────
# The server encrypts:
#   'access_username=bdmin&password=sUp3rPaSs1&password=anything'
# and sends it back as a hex string (no IV included — it's stored server-side).

r.recvuntil(b'Leaked ciphertext: ')
ct_hex = r.recvline().strip().decode()
ct = bytearray(unhexlify(ct_hex))

# Block layout of the plaintext (each block = 16 bytes):
#   C0 → B0: 'access_username='
#   C1 → B1: 'bdmin&password=s'   ← we want this to be 'admin&password=s'
#   C2 → B2: 'Up3rPaSs1&passwo'
#   C3 → B3: 'rd=anything\x05\x05\x05\x05\x05'

# ── Step 4: Bit-flip attack ───────────────────────────────────────────────────
# In CBC: Plaintext[1][i] = AES_Dec(Ciphertext[1])[i] XOR Ciphertext[0][i]
#
# To change B1[0] from 'b' (0x62) to 'a' (0x61):
#   Ciphertext[0][0] ^= ('b' XOR 'a') = 0x03
#
# Side-effect: B0 decrypts to garbage, but the win condition only checks
# for the target string as a *substring*, so corrupted B0 doesn't matter.

ct[0] ^= ord('b') ^ ord('a')   # 0x62 XOR 0x61 = 0x03

# ── Step 5: Send the modified ciphertext ─────────────────────────────────────
# After the flip, the server decrypts our ciphertext to:
#   [garbage B0] + 'admin&password=sUp3rPaSs1' + '&password=anything' + ...
# The substring check passes → flag is returned.

r.recvuntil(b'enter ciphertext: ')
r.sendline(hexlify(bytes(ct)))

# ── Step 6: Collect the flag ──────────────────────────────────────────────────
print(r.recvall().decode())

Step 4 — Running It

1
2
3
4
5
6
$ python3 cbc_flip.py
[+] Opening connection to 10.128.176.38 on port 1337: Done
[+] Receiving all data: Done (73B)
[*] Closed connection to 10.128.176.38 port 1337
No way! You got it!
A nice flag for you: THM{REDACTED}

One connection. One XOR. Done.


7. Conclusion / Flag

1
THM{REDACTED}

The flag says it all: Flip Dat Bit Or Get Flipped. The challenge is a clean, minimal demonstration of why CBC mode should never be used as an authentication oracle.

The Three Mistakes That Made This Possible

MistakeWhy It Matters
Leaking the ciphertextGives the attacker Ciphertext[0] to use as a lever
Decrypting attacker-controlled input with the same key/IVTurns the server into a decryption oracle
Substring check instead of HMAC/signatureMeans garbled blocks outside the target window are irrelevant

Any one of these alone might be acceptable in a different context. Together they create a perfect storm.

Real-World Lesson

If you need AES and need to ensure the decrypted data hasn’t been tampered with, use an authenticated encryption mode — AES-GCM is the modern go-to. It provides both confidentiality and integrity; any modification to the ciphertext makes decryption fail outright rather than silently producing attacker-controlled plaintext.

CBC is not broken — but it is brittle. Use it wrong, and a single ^= 0x03 is all it takes to walk through the front door.


Written after solving the challenge live. All offsets and values are derived directly from the server source — no guessing required.