+++ title = "ScriptCTF 2025 Writeup" description = "ScriptCTF 2025 Writeup" author = "mockingspectre" date = 2025-08-18 [taxonomies] tags = ["ctf", "rev", "web", "forensics"] +++ ![][image1] Over the weekend, I participated in ScriptCTF, a capture-the-flag competition packed with diverse challenges spanning cryptography, forensics, reverse engineering, and web exploitation. In this write-up, I’ll walk through some of the problems I solved, breaking down my approach, the tools I used, and the reasoning behind each step. Rather than just dropping final answers, my goal is to document the problem-solving process in a way that’s reproducible and useful for others looking to sharpen their cybersecurity skills. # Challenge: Renderer ![][image2] This is the first web challenge; it launches an instance, and the source code is provided for manual code review. There are three important files: two Jinja templates and a Python file. upload.html ```html Upload Image

Upload Images

This free to use website allows you to render your images!

``` display.html ```html View Image

Uploaded Image

Upload another image

``` `[app.py](http://app.py)` ```python from flask import Flask, request, redirect, render_template, make_response, url_for app = Flask(__name__) from hashlib import sha256 import os def allowed(name): if name.split('.')[1] in ['jpg','jpeg','png','svg']: return True return False @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') @app.route('/render/') def render(filename): return render_template('display.html', filename=filename) @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!" if __name__ == '__main__': app.run(host='0.0.0.0', port=1337) ``` From the files, we can see that this is an app built using Flask. What the app does: `/` — Upload endpoint: * Accepts `jpg`, `jpeg`, `png`, `svg` files. * Saves them under `./static/uploads/.`. * Redirects to `/render/`. `/render/` — Renders the uploaded image using `display.html`. `/developer` — Protected route: * Checks a cookie `developer_secret_cookie` against `secret_cookie.txt`. * If it matches, it reveals `flag.txt`. `secret_cookie.txt` is generated if empty and is overwritten after a successful visit. Key Observations 1. File upload: Only the extension is checked; there is no content verification. So you can upload anything with .jpg, .png, etc. 2. Render template: The render() route calls render_template('display.html', filename=filename). 3. Secrets: The /developer route requires knowing secret_cookie.txt. We need a way to leak it. Navigating to the developer endpoint without the cookie returns this request. ``` HTTP/1.1 200 OK Server: Werkzeug/3.1.3 Python/3.10.16 Date: Sun, 17 Aug 2025 11:51:49 GMT Content-Type: text/html; charset=utf-8 Content-Length: 24 Connection: close You are not a developer! ``` Now I tried some path traversal techniques, but I couldn't reach the cookie. I turned my focus on how the uploaded images were being rendered and saw that it was an inline iframe. Hmm… interesting, what if I could get code execution on this? I thought of uploading a malicious SVG. Here is an innocent-looking SVG ```svg ``` And… ![][image3] I didn't think that through. I can't make that request without the cookie. Another approach I tried was Local File Inclusion(LFI) using the SVG. Here is another not-so-evil one: ```svg ``` The browser shows it as a broken image, but on opening it on another tab, I was able to leak the cookie `5eb7b76fbb08895de572fbe6028a4ee4abe3663641416deb5b889e0127920c6e`. I copied and pasted the direct URL on another browser tab, and I got straight to it. I might have over-engineered something so simple, but anyway, Do Hard Things!! Retrieving the flag ```zsh $ curl -b "developer_secret_cookie=5eb7b76fbb08895de572fbe6028a4ee4abe3663641416deb5b889e0127920c6e" http://play.scriptsorcerers.xyz:10257/developer Welcome! There is currently 1 unread message: scriptCTF{my_c00k135_4r3_n0t_s4f3!_601871f7aad5} ``` Flag: `scriptCTF{my_c00k135_4r3_n0t_s4f3!_601871f7aad5}` # Challenge: Div ![][image4] Here are the contents of the [chall.py](http://chall.py) ```python 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...') ``` Let’s break it down: 1. The server picks a secret — a random 128-bit integer (`os.urandom(16)` → 32 hex chars → huge number). 2. You input a number `num`. 3. If your input contains `'e'` or `'E'` (scientific notation), you get blocked. 4. If your input is ≥ 10 characters, you get blocked. 5. It converts `num` to a `decimal.Decimal` with precision 50. 6. It computes `div = secret / fl_num`. 7. If `div == 0`, you get the flag. Key observation * `secret` is positive and huge (on the order of `2128`). * Normally, `secret / something` won’t be 0. * In Python `decimal.Decimal`, `div == 0` only if the result is *exactly* 0. Decimal doesn’t overflow or underflow like float — it has arbitrary precision (set to 50). So how do we get exactly zero? * Decimal division only returns `0` if the numerator is `0` OR if the denominator is so huge that the quotient rounds down to 0 at the given precision. * Precision is 50 significant digits. That means if the result is smaller than `10^(-50)`, it will round to 0. We want `secret / fl_num < 10^-50`. `fl_num > secret × 10^50` Since `secret` ≈ `2^128` ≈ `3.4 × 10^38`, we need `fl_num > (3.4 × 10^38) × 10^50 = 3.4 × 10^88` But there’s a catch We can’t use exponent notation (e), and the input length must be < 10 characters. That means we can’t type a literal 100000... with 89 zeros, it’s too long. But in Decimal, you can input a decimal point and make something big by making it a very small number and dividing 1 by it. This is about NaN / Infinity in `decimal.Decimal`. If `fl_num` is `'Infinity'` (allowed, length 8), `secret / Infinity` ```zsh nc play.scriptsorcerers.xyz 10456 Enter a number: Infinity scriptCTF{70_1nf1n17y_4nd_b3y0nd_20e422d293fc} ``` Flag: `scriptCTF{70_1nf1n17y_4nd_b3y0nd_20e422d293fc}` Some context Python’s decimal.Decimal module implements IEEE 754 decimal floating-point arithmetic. It accepts strings like: "Infinity" or "inf" → positive infinity "-Infinity" → negative infinity "NaN" → not-a-number These are special constants, not parsed as normal numbers. Under IEEE 754 rules: - finite_number / Infinity = 0 exactly - finite_number / -Infinity = -0 (also treated as zero) Infinity represents a number so large that any finite numerator divided by it tends towards 0 exactly, with no rounding error. # Challenge: Emoji ![][image5] Out.txt has some symbols. ```txt 🁳🁣🁲🁩🁰🁴🁃🁔🁆🁻🀳🁭🀰🁪🀱🁟🀳🁮🁣🀰🁤🀱🁮🁧🁟🀱🁳🁟🁷🀳🀱🁲🁤🁟🀴🁮🁤🁟🁦🁵🁮🀡🀱🁥🀴🀶🁤🁽 ``` The symbols are Unicode Domino Tiles (in the U+1F030–U+1F093 range). If you subtract 0x1F000 (the start of the Mahjong/Domino emoji area) from each character’s code point, you get plain ASCII bytes. You can learn more about Unicode here. - [https://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/](https://www.joelonsoftware.com/2003/10/08/the-absolute-minimum-every-software-developer-absolutely-positively-must-know-about-unicode-and-character-sets-no-excuses/) - [https://en.wikipedia.org/wiki/Dominoes#Dominoes_in_Unicode:~:text=%5B38%5D-,Dominoes%20in%20Unicode,-%5Bedit%5D](https://en.wikipedia.org/wiki/Dominoes#Dominoes_in_Unicode:~:text=%5B38%5D-,Dominoes%20in%20Unicode,-%5Bedit%5D) - https://www.reedbeta.com/blog/programmers-intro-to-unicode/ Here is a Python script for that(old habits die hard) ```python s = "🁳🁣🁲🁩🁰🁴🁃🁔🁆🁻🀳🁭🀰🁪🀱🁟🀳🁮🁣🀰🁤🀱🁮🁧🁟🀱🁳🁟🁷🀳🀱🁲🁤🁟🀴🁮🁤🁟🁦🁵🁮🀡🀱🁥🀴🀶🁤🁽" print(''.join(chr(ord(ch) - 0x1F000) for ch in s)) ``` ```zsh $ python3 solve.py scriptCTF{3m0j1_3nc0d1ng_1s_w31rd_4nd_fun!1e46d} ``` Flag : `scriptCTF{3m0j1_3nc0d1ng_1s_w31rd_4nd_fun!1e46d}` # Challenge: Enchant ![][image6] After some Google search, I found out that’s the Standard Galactic Alphabet, the glyphs used by Minecraft’s enchantment table. The enchantment-table glyphs map one-to-one to letters in the Standard Galactic Alphabet (SGA). I used the SGA ↔ Latin mapping to translate each symbol into its English letter at [https://www.dcode.fr/standard-galactic-alphabet](https://www.dcode.fr/standard-galactic-alphabet). The result is MINECRAFTISFUN. Flag: `scriptCTF{MINECRAFTISFUN}` Challenge: diskchal ![][image7] This was a forensics challenge; we were given a disk image, probably to recover some files, as the challenge description suggests. ```zsh mockingspectre@kali:~/ctf/scriptctf2025/forensics/diskchal$ file stick.img stick.img: DOS/MBR boot sector, code offset 0x58+2, OEM-ID "mkfs.fat", sectors 49152 (volumes <=32 MB), Media descriptor 0xf8, sectors/track 32, heads 4, FAT (32 bit), sectors/FAT 378, reserved 0x1, serial num ber 0x8caae860, unlabeled ``` The file is a FAT32 filesystem image created with `mkfs.fat`.I try to mount the image and see what's inside. ```zsh mockingspectre@kali:~/ctf/scriptctf2025/forensics/diskchal$ sudo mount stick.img mnt/ mockingspectre@kali:~/ctf/scriptctf2025/forensics/diskchal$ ls mnt notes.txt random_thoughts.txt mockingspectre@kali:~/ctf/scriptctf2025/forensics/diskchal$ cat * mnt/ cat: mnt: Is a directory �X�mkfs.fat �� z�)`誌NO NAME FAT32 �w|�"�t V^2�����This is not a bootable disk. Please insert a bootable floppy and press any key to try again ... U�RRaArrAa��U��X�mkfs.fat �� z�)`誌NO NAME FAT32 �w|�"�t V���^��2�����This is not a bootable disk. Please insert a bootable floppy and press any key to try again ... U�RRaArrAa��U�������������������������������Anotes�.txt������NOTES TXT Rk��Z[k��ZLBts.tx hash.txt mockingspectre@kali:~/ctf/scriptctf2025/forensics/justsome-avocado$ john --format=PKZIP hash.txt --wordlist=/usr/share/wordlists/rockyou.txt Using default input encoding: UTF-8 Loaded 1 password hash (PKZIP [32/64]) Will run 4 OpenMP threads Press 'q' or Ctrl-C to abort, almost any other key for status impassive3428 (justsomezip.zip) 1g 0:00:00:01 DONE (2025-08-17 17:16) 0.7633g/s 5609Kp/s 5609Kc/s 5609KC/s in4nd0keren..imissdesi Use the "--show" option to display all of the cracked passwords reliably Session completed. ``` After a few seconds, the password is revealed to be `impassive3428`. Inside the zip were two files with the same name as before ```zsh mockingspectre@kali:~/ctf/scriptctf2025/forensics/justsome-avocado/justsomezip$ tree . ├── justsomezip.zip └── staticnoise.wav 1 directory, 2 files mockingspectre@kali:~/ctf/scriptctf2025/forensics/justsome-avocado/justsomezip$ file * justsomezip.zip: Zip archive data, made by v3.0 UNIX, extract using at least v1.0, last modified Jun 24 2025 19:12:42, uncompressed size 28, method=store staticnoise.wav: RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit, stereo 22050 Hz ``` I tried brute-forcing the password again, but there were no positive results, so I shifted my focus onto the WAV file. Running exiftool on it showed a telling comment. The password couldn't be brute-forced ```zsh mockingspectre@kali:~/ctf/scriptctf2025/forensics/justsome-avocado/justsomezip$ exiftool staticnoise.wav ExifTool Version Number : 13.25 File Name : staticnoise.wav Directory : . File Size : 438 kB File Modification Date/Time : 2025:06:25 13:38:40+03:00 File Access Date/Time : 2025:08:17 17:18:23+03:00 File Inode Change Date/Time : 2025:08:17 17:18:17+03:00 File Permissions : -rw-r--r-- File Type : WAV File Type Extension : wav MIME Type : audio/x-wav Encoding : Microsoft PCM Num Channels : 2 Sample Rate : 22050 Avg Bytes Per Sec : 88200 Bits Per Sample : 16 Comment : What if my password isn't on rockyou.txt Software : Lavf61.7.100 Duration : 4.96 s ``` I opened the file in Sonic Visualizer and applied a spectrogram view. Here is what shows ![][image11] It looks like some embedded text, but it's clipped. I redid the same process in Audacity, and this is what it shows. ![][image12] I can make out the first three characters, d41, and the fifth `3`. We can use John's mask mode to generate the possible combinations. ```zsh john --mask='d41?1?1?1?1?1' --1='?l?d' --format=PKZIP hash.txt ``` * d41 = known prefix * ?1?1?1?1?1 = 5 unknown characters * --1='?l?d' = defines ?1 as “lowercase letters or digits” * --format=PKZIP = tells John it’s a ZIP hash ```zsh mockingspectre@kali:~/ctf/scriptctf2025/forensics/justsome-avocado/justsomezip$ john --mask='d41?1?1?1?1?1' --1='?l?d' --format=PKZIP hash.txt Using default input encoding: UTF-8 Loaded 1 password hash (PKZIP [32/64]) Will run 4 OpenMP threads Press 'q' or Ctrl-C to abort, almost any other key for status d41v3ron (justsomezip.zip/flag.txt) 1g 0:00:00:00 DONE (2025-08-17 21:23) 1.388g/s 9545Kp/s 9545Kc/s 9545KC/s d41v7non..d41oddon Use the "--show" option to display all of the cracked passwords reliably Session completed. mockingspectre@kali:~/ctf/scriptctf2025/forensics/justsome-avocado/justsomezip$ unzip justsomezip.zip Archive: justsomezip.zip [justsomezip.zip] flag.txt password: extracting: flag.txt mockingspectre@kali:~/ctf/scriptctf2025/forensics/justsome-avocado/justsomezip$ cat flag.txt scriptCTF{1_l0ve_d41_v3r0n} ``` Flag: `scriptCTF{1_l0ve_d41_v3r0n}` # Challenge: RSA-1 ![][image13] The attachment out.txt has ```txt out.txt has: n1 = 156503881374173899106040027210320626006530930815116631795516553916547375688556673985142242828597628615920973708595994675661662789752600109906259326160805121029243681236938272723595463141696217880136400102526509149966767717309801293569923237158596968679754520209177602882862180528522927242280121868961697240587 c1 = 77845730447898247683281609913423107803974192483879771538601656664815266655476695261695401337124553851404038028413156487834500306455909128563474382527072827288203275942719998719612346322196694263967769165807133288612193509523277795556658877046100866328789163922952483990512216199556692553605487824176112568965 n2 = 81176790394812943895417667822424503891538103661290067749746811244149927293880771403600643202454602366489650358459283710738177024118857784526124643798095463427793912529729517724613501628957072457149015941596656959113353794192041220905793823162933257702459236541137457227898063370534472564804125139395000655909 c2 = 40787486105407063933087059717827107329565540104154871338902977389136976706405321232356479461501507502072366720712449240185342528262578445532244098369654742284814175079411915848114327880144883620517336793165329893295685773515696260299308407612535992098605156822281687718904414533480149775329948085800726089284 n3 = 140612513823906625290578950857303904693579488575072876654320011261621692347864140784716666929156719735696270348892475443744858844360080415632704363751274666498790051438616664967359811895773995052063222050631573888071188619609300034534118393135291537302821893141204544943440866238800133993600817014789308510399 c3 = 100744134973371882529524399965586539315832009564780881084353677824875367744381226140488591354751113977457961062275480984708865578896869353244823264759044617432862876208706282555040444253921290103354489356742706959370396360754029015494871561563778937571686573716714202098622688982817598258563381656498389039630 e = 3 ``` this is the classic low-exponent (e = 3) broadcast attack. To crack it: 1. Use the three ciphertexts c1,c2,c3c_1,c_2,c_3c1​,c2​,c3​ and moduli n1,n2,n3n_1,n_2,n_3n1​,n2​,n3​. 2. Combine them with the Chinese Remainder Theorem to reconstruct M=m3 mod NM = m^3 bmod NM=m3modN where N=n1n2n3N = n_1 n_2 n_3N=n1​n2​n3​. Because the original message mmm is smaller than each modulus and m3 [image2]: [image3]: [image4]: [image5]: [image6]: [image7]: [image8]: [image9]: [image10]: [image11]: [image12]: [image13]: [image14]: [image15]: