analyzing CVE-2025-65606

Lately, I’ve been focusing on IoT exploitation and hardware hacking. On January 6th, I was reading the news1 when I found an interesting new vulnerability (CVE-2025-65606) in a Totolink product that won't be patched. After reading up on it, I searched for a proof of concept or an exploit, but the researcher hadn't made it public.

So, here I am. Trying to learn something new.

After searching for more information about the CVE, I found the researcher's (Leandro Kogan) post on LinkedIn2 and I gathered more information about the vulnerability:

  • "... TOTOLINK EX200 router (firmware V4.0.3c.7646_B20201211, last version)."
  • "... attacker can upload a maliciously crafted firmware update that takes advantage of improper error handling, triggering a system function wrapper which in turn executes various services, including telnetd."

The TOTOLINK EX200 is a compact, wall-plug device designed to boost and expand existing Wi-Fi signals to cover "dead zones" in a home or office.

Product

The next step was downloading the firmware. I was happy when I saw the official firmware on the TOTOLINK website: https://totolink.com.my/wp-content/uploads/2022/11/EX200_V4.0.3c.7646_B20201211.zip.

Download firmware

pre-analysis

extracting firmware

The firmware is zipped with a text file (changelog, upgrade guide).

EX200_V4.0.3c.7646_B20201211
├── IMPORTANT.txt
└── TOTOLINK_CS133E-EN_EX200_WX005_8196E_SPI_4M32M_V4.0.3c.7646_B20201211_ALL.web

Entropy Analysis

After doing an entropy analysis, I found an interesting part. Next to a large entropy drop, at 0x11FC3A, there is a hsqh string. It indicates that there is a SquashFS filesystem inside.

0011FC00  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  ................
0011FC10  26 D1 9A 37 00 00 1D DE  43 53 31 33 33 45 00 00  &..7....CS133E..
0011FC20  00 00 00 00 00 00 00 00  99 C9 72 36 63 72 00 2D  ..........r6cr.-
0011FC30  00 00 00 13 00 00 00 18  00 02 68 73 71 73 AE 02  ..........hsqs..
0011FC40  00 00 00 17 FD 80 00 00  02 00 1B 00 00 00 02 00  ................

The next thing I did was extract this filesystem with dd and unsquashfs.

$ dd if=TOTOLINK_CS133E-EN_EX200_WX005_8196E_SPI_4M32M_V4.0.3c.7646_B20201211_ALL.web of=fs.bin skip=$((0x11FC3A)) bs=1
1573456+0 records in
1573456+0 records out
1573456 bytes (1,6 MB, 1,5 MiB) copied, 0,789706 s, 2,0 MB/s

$ file fs.bin 
fs.bin: Squashfs filesystem, little endian, version 4.0, lzma compressed, 1569573 bytes, 686 inodes, blocksize: 131072 bytes, created: Fri Jul 30 02:35:44 2038

$ unsquashfs fs.bin 
Parallel unsquashfs: Using 8 processors
643 inodes (287 blocks) to write

[=======================================================================================================================================|] 930/930 100%

created 266 files
created 43 directories
created 95 symlinks
created 282 devices
created 0 fifos
created 0 sockets
created 0 hardlinks

The extracted filesystem is a regular linux filesystem:

squashfs-root
├──  bin
├──  dev
├──  etc
├──  home
├──  init ⇒ bin/init
├──  lib
├──  lighttp
├──  mnt
├──  proc
├──  sys
├──  tmp ⇒ /var/tmp
├──  usr
├──  var
└──  web_cste

initialization

It starts a lighttp 1.4.20 server with the config in /lighttp/lighttpd.conf. It can be found in /etc/init.d/rcS on line 120.

...
initcste
lighttpd -f /lighttp/lighttpd.conf -m /lighttp/lib/

crond &
...

The server document root is /web_cste. It can be found in /lighttp/lighttpd.conf on line 41.

