The web is just browsers and servers passing data back and forth. Every system in that chain has to decide what to trust, and that's where things start to go wrong.
This module walks through the basics: poking at servers, tripping over simple misconfigurations, and turning small bugs into a foothold you can build on.
In the real world, it is extremely rare to find yourself with direct shell access to your target environment, even an unprivileged one.
After gaining an initial foothold through various vulnerabilities, you typically need a reliable means of achieving remote code execution.
Usually, you have two main options: bind shells and reverse shell. A bind shell opens a port on the target machine and waits for you to connect, but this approach has severe limitations. Firewalls typically block incoming connections, NAT makes direct connections impossible, and monitoring tools easily detect open ports.
A reverse shell, however, instead of you trying to connect TO the target, the target connects to YOU. The compromised system reaches out through the firewall (outbound connections are usually allowed), bypasses NAT restrictions, and establishes the connection from the inside out.
It's like having the fortress call you with the keys, rather than trying to break down the front gate.
Challenge Environment
In this challenge, the server is automatically started; you can access the website at: https://challenge.internal
The server is listening for a request at https://challenge.internal/reverse endpoint in order to trigger a reverse shell connecting to localhost on port 1337.
Now that we learned about reverse shell, understanding bind shells is equally important for your foundational knowledge.
For bind shell, instead of having the target reach out to you, you connect to a listening port on the target machine.
This technique has its place in specific scenarios, perhaps you're already inside a trusted network where firewalls aren't blocking internal connections, or you're working in an environment where outbound connections are heavily monitored but internal traffic flows freely.
Challenge Environment
In this challenge, the server is automatically started; you can access the website at: https://challenge.internal
The server is listening for a request at https://challenge.internal/bind endpoint in order to bind and start a shell at localhost on port 1337.
Some characters are data. Some characters are delimiters.
When parsers see characters like & and =, they often split parameters instead of keeping them inside your payload. Reliable exploitation means controlling when special characters are interpreted and when they are preserved.
Challenge Environment
In this challenge, the server is automatically started; you can access the website at: https://challenge.internal
The server is listening for a request at https://challenge.internal endpoint accepting payload argument.
Read the server's source code at /challenge/server, preserve delimiter bytes inside payload, and retrieve the flag.
Web payloads are not limited to strings. Encode an entire ELF executable into a query parameter and have the server execute it.
Challenge Environment
In this challenge, the server is automatically started; you can access the website at: https://challenge.internal
The server accepts a request at https://challenge.internal/?elf=... where elf is a URL-safe base64 encoding of the ELF bytes.
Read the server's source code at /challenge/server, build an ELF payload, encode it, send it over HTTP, and use the resulting execution primitive to retrieve the flag.
Not every web bug is about popping a shell. Plenty of them are about the application simply believing the wrong thing. When that belief is "this request is coming from a logged-in user," getting it to believe you without real credentials is called an Authentication Bypass.
This challenge runs pwnpost, a small feed app. Anyone can write drafts and publish them, but a draft stays private to its author and to the admin user. The admin parked the flag inside an unpublished draft, so your job is to get the app to treat you as admin and show it to you.
The weak spot here is trust. When you log in, the app stamps your identity into the URL and then reads it right back off the next request, never stopping to ask whether you were the one who put it there. Have a look at how the / route decides who you are, and see if you can answer that question for it.
Challenge Environment
The challenge files are located in /challenge.
To begin, start the web server: /challenge/server
Once running, you can access the website at: https://challenge.internal
After the last level, putting your identity in the URL was clearly a mistake, so this version moves it somewhere that feels safer: a cookie. You log in for real now, and the app drops a cookie that the / route trusts to tell it who you are.
Trouble is, "safer" isn't the same as "safe." A cookie is just a value your browser holds onto and sends back, and this app neither signs it nor double-checks it. Log in with an account you actually have, take a look at the cookie you got, and consider what the app would do if that value happened to read admin.
Editing the cookie is off the table now. This version signs the session, so without the server's secret key any tampering just gets thrown out.
The session isn't the only thing the app looks at, though. Applications often run behind a proxy and pick up details about a request from its headers, like which address it came from or whether it should count as internal traffic. The awkward part is that headers ride along in the request, which means they come from you. If the app takes one of them at face value, you get to decide what it says.
Read through the source, figure out which header flips the app into treating you as admin, set it, and go collect that draft.
The session and the header tricks are both closed off, and admin's password is still a mystery, so the regular login form won't get you into that account.
What's new is an /admin area guarded by HTTP Basic authentication. With Basic auth, your browser attaches an Authorization: Basic <base64(user:password)> header on each request, and if it's absent the server answers with a 401 and a WWW-Authenticate prompt asking for credentials.
It looks like you're stuck without admin's password, but check what the app genuinely inspects in that header versus what it just waves through. Once you spot the gap, build the header yourself and let it carry you into the admin area.
You can hand-craft a Basic credential with curl -u <user>:<pass> https://challenge.internal/admin.
Every shortcut from the earlier levels is gone: no identity in the URL, no editable cookie, no header to lean on, no admin side door. The session is signed and the form is the only entrance, so this time you have to get the login handler itself to wave you through.
Admin's password still isn't something you can find or guess. But a login is really just a chain of checks run one after another, and the way they're ordered (plus the exact condition guarding each one) leaves room for mistakes. Read the handler closely and look for the input that slips past every check.
Web apps love to lean on the tools already sitting on the server. Need a directory listing? Just shell out to ls and hand back whatever it prints. It's quick, it works, and it's a great way to get owned.
The problem shows up the moment your input becomes part of the command line. To the shell, your input isn't a single value, it's more text to parse, and a few well-placed characters turn "an argument to ls" into "a second command of my choosing." This is Command Injection.
This tool runs ls on a path you provide and returns the result. Read the source, figure out how your input lands in that command, and get it to run something that prints the flag at /flag instead.
Getting popped by a semicolon stung, so this version fights back. Before your input ever reaches the command, the app scans it for the obvious troublemakers and rejects the request if it spots one.
The catch with blocklists is that they only stop what the author remembered to think of. A shell has more than one way to glue commands together and more than one way to spell a space, and not all of them made it onto the list. If the front door is bolted, look for a window.
Same tool, same goal: get a command of yours to run and read /flag. Check the source to see exactly which characters are off-limits, then reach for one that isn't.
Chasing individual bad characters clearly wasn't working, so the developer changed tactics. Now your input gets wrapped in quotes before it joins the command, on the theory that the shell will treat the whole thing as one harmless string no matter what you put in it.
It's a reasonable instinct, and it almost holds. The weak point is that you also get to supply the very character that ends the quoting. Once the quote closes, you're back out in the open with the rest of the command line in front of you.
It's still the ls tool and still the same goal. Look at how the source builds the command, slip out of the quotes, and run something that prints /flag.
The last filter only swatted away a handful of separators, so this version shows up with a much longer blocklist. Every shell metacharacter the author could think of gets rejected on sight: the chaining operators, command substitution, redirection, quotes, the lot. Throw the usual payloads at it and you'll just be turned away.
The trouble with a blocklist is that it can only reject what someone thought to put on it, and a shell recognizes more ways to end one command and start another than most people picture. Somewhere in that gap is a separator that never made the list. Read the source, work out what it forgot, and slip through.
After all that, the developer finally read the advice everyone gives: don't invoke a shell at all. This version splits your input into arguments itself and hands the list straight to the program. There's no shell to parse your semicolons, your quotes, or your $(...), so every trick from the earlier levels is genuinely dead.
So you can't start a new command. But you can still decide what arguments the program receives, and plenty of perfectly normal tools have options that do far more than their name suggests. If one of those options happens to run a command for you, you don't need a shell of your own.
This tool backs up the paths you list by running them through tar. Read the source, look up what flags tar is willing to accept, and find the one that turns "archiving files" into running your command to read /flag.
Plenty of apps need to hand you a file: a document, an avatar, a report. The lazy way is to take the name you ask for, stick it onto a directory the app trusts, and open whatever that points at. The trouble is that a file name isn't just a name, it can also contain directions, and .. means "go up a level."
This is Path Traversal: when the name you supply is allowed to walk out of the intended directory and into the rest of the filesystem.
pwndocs reads files out of its document folder and shows them to you. It runs with enough privilege to read the flag at /flag, even though you can't read that file from your own shell.
A bare .. walked right out of the folder last time, so this version scrubs your input first: it finds the traversal sequence and deletes it before building the path. No more ../, no more climbing. Supposedly.
The flaw is in how the scrubbing runs. It makes a single pass over your input and removes what it sees, but it never looks back at the result. If a traversal sequence can be left behind after the deletion finishes, the cleanup hands it straight to the file open. Think about how to write ../ so that cutting one piece out of the middle leaves another ../ in its place.
Clever rewrites of .. kept slipping through, so this version stops playing games with the text and simply refuses anything containing ... If there's no way to climb upward, there's no way out of the folder. Right?
Climbing up isn't the only way to point somewhere else. Joining a trusted folder with a user-supplied name only stays inside that folder while the name is relative. Hand the join an absolute path instead and the folder it was supposed to anchor to quietly disappears, leaving you pointed wherever you named, no .. required.
Both doors are shut now: the app rejects anything with .. in it and anything that starts with a slash. The relative trick and the absolute trick are both dead on arrival.
But notice the order of operations. The check runs against the name as it arrives, and only afterward does the app decode percent-escapes in it. That gap is the whole game. If your traversal is still wrapped in encoding when the check looks at it, the check sees something harmless, and by the time the decoding turns it back into ../ the inspection is already over. Your request travels through one layer of decoding before it ever reaches the app, so you'll need to wrap it well enough to survive that and still arrive encoded.
The encoding trick worked because the old filter inspected your input before decoding it. This version fixes that order: it decodes your input all the way down first, and only then cleans it. With the name fully decoded, there is no hidden layer left to smuggle a traversal through.
The cleaning step is where it gets interesting. The developer assumed a traversal always announces itself the same way at the same place, and wrote the sanitizer to match that assumption exactly. Read what that step actually does to your input, and ask whether your traversal is really obliged to look the way the developer pictured it.