Introduction


Web Security.

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.



Shell

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.


URL & Encoding

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.


Authentication Bypass

Not every web bug ends in a shell. Many of them come down to the application trusting something it should not. When the thing it trusts is your identity, getting it to believe you are someone you are not is called an authentication bypass.

This challenge runs pwnpost, a small feed app. You can log in with a normal account and read the feed, but the admin kept the flag in an unpublished draft. A draft is shown in full only to its author and to admin, so as a normal user you just see the first few characters:

pwn.college{…

To read the whole thing, the app has to believe you are admin.

In this challenge, the app writes your identity into the URL when you log in, then reads it straight back on the next request:

# after a successful login
return redirect(f"/?session_user={user['username']}")

# how each page decides who you are
username = request.args.get("session_user")

Nothing checks that you are the one who set session_user.


Challenge Environment

The challenge files are in /challenge.

Start the web server by running /challenge/server, then open https://challenge.internal in a browser inside the Desktop workspace.

You can log in to pwnpost with these accounts:

  • guest:password
  • hacker:1337

Putting your identity in the URL was clearly too easy to change, so this challenge moves it somewhere that feels safer: a cookie. You log in for real, and the app hands you a cookie that later tells it who you are.

But safer is not the same as safe. A cookie is just a value your browser stores and sends back on every request, and this app never signs it or checks it. It sets the cookie at login and trusts whatever comes back:

# at login
response.set_cookie("session_user", user["username"])

# on every page
username = request.cookies.get("session_user")

Command Injection

Web apps often lean on the command-line tools already on the server. Need a directory listing? Shell out to ls and return whatever it prints. It is quick and it works, right up until user input becomes part of the command.

This tool runs ls on a path you provide. Your input is pasted straight into a shell command:

command = f"ls -la {path}"
subprocess.run(command, shell=True)

The problem is shell=True. It hands your whole input to a shell, which re-reads the string looking for its own syntax before running anything. A character like ; ends the ls command and starts a new one, so your input stops being an argument to ls and turns into a command of your own. This is command injection.

The last challenge dropped your input straight into the command, so this one tries to contain it. Your input is wrapped in single quotes before it joins the command, on the theory that the shell will then treat it as one harmless string:

command = f"ls -la '{path}'"
subprocess.run(command, shell=True)

That almost holds. The catch is that you also control the character that ends the quoting. If your input contains a single quote, it closes the quoted string early, and everything after it is back out in the open as part of the command line.

Breaking out of the quotes was too easy, so this challenge takes a different approach. Before your input reaches the command, the app rejects it if it contains one of a few banned characters:

BLOCKED = [";", "&", "|"]

if any(token in path for token in BLOCKED):
    return "blocked"

command = f"ls -la {path}"
subprocess.run(command, shell=True)

The weakness of a blocklist is that it only stops what its author thought of. A shell has more than one way to end one command and start another, and not all of them are on this list.

The last filter blocked only a few separators, so this challenge uses a much longer one. Nearly every shell metacharacter the author could think of is rejected on sight: the chaining operators, command substitution, redirection, quotes, and more.

BLOCKED = set(";&|<>$`(){}[]*?!#~" + "'" + '"' + "\\")
if any(char in BLOCKED for char in path):
    return "blocked"

command = f"ls -la {path}"
subprocess.run(command, shell=True)

It is still a blocklist, so it can still only reject what is on it. A shell treats more than punctuation as the boundary between commands, and at least one of those boundaries never made it into this set.

After all that, the developer took the advice everyone gives: do not invoke a shell at all. This challenge splits your input into arguments itself and passes the list straight to the program, with no shell in between:

args = ["tar", "cf", "/tmp/backup.tar"] + shlex.split(target)
subprocess.run(args)

There is no shell to parse your ;, your quotes, or your $(...), so every trick from the earlier challenges is dead. You cannot start a new command.

But you still decide what arguments tar receives, and that is its own weakness. This is argument injection: even without a shell, the right flag can be as dangerous as a command. Plenty of ordinary tools have options that do far more than their name suggests, and some tar options can run a command for you.


Path Traversal

Plenty of apps need to hand you a file: a document, an avatar, a report. The quick way is to take the name you ask for, join it onto a folder the app trusts, and open whatever that points at.

Docs does exactly that. The name from your request is pasted onto its document folder and opened:

path = f"{BASE}/{filename}"
with open(path) as handle:
    content = handle.read()

The problem is that a file name can carry more than a name. It can also carry directions through the folders, and .. means "go up a level." Nothing here keeps your input inside the document folder, so you can walk out of it and into the rest of the filesystem. This is path traversal.

The app opens files with more privilege than your own shell has, so it can read files you cannot.

A bare .. walked straight out of the folder last challenge, so this one scrubs your input first. Before the path is built, it deletes every ../ it finds:

path = f"{BASE}/{filename.replace('../', '')}"

The flaw is that replace makes a single pass and never looks at what it leaves behind. It cuts out each ../ it sees, but the characters on either side then slide together, and they can form a brand new ../ that the pass has already moved past.

Rewrites of .. kept slipping through the scrubber last challenge, so this one stops editing your input and simply rejects it if it contains ..:

if ".." in filename:
    return error

path = os.path.join(BASE, filename)

Climbing up with .. is not the only way to point somewhere else.

An absolute path slipped past the last challenge because the filter only watched for ... This one tries to close both routes at once. It cleans the name by stripping . and / off the ends, which knocks the ../ off a relative climb and the / off an absolute path:

path = f"{BASE}/{urllib.parse.unquote(filename).strip('./')}"

strip('./') looks like it removes a leading ../, but that is not what it does. It removes any run of . and / characters from the two ends of the string, and it never touches the middle. The developer pictured a traversal that sits right at the front of the name, so that is all the cleanup guards against. A ../ buried in the middle rides through completely untouched.


30-Day Scoreboard:

This scoreboard reflects solves for challenges in this module after the module launched in this dojo.

Rank Hacker Badges Score