crypto

Challenges

Secure-Server

John Doe uses this secure server where plaintext is never shared. Our Forensics Analyst was able to capture this traffic and the source code for the server. Can you recover John Doe's secrets?

❯ unzip -l files.zip
Archive:  files.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
     2580  2025-08-04 12:47   capture.pcap
      412  2025-07-17 14:22   server.py
---------                     -------
     2992                     2 files
  • strings capture.pcap

Intel(R) Core(TM) i9-14900HX (with SSE4.2)
Linux 6.12.32-amd64
Dumpcap (Wireshark) 4.0.17 (Git v4.0.17 packaged as 4.0.17-0+deb12u1)
enp0s3
host 108.59.80.160
Linux 6.12.32-amd64
>WRU
tEl;P
>WRU
With the Secure Server, sharing secrets is safer than ever!
Enter the secret, XORed by your key (in hex): 
151e71ce4addf692d5bac83bb87911a20c39b71da3fa5e7ff05a2b2b0a83ba03
>WRU
tGl;P
>WRU
Double encrypted secret (in hex): e1930164280e44386b389f7e3bc02b707188ea70d9617e3ced989f15d8a10d70
XOR the above with your key again (in hex): 
87ee02c312a7f1fef8f92f75f1e60ba122df321925e8132068b0871ff303960e
>WRU
tEl;P
>WRU
t3l;P
Secret received!
>WRU
tCl;P
>WRU
tBl;P
>WRU
tAl;P
>WRU
t@l;P
~6zU
Counters provided by dumpcap
`6zU
  • server.py

import os
from pwn import xor
print("With the Secure Server, sharing secrets is safer than ever!")
enc = bytes.fromhex(input("Enter the secret, XORed by your key (in hex): ").strip())
key = os.urandom(32)
enc2 = xor(enc,key).hex()
print(f"Double encrypted secret (in hex): {enc2}")
dec = bytes.fromhex(input("XOR the above with your key again (in hex): ").strip())
secret = xor(dec,key)
print("Secret received!")
  • passive cryptanalysis — we just get the value and reverse engineer it

  • I didn't do too much beside strings the capture.pcap file and trace the flawed implementation

# Server's process:
enc = bytes.fromhex(input("Enter secret ..."))  # John Doe input
key = os.urandom(32)                            # Server generates random key
enc2 = xor(enc, key).hex()                      # Server XORs with its key
print(f"Double encrypted secret: {enc2}")       # Server reveals this

Since we know both from the pcap:

  • john_input (what John enter) — flag ⊕ john_key = encrypted_flag

  • enc2 (the print statement)

We can recover the server's secret key using the XOR property:

  • enc2 = XOR(john_input, server_key)

  • Therefore: server_key = XOR(john_input, enc2)

  • Once we have the server key, we can decrypt: flag = XOR(final_input, server_key)

dec = bytes.fromhex(input("XOR the above with your key again (in hex): ").strip())
secret = xor(dec,key)
print("Secret received!")
  • the dec in the code is the final_input we were looking for

Script

from pwn import xor

# Input sent: 151e71ce4addf692d5bac83bb87911a20c39b71da3fa5e7ff05a2b2b0a83ba03
# Server returned enc2: e1930164280e44386b389f7e3bc02b707188ea70d9617e3ced989f15d8a10d70
# Second input: 87ee02c312a7f1fef8f92f75f1e60ba122df321925e8132068b0871ff303960e

john_input = bytes.fromhex("151e71ce4addf692d5bac83bb87911a20c39b71da3fa5e7ff05a2b2b0a83ba03")
enc2 = bytes.fromhex("e1930164280e44386b389f7e3bc02b707188ea70d9617e3ced989f15d8a10d70")
final_input = bytes.fromhex("87ee02c312a7f1fef8f92f75f1e60ba122df321925e8132068b0871ff303960e")

# The server does: XOR(john_input, server_key) = enc2
# So: server_key = XOR(john_input, enc2)
key = xor(john_input, enc2)
print(f"Extracted key: {key.hex()}")

# Now we can get the original secret: XOR(final_input, key)
secret = xor(enc2, key)
print(f"Original secret: {secret}")
print(f"Flag: {secret.decode()}")
  • scriptCTF{x0r_1s_not_s3cur3!!!!}

RSA - 1

Yú Tóngyī send a message to 3 peoples with unique modulus. But he left it vulnerable. Figure out :)

n1 = 156503881374173899106040027210320626006530930815116631795516553916547375688556673985142242828597628615920973708595994675661662789752600109906259326160805121029243681236938272723595463141696217880136400102526509149966767717309801293569923237158596968679754520209177602882862180528522927242280121868961697240587
c1 = 77845730447898247683281609913423107803974192483879771538601656664815266655476695261695401337124553851404038028413156487834500306455909128563474382527072827288203275942719998719612346322196694263967769165807133288612193509523277795556658877046100866328789163922952483990512216199556692553605487824176112568965

n2 = 81176790394812943895417667822424503891538103661290067749746811244149927293880771403600643202454602366489650358459283710738177024118857784526124643798095463427793912529729517724613501628957072457149015941596656959113353794192041220905793823162933257702459236541137457227898063370534472564804125139395000655909
c2 = 40787486105407063933087059717827107329565540104154871338902977389136976706405321232356479461501507502072366720712449240185342528262578445532244098369654742284814175079411915848114327880144883620517336793165329893295685773515696260299308407612535992098605156822281687718904414533480149775329948085800726089284

n3 = 140612513823906625290578950857303904693579488575072876654320011261621692347864140784716666929156719735696270348892475443744858844360080415632704363751274666498790051438616664967359811895773995052063222050631573888071188619609300034534118393135291537302821893141204544943440866238800133993600817014789308510399
c3 = 100744134973371882529524399965586539315832009564780881084353677824875367744381226140488591354751113977457961062275480984708865578896869353244823264759044617432862876208706282555040444253921290103354489356742706959370396360754029015494871561563778937571686573716714202098622688982817598258563381656498389039630

e = 3

I translated the challenge description as:

  • (Yú Tóngyī) sending

    • Same message to multiple people

    • Different moduli ("unique modulus" for each person)

    • Left it "vulnerable"

1

Looking at out.txt, we see:

  • 3 different moduli (n1, n2, n3)

  • 3 different ciphertexts (c1, c2, c3)

  • Same small exponent (e = 3)

  • Number of messages = exponent value (3 messages, e=3)

n1 = [some number 1]
c1 = [encrypted message 1]

n2 = [some number 2]
c2 = [encrypted message 2]

n3 = [some number 3]
c3 = [encrypted message 3]

e = 3
2

We could see that

Yú Tóngyī has a secret message (flag)

Person 1: Has RSA keypair (n1, e=3, d1)
Person 2: Has RSA keypair (n2, e=3, d2)  
Person 3: Has RSA keypair (n3, e=3, d3)

Yú Tóngyī encrypts the SAME message with each person's public key:
   c1 = m³ mod n1
   c2 = m³ mod n2  
   c3 = m³ mod n3
3

This is a classic RSA vulnerability so I highly recommend you to read over the CTR article, since the conditions met. We can just follows the theorem and decrypt the flag

  • Here are the math for...whoever needs it.

Script

def extended_gcd(a, b):
    if a == 0:
        return b, 0, 1
    gcd, x1, y1 = extended_gcd(b % a, a)
    x = y1 - (b // a) * x1
    y = x1
    return gcd, x, y

def chinese_remainder_theorem(remainders, moduli):
    """
    Solve system of congruences using CRT
    x ≡ remainders[i] (mod moduli[i])
    """
    total = 0
    prod = 1
    for m in moduli:
        prod *= m                                    # Step 1: N = n1 * n2 * n3

    for r, m in zip(remainders, moduli):
        p = prod // m                                # Step 2
        total += r * mul_inv(p, m) * p               # Step 4: CRT formula

    return total % prod

def mul_inv(a, b):
    """Calculate modular inverse of a mod b"""        # Step 3: Find yi
    b0 = b
    x0, x1 = 0, 1
    if b == 1:
        return 1
    while a > 1:
        q = a // b
        a, b = b, a % b
        x0, x1 = x1 - q * x0, x0
    if x1 < 0:
        x1 += b0
    return x1

def nth_root(n, e):
    """Calculate the e-th root of n"""               # Step 5: Extract m
    low = 0
    high = n

    while low <= high:
        mid = (low + high) // 2
        mid_e = pow(mid, e)

        if mid_e == n:
            return mid
        elif mid_e < n:
            low = mid + 1
        else:
            high = mid - 1

    return high

def solve_rsa_crt():
    # values from the challenge - Equations (1), (2), (3)
    n1 = 156503881374173899106040027210320626006530930815116631795516553916547375688556673985142242828597628615920973708595994675661662789752600109906259326160805121029243681236938272723595463141696217880136400102526509149966767717309801293569923237158596968679754520209177602882862180528522927242280121868961697240587
    c1 = 77845730447898247683281609913423107803974192483879771538601656664815266655476695261695401337124553851404038028413156487834500306455909128563474382527072827288203275942719998719612346322196694263967769165807133288612193509523277795556658877046100866328789163922952483990512216199556692553605487824176112568965

    n2 = 81176790394812943895417667822424503891538103661290067749746811244149927293880771403600643202454602366489650358459283710738177024118857784526124643798095463427793912529729517724613501628957072457149015941596656959113353794192041220905793823162933257702459236541137457227898063370534472564804125139395000655909
    c2 = 40787486105407063933087059717827107329565540104154871338902977389136976706405321232356479461501507502072366720712449240185342528262578445532244098369654742284814175079411915848114327880144883620517336793165329893295685773515696260299308407612535992098605156822281687718904414533480149775329948085800726089284

    n3 = 140612513823906625290578950857303904693579488575072876654320011261621692347864140784716666929156719735696270348892475443744858844360080415632704363751274666498790051438616664967359811895773995052063222050631573888071188619609300034534118393135291537302821893141204544943440866238800133993600817014789308510399
    c3 = 100744134973371882529524399965586539315832009564780881084353677824875367744381226140488591354751113977457961062275480984708865578896869353244823264759044617432862876208706282555040444253921290103354489356742706959370396360754029015494871561563778937571686573716714202098622688982817598258563381656498389039630

    e = 3

    print("RSA CRT Attack")
    print("===================================")
    print(f"e = {e}")
    print()

    # Set up the system of congruences - Equations (1), (2), (3)
    # m^3 ≡ c1 (mod n1)
    # m^3 ≡ c2 (mod n2)
    # m^3 ≡ c3 (mod n3)
    remainders = [c1, c2, c3]
    moduli = [n1, n2, n3]

    print("Applying...")

    # Use CRT to find m^3 mod (n1 * n2 * n3) - Equation (8)
    m_cubed = chinese_remainder_theorem(remainders, moduli)

    print(f"m^3 = {m_cubed}")
    print()

    # Since e = 3 and we have 3 equations, m^3 < n1*n2*n3
    # So we can just take the cube root directly - Equation (9)
    print("Taking cube root...")
    m = nth_root(m_cubed, e)

    print(f"m = {m}")
    print()

    # Convert to text
    message_bytes = m.to_bytes((m.bit_length() + 7) // 8, byteorder='big')
    message = message_bytes.decode('utf-8', errors='ignore')
    print(f"Message (UTF-8): {message}")
    return m, message if 'message' in locals() else None

solve_rsa_crt()
  • scriptCTF{y0u_f0und_mr_yu's_s3cr3t_m3g_12a4e4}

Mod

Just a simple modulo challenge

#!/usr/local/bin/python3
import os
secret = int(os.urandom(32).hex(),16)
print("Welcome to Mod!")
num=int(input("Provide a number: "))
print(num % secret)
guess = int(input("Guess: "))
if guess==secret:
    print(open('flag.txt').read())
else:
    print("Incorrect!")

The challenge generates a random 32-byte (256-bit) secret and implements this simple protocol:

  1. Accept a number from the user

  2. Return number % secret (our input % secret)

  3. Ask for a guess of the secret

  4. Give the flag if the guess is correct


The intended solution:

This is due to how Python handled negative modulo opeartions.

  • From the highlight

  • -1 = -1 * secret + (secret - 1)
    So: -1 % secret = secret - 1
    
    i.e,:
    
    -1 = -1 * 2 + (2 - 1)  = -2 + 1 = -1
  • So we simply input -1 and then +1 to whatever the number % secret is.

  • My solution on the other hands tho...

from pwn import *

# Connect to the challenge
# p = remote('play.scriptsorcerers.xyz', 10332)
p = process(['python3', 'chall.py'])

# Receive welcome message
welcome = p.recvuntil(b"Provide a number: ")
print(f"Received: {welcome.decode()}")

# Send a large prime number to get a remainder
large_prime = 2**256 + 1  # This is actually not prime, but large enough
print(f"Sending number: {large_prime}")
p.sendline(str(large_prime).encode())

# Get the result
result_line = p.recvuntil(b"Guess: ")
print(f"Raw result line: {result_line}")

# Parse the result from the debug output
lines = result_line.decode().split('\n')
for line in lines:
    line = line.strip()
    if line and not line.startswith('DEBUG:') and line.isdigit():
        remainder = int(line)
        break

print(f"{large_prime} % secret = {remainder}")

q = (large_prime - remainder) // ((2**255 + 2**254) // 2)  # Estimate q

min_secret = 2**248  # At least this big typically
max_secret = 2**256 - 1

# q must be large_prime // secret, so:
max_q = large_prime // min_secret
min_q = large_prime // max_secret

print(f"Estimated quotient range: {min_q} to {max_q}")

# Try different values of q in this range
secret_found = False
for q in range(max(1, min_q), min(max_q + 1, 10)):
    potential_secret = (large_prime - remainder) // q
    if potential_secret > 0:
        # Verify this secret works
        if large_prime % potential_secret == remainder:
            secret = potential_secret
            print(f"Found secret with quotient {q}: {secret}")
            secret_found = True
            break

p.sendline(str(secret).encode())
response = p.recvall()
print(f"Flag?: {response.decode()}")

p.close()

In my mind: "This is a crypto challenge, so it must require advanced mathematical attacks"

Reality: "a simple logic flaw that can be exploited with basic knowledge"


  • I think I was heading towards the same direction...although slightly off.

  • I fundamentally exploits the relationship: large_number = quotient × secret + remainder

  • So when we can send a large_prime = 2^256 + 1 and receive remainder, we can rearrange it to:

  • secret = (large_prime - remainder) / quotient

  • Next:

    • secret ranges from ~2^248 to 2^256 (a wild assumption)

    • large_prime = 2^256 + 1

    • --> quotient = (2^256 + 1) // secret will be 1 or 2

    • Then try each possible quotient q in the estimated range

    • Calculate potential secret using secret = (large_prime - remainder) / q

    • Verify the answer by checking if large_prime % potential_secret == remainder

That is why I need to run the script 2, 3 times to get the flag.
❯ python [solve.py](http://solve.py/)
[+] Opening connection to play.scriptsorcerers.xyz on port 10332: Done
Received: Welcome to Mod!
Provide a number:
Sending number: 115792089237316195423570985008687907853269984665640564039457584007913129639937
Raw result line: b'3776257869426289293781473901468954635422858186798964021296543137402304675597\nGuess: '
115792089237316195423570985008687907853269984665640564039457584007913129639937 % secret = 3776257869426289293781473901468954635422858186798964021296543137402304675597
Estimated quotient range: 1 to 256
Found secret with quotient 1: 112015831367889906129789511107218953217847126478841600018161040870510824964340
Calculated secret: 112015831367889906129789511107218953217847126478841600018161040870510824964340
Secret in hex: 0xf7a6b6bad76773bc300db3c60592c25481da1f76b86758aaca718e3044a6c0f4
Sending guess: 112015831367889906129789511107218953217847126478841600018161040870510824964340
[+] Receiving all data: Done (11B)
[*] Closed connection to play.scriptsorcerers.xyz port 10332
Flag?: Incorrect!

❯ python [solve.py](http://solve.py/)
[+] Opening connection to play.scriptsorcerers.xyz on port 10332: Done
Received: Welcome to Mod!
Provide a number:
Sending number: 115792089237316195423570985008687907853269984665640564039457584007913129639937
Raw result line: b'3757474988198533431911348043852692516607532118397785400813393178725141199763\nGuess: '
115792089237316195423570985008687907853269984665640564039457584007913129639937 % secret = 3757474988198533431911348043852692516607532118397785400813393178725141199763
Estimated quotient range: 1 to 256
Found secret with quotient 1: 112034614249117661991659636964835215336662452547242778638644190829187988440174
Calculated secret: 112034614249117661991659636964835215336662452547242778638644190829187988440174
Secret in hex: 0xf7b15832a0901554f30554ac714d4735c2b303063ff5b7cb75c2f981ef23c46e
Sending guess: 112034614249117661991659636964835215336662452547242778638644190829187988440174
[+] Receiving all data: Done (11B)
[*] Closed connection to play.scriptsorcerers.xyz port 10332
Flag?: Incorrect!

❯ python [solve.py](http://solve.py/)
[+] Opening connection to play.scriptsorcerers.xyz on port 10332: Done
Received: Welcome to Mod!
Provide a number:
Sending number: 115792089237316195423570985008687907853269984665640564039457584007913129639937
Raw result line: b'16443642149089151997923941091040628302239918556757252076912467405236896043638\nGuess: '
115792089237316195423570985008687907853269984665640564039457584007913129639937 % secret = 16443642149089151997923941091040628302239918556757252076912467405236896043638
Estimated quotient range: 1 to 256
Found secret with quotient 1: 99348447088227043425647043917647279551030066108883311962545116602676233596299
Calculated secret: 99348447088227043425647043917647279551030066108883311962545116602676233596299
Secret in hex: 0xdba53a76024aeef0e57396eed72b809c55077f08597e7ea144f60313e8e76d8b
Sending guess: 99348447088227043425647043917647279551030066108883311962545116602676233596299
[+] Receiving all data: Done (49B)
[*] Closed connection to play.scriptsorcerers.xyz port 10332
Flag?: scriptCTF{-1_f0r_7h3_w1n_4a3f7db1_43eed08ce9f6}
  • scriptCTF{-1_f0r_7h3_w1n_4a3f7db1_43eed08ce9f6}

Secure-Server-2 (upsolve)

This time, the server is even more secure, but did it actually receive the secret? Simple brute-force won't work!

❯ unzip -l files.zip
Archive:  files.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
     2648  2025-08-04 12:49   capture.pcap
      672  2025-08-04 13:05   johndoe.py
     1104  2025-08-04 12:55   server.py
---------                     -------
     4424                     3 files
What happening is:
John Doe                           Server
--------                           ------

message = "secret_flag..."
    |
    | E1 (encrypt with K1)
    v  
E1(message)
    |
    | E2 (encrypt with K2)  
    v
E2(E1(message)) -----------------> receives: E2(E1(message))
                                      |
                                      | E3 (encrypt with K3)
                                      v
                                   E3(E2(E1(message)))
                                      |
                                      | E4 (encrypt with K4)
                                      v
receives: E4(E3(E2(E1(message)))) <-- E4(E3(E2(E1(message))))
    |
    | D1 (decrypt with K1)
    v
D1(E4(E3(E2(E1(message)))))
    |  
    | D2 (decrypt with K2)
    v
D2(D1(E4(E3(E2(E1(message)))))) -> sends to server
                                      |
                                      | Server thinks this is
                                      | the original message
                                      v
                                   "Secret received"

BUT: D2(D1(E4(E3(E2(E1(message)))))) ≠ message
(Because AES is NOT commutative)
  • strings capture.pcap to see

1. Server: "Enter the secret, encrypted twice with your keys (in hex):"
   
2. John Doe sends: 19574ac010cc9866e733adc616065e6c019d85dd0b46e5c2190c31209fc57727
   This is: E2(E1(message))

3. Server: "Quadriple encrypted secret (in hex): 0239bcea627d0ff4285a9e114b660ec0e97f65042a8ad209c35a091319541837"
   This is: E4(E3(E2(E1(message))))

4. Server: "Decrypt the above with your keys again (in hex):"

5. John Doe sends: 4b3d1613610143db984be05ef6f37b31790ad420d28e562ad105c7992882ff34
   This is: D2(D1(E4(E3(E2(E1(message))))))

6. Server: "Secret received!" (no you did not buddy :DDDDD)
  • "Simple brute-force won't work!"

    • That just tell us to do some shenanigans

    • Well this is where I stuck and couldn't come up with an effective attacks in time.

  • Solution:

Value 1: E2(E1(message))
Value 2: E4(E3(E2(E1(message))))
Value 3: D2(D1(E4(E3(E2(E1(message))))))
  • 4 keys, each 16 bits (2 bytes)

k1 = b'AA' # 16 bits
k2 = b'AA' # 16 bits
k3 = b'BB' # 16 bits
k4 = b'B}' # 16 bits
  • individual keys are small and easily brute force, it is impossible if combined them.

  • So we have

    • Value 1: E2(E1(flag)) = 19574ac0...
      Value 2: E4(E3(E2(E1(flag)))) = 0239bcea...
    • We want to find K3 and K4 such that

      • E4(E3(Value 1)) = Value 2

    • The same logic for finding K1 and K2.

  • The "Meet in the Middle".

    • From the LEFT: encrypt all possible K3 values on Value 1

    • From the RIGHT: decrypt all possible K4 values on Value 2

    • Look for where they MEET

Step 1 - Finding K3 and K4:

Value_1: E2(E1(message))
    ↓ [encrypt with all possible K3]
    E3(E2(E1(message))) ← Store in table

Value_2: E4(E3(E2(E1(message))))  
    ↓ [decrypt with all possible K4]
    E3(E2(E1(message))) ← When K4 is correct, this will matches table entry

Step 2 -- Finding K1 and K2

Value_3: D2(D1(E4(E3(E2(E1(message))))))
    ↓ [Encrypt with all possible K1]
    D1(E4(E3(E2(E1(flag)))))) <-- store in table (guesses key)

Value_2: E4(E3(E2(E1(message))))
    ↓ [decrypt with all possible K2]
    D1(E4(E3(E2(E1(flag)))))) <-- they matched the table entry, found K1, K2
  • We then proceed to reverse the entire process with the all the keys (from Value 1)

E2(E1(secret)) → D2(K2) → D1(K1) → secret
--> secret + k1 + k2 + k3 + k4

Script

from Crypto.Cipher import AES

def format_key(key_int):
    return bin(key_int)[2:].zfill(16).encode()

def mitm_attack():
    """
    capture.pcap:
    Value 1: E2(E1(message))
    Value 2: E4(E3(E2(E1(message))))
    Value 3: D2(D1(E4(E3(E2(E1(message))))))
    """

    # Extracted values from network capture
    value1 = bytes.fromhex('19574ac010cc9866e733adc616065e6c019d85dd0b46e5c2190c31209fc57727')
    value2 = bytes.fromhex('0239bcea627d0ff4285a9e114b660ec0e97f65042a8ad209c35a091319541837')
    value3 = bytes.fromhex('4b3d1613610143db984be05ef6f37b31790ad420d28e562ad105c7992882ff34')

    # Build lookup table: encrypt value1 with all possible K3
    k3_lookup = {}
    for k3 in range(2**16):
        cipher = AES.new(format_key(k3), mode=AES.MODE_ECB)
        encrypted = cipher.encrypt(value1)
        k3_lookup[encrypted.hex()] = k3

    # Search: decrypt value2 with all possible K4 and check for matches
    k3_final = None
    k4_final = None

    for k4 in range(2**16):
        cipher = AES.new(format_key(k4), mode=AES.MODE_ECB)
        decrypted = cipher.decrypt(value2)

        if decrypted.hex() in k3_lookup:
            k3_final = k3_lookup[decrypted.hex()]
            k4_final = k4
            # print(f"[!] Found K3: {k3_final}, K4: {k4_final}")
            break

    # Build lookup table: encrypt value3 with all possible K1
    k1_lookup = {}
    for k1 in range(2**16):
        cipher = AES.new(format_key(k1), mode=AES.MODE_ECB)
        encrypted = cipher.encrypt(value3)
        k1_lookup[encrypted.hex()] = k1

    # Search: decrypt value2 with all possible K2 and check for matches
    k1_final = None
    k2_final = None

    for k2 in range(2**16):
        cipher = AES.new(format_key(k2), mode=AES.MODE_ECB)
        decrypted = cipher.decrypt(value2)

        if decrypted.hex() in k1_lookup:
            k1_final = k1_lookup[decrypted.hex()]
            k2_final = k2
            # print(f"[!] Found K1: {k1_final}, K2: {k2_final}")
            break

    cipher1 = AES.new(format_key(k1_final), mode=AES.MODE_ECB)
    cipher2 = AES.new(format_key(k2_final), mode=AES.MODE_ECB)

    # Decrypt value1 (which is E2(E1(message))) to get original message
    # Order: D1(D2(E2(E1(message)))) = message
    secret_message = cipher1.decrypt(cipher2.decrypt(value1))

    # Convert keys to bytes for final flag construction
    k1_bytes = bytes.fromhex(hex(k1_final)[2:].zfill(4))
    k2_bytes = bytes.fromhex(hex(k2_final)[2:].zfill(4))
    k3_bytes = bytes.fromhex(hex(k3_final)[2:].zfill(4))
    k4_bytes = bytes.fromhex(hex(k4_final)[2:].zfill(4))

    # Construct final flag
    final_flag = secret_message + k1_bytes + k2_bytes + k3_bytes + k4_bytes

    return final_flag

# run
print(mitm_attack().decode())

scriptCTF{s3cr37_m3ss4g3_1337!_7e4b3f8d}

EaaS (upsolve)

upsolve 🚩 it is a fun challenge.

Email as a Service! Have fun...

❯ unzip -l eaas.zip
Archive:  eaas.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
     1868  2025-06-12 08:17   server.py
       16  2025-06-12 08:11   flag.txt
---------                     -------
     1884                     2 files
#!/usr/bin/env python3
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import os
import random
email=''
flag=open('flag.txt').read()
has_flag=False
sent=False
key = os.urandom(32)
iv = os.urandom(16)
encrypt = AES.new(key, AES.MODE_CBC,iv)
decrypt = AES.new(key, AES.MODE_CBC,iv)

def send_email(recipient):
    global has_flag
    if recipient.count(b',')>0:
        recipients=recipient.split(b',')
    else:
        recipients=recipient
    for i in recipients:
        if i == email.encode():
            has_flag = True

for i in range(10):
    email += random.choice('abcdefghijklmnopqrstuvwxyz')
email+='@notscript.sorcerer'

print(f"Welcome to Email as a Service!\nYour Email is: {email}\n")
password=bytes.fromhex(input("Enter secure password (in hex): "))

assert not len(password) % 16
assert b"@script.sorcerer" not in password
assert email.encode() not in password

encrypted_pass = encrypt.encrypt(password)
print("Please use this key for future login: " + encrypted_pass.hex())

while True:
    choice = int(input("Enter your choice: "))
    print(f"[1] Check for new messages\n[2] Get flag")

    if choice == 1:
        if has_flag:
            print(f"New email!\nFrom: scriptsorcerers@script.sorcerer\nBody: {flag}")
        else:
            print("No new emails!")

    elif choice == 2:
        if sent:
            exit(0)
        sent=True
        user_email_encrypted = bytes.fromhex(input("Enter encrypted email (in hex): ").strip())
        if len(user_email_encrypted) % 16 != 0:
            print("Email length needs to be a multiple of 16!")
            exit(0)
        user_email = decrypt.decrcypt(user_email_encrypted)
        if user_email[-16:] != b"@script.sorcerer":
            print("You are not part of ScriptSorcerers!")
            exit(0)

        send_email(user_email)
        print("Email sent!")

Let's first analyze the source code that was given to us.

When we connect / run to the server:

  1. We get assigned a random email: Something like abcdefghij@notscript.sorcerer

  2. We must provide a password in hex: Must be a multiple of 16 bytes (AES block size)

  3. Password restrictions: Cannot contain our email address or the string @script.sorcerer

  4. The server encrypts our password using AES-CBC with the same key and IV for both encryption and decryption

So what is our win conditions? To get the flag, we need to:

  1. Trigger has_flag = True by convincing the server that we received an email from the sorcerers

  2. Check messages to read the flag

The server will only set has_flag = True if we can submit encrypted data that when decrypted:

  • Ends with exactly @script.sorcerer (last 16 bytes)

  • Contains our assigned email (hint: as one of the comma-separated recipients)

The problem: The server forbids us from including @script.sorcerer and our email in the input, but requires the decrypted output to end with @script.sorcerer and contain our email!, the lines responsible for our problem:

  • Input validation (what we submit):

assert b"@script.sorcerer" not in password
assert email.encode() not in password    
  • Output validation (what gets decrypted):

if user_email[-16:] != b"@script.sorcerer":
    print("You are not part of ScriptSorcerers!")
    exit(0)
  • So of course...we need to find a way to manipulate our input so that after encryption and decryption, it magically contains the forbidden strings.

  • Well...then we have to understand what the encryption method is being used ...AES-CBC...classic

  • I think the articles below will do a better job at explaining the CBC Bit-Flipping Attack than I do

The key is that in CBC mode, you can precisely control changes to plaintext by manipulating the previous ciphertext block.

Script

from pwn import remote, process
from Crypto.Util.strxor import strxor

# r = remote("play.scriptsorcerers.xyz", 10495)
r = process(["python3", "server.py"])

r.recvuntil(b"Your Email is: ")
email = r.recvline().strip().decode()
print(f"Assigned email: {email}")

control_block = b"a" * 16
email_part1 = b",1" + email.encode()[1:]  # Replace first char with '1'

# Calculate how to split across blocks
if len(email_part1) <= 16:
    block1 = email_part1 + b"a" * (16 - len(email_part1))
    block2 = b",aaaaaaaaaaaaaa"  # Comma + padding
else:
    block1 = email_part1[:16]
    remaining = email_part1[16:]
    block2 = remaining + b"," + b"a" * (15 - len(remaining))

control_block2 = b"a" * 16

# Use @script.sorcerer but change 's' to 't' to pass validation
target_domain = b"@tcript.sorcerer"

payload = control_block + block1 + block2 + control_block2 + target_domain

print(f"Payload: {payload}")
print(f"Payload hex: {payload.hex()}")

# Submit payload
r.sendline(payload.hex())

# Get encrypted result
r.recvuntil(b"key for future login: ")
key_hex = r.recvline().strip().decode()
print(f"Encrypted: {key_hex}")

# Apply bit flips
encrypted = bytearray(bytes.fromhex(key_hex))

# Flip to restore first character of email
first_char_fix = strxor(b"1", email[0].encode())
encrypted[1] ^= ord(first_char_fix)

# Flip to change 't' back to 's' in @script.sorcerer
domain_fix = strxor(b"t", b"s")
encrypted[49] ^= ord(domain_fix)  # Position in control block 3 that affects block 4

modified_hex = encrypted.hex()
print(f"Modified: {modified_hex}")

# Now submit to option 2
print("Submitting to option [2]...")
r.sendline(b"2")
r.recvuntil(b"Enter encrypted email (in hex): ")
r.sendline(modified_hex.encode())

# Check result
r.recvuntil(b"Email sent!")

# Now check for messages
print("Checking messages...")
r.sendline(b"1")

r.interactive()

python solve.py
[+] Opening connection to play.scriptsorcerers.xyz on port 10495: Done
Assigned email: shykpibmlu@notscript.sorcerer
Payload: b'aaaaaaaaaaaaaaaa,1hykpibmlu@notscript.sorcerer,aaaaaaaaaaaaaaaaa@tcript.sorcerer'
Payload hex: 616161616161616161616161616161612c3168796b7069626d6c75406e6f747363726970742e736f7263657265722c6161616161616161616161616161616161407463726970742e736f726365726572
Encrypted: fd79d1c25aed23c500b4d24ebbade063e385ea14209fe5ee780ee9a08bb99c8f30c0bd4d93f534e0c8705d128bffdef7b85469988b7bfc79f1f7083d39e72827453770c0030c35e7cb0b677b32933a52
Modified: fd3bd1c25aed23c500b4d24ebbade063e385ea14209fe5ee780ee9a08bb99c8f30c0bd4d93f534e0c8705d128bffdef7b85369988b7bfc79f1f7083d39e72827453770c0030c35e7cb0b677b32933a52
Submitting to option [2]...
Checking messages...
[*] Switching to interactive mode

Enter your choice: [1] Check for new messages
[2] Get flag
New email!
From: scriptsorcerers@script.sorcerer
Body: scriptCTF{CBC_1s_s3cur3_r1ght?_3c38ba9890e9}
  • scriptCTF{CBC_1s_s3cur3_r1ght?_3c38ba9890e9}

Last updated