## a static document-root, for virtual-hosting take look at the
## server.virtual-* options
server.document-root        = "/web_cste/"

In /web_cste there are a bunch of .htm, .js, .asp and some web files. There are some interesting files: /web_cste/cgi-bin/upload.cgi, /web_cste/cgi-bin/upload_bootloader.cgi, /web_cste/cgi-bin/cstecgi.cgi.

analysis

From the CVE details, I knew that the vulnerability is in the error handling of an upload function and that it starts a telnet service. I searched for the telnetd string in the document server root.

$ grep -iaRl "telnetd" 2>/dev/null
cgi-bin/upload.cgi
cgi-bin/upload_bootloader.cgi

Well, it was a little bit obvious that these files are juicy. :)

The next step was reversing the upload.cgi. I opened it in Ghidra3 and analyzed it (the architecture is MIPS Big Endian 32 bits).

After renaming some functions and variables, I detected critical sections in the main function.

The first section gets the language from nvram ( this strcpy from nvram looks weird and suspicious. Maybe ta opic for another post xD), gets the content length and server version.

  pcVar1 = (char *)get_language_nvram();
  strcpy(language,pcVar1);
  __s = fopen("/var/tmpimg","w");
  pcVar1 = getenv("CONTENT_LENGTH");
  lVar2 = strtol(pcVar1,(char **)0x0,10);
  length = lVar2 + 1;
  pcVar1 = getenv("SERVER_SOFTWARE");

The second section checks the firmware and flash size. It saves the flash size and checks:

  • if there is an error and its equal to 0.
  • if the firmware size is greater than the flash size.
  • else, continue.
  FLASHSIZE = get_flash_size();
  if (FLASHSIZE == 0) {
    FUN_0040203c(0);
    iVar3 = strcmp(language,"cn");
    if (iVar3 == 0) {
      puts(&DAT_0040446c);
    }
    else {
      puts("<br><b>Can not get flash size!</b><br><br>");
    }
  }
  else if ((uint)(FLASHSIZE << 0x14) < length) {
    FUN_0040203c(0);
    iVar3 = strcmp(language,"cn");
    if (iVar3 == 0) {
      puts(&DAT_004044c0);
    }
    else {
      puts("<br><b>Not a valid firmware file , The file is too large!</b><br><br>");
    }
  }
  else {
	  ...
	  ...
	  ...
  }

The two errors continue to the end of the main function. Let's analyze it later.

If there are no errors, the execution continues to the third section:

    FUN_004020e8(1);
    pcVar1 = (char *)malloc(length);
    memset(pcVar1,0,length);
    fread(pcVar1,1,length,_stdin);
    pcVar4 = strstr(pcVar1,"\r\n");
    if (pcVar4 == (char *)0x0) {
      printf("%s %d","RFC1867 error",1);
      return 0xffffffff;
    }
    __n = (int)pcVar4 - (int)pcVar1;
    __s_00 = malloc(__n + 1);
    if (__s_00 == (void *)0x0) {
      printf("boundary allocate %d faul!\n",__n);
    } else {
		memset(__s_00,0,__n + 1);
        memcpy(__s_00,pcVar1,__n);
        pcVar5 = strstr(pcVar4 + 2,"\r\n");
        if (pcVar5 == (char *)0x0) {
          printf("%s %d","RFC1867 error",2);
        }
        else {
          iVar3 = strncasecmp(pcVar4 + 2,"content-disposition: form-data;",0x1f);
          if (iVar3 == 0) {
            pcVar4 = strchr(pcVar4 + 0x22,0x3b);
            if (pcVar4 == (char *)0x0) {
              puts("We dont support multi-field upload.");
            }
            else {
	          iVar3 = strncasecmp(pcVar4 + 2,"filename=",9); // <- I found the parameter
	            ...
	        }
    }

The code continues checking formats and allocations. After this, the code continues with checks and, if there are no errors, writes the firmware.

But, how is managing errors?? For example, lets analyze the first two errors. Where do they terminate??

Well...

    }
  }
  if (__s != (FILE *)0x0) {
    fclose(__s);
  }
  FUN_00401468("rm -rf /var/tmpimg 1>/dev/null 2>&1",0);
  FUN_004020e8(0);
  FUN_00401fe8();
  
  exit(-1);
}

