Information

Introduction

Oray PgyBox X5 Pro Router firmware 7.6.1 allows an administrator with the management system password to put parent directory components in the filename parameter of config_restore_ex. In URL download mode, the API writes the fetched content outside its /tmp staging area before validating the backup. Writing Lua code to /etc/openwrt_release and then calling sys_base_info executes that code in the root Web process, even though the restore request reports an invalid cipher file.

Vulnerability Details

The X5 Pro 7.6.1 device showed the full sequence. Before the restore request, sys_base_info returned code: 0 and the proof URL returned HTTP 404. The authenticated config_restore_ex request returned code: 116, the cipher file error. After another sys_base_info request returned code: 0, the same proof URL returned HTTP 200 with uid=0(root) gid=0(root) in its body. The failed restore had left executable content at the selected destination.

The request goes to /cgi-bin/oraybox with _api=config_restore_ex. The CGI checks a supplied _pwd against the oray management account, rejects a user without valid credentials for this module, and then invokes index(args). The relevant dispatcher lines are:

local check_res = luci.sys.user.checkpasswd("oray", pwd)
if check_res then return "admin" end

local m = require("oraybox.api." .. api_name())
local user = get_login_user()
if not user and not m.i_do_not_need_password then
    error_output(oraybox.errorcode.wrong_passwd)
    return
end
local output = m.index(args, user)

The decoded 7.6.1 module /usr/lib/lua/oraybox/api/config_restore_ex.lua:25-41,125-150 defines a blacklist check for filename. The blacklist omits . and /. Its index() accepts is_upload_file=0, turns that value into false, decodes add_list=[] as a Lua table, and passes these values to done(). An empty table satisfies the add_list check in done().

filename = {
  type = oraybox.util.STRING_CHECK_TYPE,
  filter_string = "`\"[|$&;<>^+'{\n",
  method = 1
},
is_upload_file = {type = oraybox.util.INT_CHECK_TYPE, min = 0, max = 1}

if args.is_upload_file == "0" then
  args.is_upload_file = false
elseif args.is_upload_file == "1" then
  args.is_upload_file = true
else
  return {code = oraybox.errorcode.args}
end
ret = {code = done(args.filename, args.is_upload_file,
  args.reboot == "1", luci.json.decode(args.add_list), args.url, args.md5)}
write_log("config_restore_ex code: %d add_list: %s", ret.code, args.add_list or "[]")
return ret

With a URL present and is_upload_file false, done() prefixes the supplied name with /tmp/ and gives that path to wget -O (config_restore_ex.lua:50-77). The name ../etc/openwrt_release makes the output path /tmp/../etc/openwrt_release, which resolves to /etc/openwrt_release. The module attempts oray_cipher only after the download. Its error return occurs before the later cleanup, so a rejected plaintext backup remains at the destination. There is no MD5 gate before this write.

if not add_list then return oraybox.errorcode.args end
-- In the URL branch of done():
config_filename = "/tmp/" .. filename
local cmd = "wget " .. (oraybox.util.softfeature_ifdef("SOFTFEATURE_DISABLE_SSL_VERIFY") and "--no-check-certificate " or "") .. "%s -O %s 2>/dev/null"
local ret = secure_exec.call(cmd, url, config_filename)
if ret ~= 0 then return oraybox.errorcode.download end

local cipher_file = config_filename .. "_cipher"
secure_exec.call("rm %s -rf", cipher_file)
local code = secure_exec.call("oray_cipher -i %s -o %s -c decode 2>/dev/null", config_filename, cipher_file)
if code ~= 0 then return oraybox.errorcode.cipher_file end

/etc/openwrt_release normally contains Lua assignments. In the decoded /usr/lib/lua/oraybox/api/sys_base_info.lua:58,133-135, index() calls get(), and get() evaluates that file before reading DISTRIB_RELEASE. The uhttpd init command starts the Web service without a user override. Thus the subsequent authenticated information request supplies the execution step in the root CGI context.

-- sys_base_info.lua
dofile("/etc/openwrt_release")
local ver_main, ver_sub, ver_revision =
  string.match(DISTRIB_RELEASE or "", "(%d+)%.(%d+)%.(%d+)")

function index(args)
  local code, sn, ver_main, ver_sub, ver_revision, ver_type = get()
  -- response assembly follows
end
# /etc/init.d/uhttpd, start_instance()
procd_set_param command "$UHTTPD_BIN" -f

PoC

Run the following Python 3 command on a computer reachable from the router. Set SERVER_IP to that computer's address as seen by the router, ROUTER_HOST to the router's management address, and ORAY_PASSWORD to the management system password. The script serves the Lua payload in memory, makes both authenticated API requests, and reads the proof through the router's Web server. The payload keeps the release assignments needed by sys_base_info and writes the root execution proof.