When there is an error, it removes tmpimg, calls FUN_004020e8(0) and FUN_00401fe8(). The second function is a wrapper for puts("\n</body>\n</html>");, but the FUN_004020e8 with 0 is the trigger.


void FUN_004020e8(int param_1)

{
  if (param_1 == 0) {
    FUN_00401468("telnetd &",0); // <- :D
    FUN_00401468("mon.sh &",0);
    FUN_00401468("daemon.sh &",0);
    FUN_00401468("udhcpd /etc/udhcpd.conf",0);
    FUN_00401468("nvram_daemon &",0);
    FUN_00401468("config-dnsmasq.sh",0);
    FUN_00401468("syslogd -C8 1>/dev/null 2>&1",0);
    FUN_00401468("klogd 1>/dev/null 2>&1",0);
    FUN_00401468("echo 1024 > /proc/sys/vm/min_free_kbytes",0);
    FUN_00401468("echo 1 > /proc/sys/vm/drop_caches",0);
  }
  else {
    FUN_00401468("killall telnetd",0);
	...
    FUN_00401468("echo 1 > /proc/sys/vm/min_free_kbytes",0);
  }
  return;
}

FUN_004020e8 with 0 initializes the telnetd service (FUN_00401468 is a function for execve).

emulation

I couldnt buy a physical TOTOLINK EX200 to verify if it is correct. I tried to emulate the webservice to see if making a request to /cgi-bin/upload.cgi fails because there is an error reading the flash size.

I emulated the environment with qemu and busybox:

$ sudo chroot . ./qemu-mips-static bin/busybox sh
# lighttpd -f /lighttp/lighttpd.conf -m /lighttp/lib/
2026-01-24 18:47:37: (server.c.624) opening pid-file failed: /var/run/lighttpd.pid No such file or directory 

After creating /var/run/lighttpd.pid:

# lighttpd -f /lighttp/lighttpd.conf -m /lighttp/lib/
2026-01-24 18:48:59: (network.c.300) can't bind to port:  80 Address already in use

After changing the port to 8000 in the configuration hehe :)

# lighttpd -f /lighttp/lighttpd.conf -m /lighttp/lib/
2026-01-24 18:50:29: (log.c.97) server started 

In http://0.0.0.0:8000: Web interface

A little bit broken, but seems up.

After making a GET request to /cgi-bin/upload.cgi: Errors in web interface

There are a bunch of errors. Wait... and a new port listening!!

$ ss -tlpn | grep "23" 
LISTEN 0      1                 0.0.0.0:23         0.0.0.0:*  

$ ps aux
root       93528  0.0  0.0 2317236 7388 ?        Ssl  19:53   0:00 /usr/libexec/qemu-binfmt/mips-binfmt-P /bin/telnetd telnetd
root       93626  0.0  0.0 2317232 7648 ?        Ssl  19:53   0:00 /usr/libexec/qemu-binfmt/mips-binfmt-P /bin/klogd klogd

final thoughts

It is evident that the error handling is vulnerable and it is sad to know that this will not be patched. Thanks to Leandro Kogan for discovering this!

This is not a formal research or PoC. Since I don't have the device, I haven't been able to test the CVE 100%. Everything described above is subject to errors.


footnotes

1

https://thehackernews.com/2026/01/unpatched-firmware-flaw-exposes.html

2

https://www.linkedin.com/posts/leandro-kogan-128a82197_certcc-vulnerability-note-vu295169-activity-7414356911028068353-rhLH

3

https://github.com/NationalSecurityAgency/ghidra