Having seeing some cool looking Chinese network switches on ServeTheHome, I've been checking out the new models every once in a while. This one has caught my attention and I got one:

You can find the exact same looking switch under multiple brands - I bought mine from Horaco. It is a managed 2.5G PoE switch with 2 SFP+ 10G cages. The label on the device itself states the model as "ZX-SW82TS-L2P". If we open it and go look at the PCB, we can also find "S1300WP-8GT-2S+ V1.03", which seems to match the scheme found in other popular Chinese switches, like for example the hasivo S1100WP-8XGT-SE. At the time of writing, hasivo has an Aliexpress listing with that model number and an identical looking device (apart from the color and branding).
The Web UI
The web UI looks surprisingly powerful, but in my case was all Chinese without any option to change. I asked the seller for an English firmware and got the response that one can switch the language via the console. It turns out that the switch also listens to telnet, so you can do this:
$ telnet 192.168.0.1
User Access Verification
Username:admin
Password:
Switch1>en
Switch1#factory language only-English
Switch1#reboot
Do you wish to reboot the switch? [Y/N]:
Logging in with the default admin/admin and the default IP address as printed on the label. Once the switch came back from reboot, the Web UI was indeed switched to English, yay!
The Console Port
The Switch comes with an RJ45 console port - this seems to be a Cisco/HP style RS232 RJ45 though I think only ground, RX and TX are connected. If we connect a USB to Console cable during startup, we get U-Boot logs:
U-Boot 2011.12.(3.6.8.55120) (Jul 11 2024 - 14:44:22)
Board: RTL9300 CPU:800MHz LX:175MHz DDR:600MHz
DRAM: 512 MB
SPI-F: MXIC/C22019/MMIO32-4/ModeC 1x32 MB (plr_flash_info @ 83f9a094)
Loading 65536B env. variables from offset 0xe0000
Net: Net Initialization Skipped
No ethernet found. 0
## Booting image from partition ... 0
## Booting kernel from Legacy Image at 81000000 ...
Image Name: RTK_SDK
Created: 2025-04-27 13:18:11 UTC
Image Type: MIPS Linux Kernel Image (lzma compressed)
Data Size: 6300104 Bytes = 6 MB
Load Address: 80000000
Entry Point: 803169a0
Verifying Checksum ... OK
Uncompressing Kernel Image ... OK
Starting kernel ...
console [ttyS0] enabled
bootconsole [early0] disabled
Calibrating delay loop... 531.66 BogoMIPS (lpj=2658304)
pid_max: default: 32768 minimum: 301
Mount-cache hash table entries: 1024 (order: 0, 4096 bytes)
Mountpoint-cache hash table entries: 1024 (order: 0, 4096 bytes)
SCSI subsystem initialized
usbcore: registered new interface driver usbfs
usbcore: registered new interface driver hub
usbcore: registered new device driver usb
TCP established hash table entries: 2048 (order: 1, 8192 bytes)
TCP bind hash table entries: 2048 (order: 3, 40960 bytes)
TCP: Hash tables configured (established 2048 bind 2048)
TCP: reno registered
UDP hash table entries: 256 (order: 1, 12288 bytes)
UDP-Lite hash table entries: 256 (order: 1, 12288 bytes)
futex hash table entries: 256 (order: 0, 7168 bytes)
ntfs: driver 2.1.31 [Flags: R/W].
jffs2: version 2.2. (NAND) (SUMMARY) © 2001-2006 Red Hat, Inc.
msgmni has been set to 489
random: modprobe urandom read with 1 bits of entropy available
io scheduler noop registered
io scheduler deadline registered
io scheduler cfq registered (default)
Serial: 8250/16550 driver, 1 ports, IRQ sharing disabled
serial8250: ttyS0 at MMIO 0x0 (irq = 47, base_baud = 10764700) is a 16550A
SCSI Media Changer driver v0.25
RTK_SPI_FLASH_MIO driver is bypassed
RTK_NORSFG3 driver is used
=================================================================
init_luna_nor_spi_map: flash map at 0xb4000000
SPI NOR driver probe...
MXIC/C22019/MMIO32-4/ModeC add SPI NOR partition
MTD partitions obtained from built-in array
Creating 7 MTD partitions on "rtk_norsf_g3":
0x000000000000-0x0000000e0000 : "LOADER"
0x0000000e0000-0x0000000f0000 : "BDINFO"
0x0000000f0000-0x000000100000 : "SYSINFO"
0x000000100000-0x000000200000 : "JFFS2 CFG"
0x000000200000-0x000000300000 : "JFFS2 LOG"
0x000000300000-0x000000a00000 : "RUNTIME"
0x000000a00000-0x000002000000 : "RUNTIME2"
=================================================================
ehci_hcd: USB 2.0 'Enhanced' Host Controller (EHCI) Driver
rtk_gen1_hcd_cs_init: rtk_gen1_hcd_cs_init()!
rtk_gen1_hcd_cs_init: register rtk_gen1_ehci ok!
usb_phy_configure_process: usb_phy_configure_process()!
rtk_gen1-ehci rtk_gen1-ehci: Realtek On-Chip EHCI Host Controller
rtk_gen1-ehci rtk_gen1-ehci: new USB bus registered, assigned bus number 1
rtk_gen1-ehci rtk_gen1-ehci: irq 28, io mem 0x18021000
rtk_gen1-ehci rtk_gen1-ehci: USB 2.0 started, EHCI 1.00
hub 1-0:1.0: USB hub found
hub 1-0:1.0: 1 port detected
ohci_hcd: USB 1.1 'Open' Host Controller (OHCI) Driver
ohci-platform: OHCI generic platform driver
usbcore: registered new interface driver uas
usbcore: registered new interface driver usb-storage
TCP: cubic registered
Freeing unused kernel memory: 4964K (80427000 - 80900000)
init started: BusyBox v1.00 (2025.04.27-05:16+0000) multi-call binary
Starting pid 26, console : '/etc/rc'
RTCORE LKM Insert...
TC util init (isr)
Starting pid 62, console /dev/ttyS0: '/bin/sh'
Decompressing /mnt/switch.tar.gz ...
switch
switch/kernel
switch/kernel/kernel_rtl9312_7v6
switch/kernel/9310-sdk3.6.11
switch/kernel/kernel_9301_version.txt
switch/other
switch/other/certificate.pem.crt
switch/other/private.pem.key
switch/other/do_next
switch/other/AmazonRootCA1.pem
switch/module
switch/module/system_gpio.ko
switch/module/inos_ifksdk.ko
switch/module/i2c-ddm.ko
switch/module/reset.ko
switch/module/i2c-poe.ko
switch/module/i2c-rtc.ko
switch/module/i2c-encyt.ko
switch/app
switch/app/ospf6d
switch/app/ripngd
switch/app/mstpd
switch/app/snmpd
switch/app/ripd
switch/app/nsm
switch/app/lacpd
switch/app/webfiles.tar
switch/app/ospfd
switch/app/bgpd
switch/app/openssh.tar.gz
switch/app/imi
switch/app/pimd
openssh
openssh/sbin
openssh/sbin/sshd
openssh/libexec
openssh/libexec/sshd-session
openssh/ssh
openssh/ssh/ssh_host_rsa_key.pub
openssh/ssh/ssh_host_ed25519_key.pub
openssh/ssh/ssh_config
openssh/ssh/ssh_host_ecdsa_key.pub
openssh/ssh/ssh_host_ecdsa_key
openssh/ssh/ssh_host_rsa_key
openssh/ssh/sshd_config
openssh/ssh/ssh_host_ed25519_key
openssh/lib
openssh/lib/libcrypto.so.1.1
openssh/lib/libz.so.1
openssh/lib/libz.so.1.3.1
openssh/passwd
openssh/bin
openssh/bin/ssh
Start to run IFKSDK ...
Start to load DDM module ...
Start to load PoE module ...
Start to load encyt module ...
Start to load GPIO module ...
Start to load reset module ...
Start to load RTC module ...
Start to run MSTP ...
Start to run LACP ...
Layer 2 switch loading ...
Start to load configuration ...
ManagedSwitch 6.9.0
Creation date:May 7 2025 16:25:44
User Access Verification
Username:
Getting the Firmware
In the past, I was usually lucky enough to find a firmware update somewhere I could further poke at. In this case, I could find very little information about this device and no firmware update files at all. So let's have a look inside!
Looking at the PCB, the SPI flash chip immediately jumps out due to the two different possible footprints:

Searching for the IC marking reveals that this is likely a 25-series SPI flash chip
from Macronix, the MX25L25645G (32MiB). This matches what we saw in the U-Boot log:
SPI-F: MXIC/C22019/MMIO32-4/ModeC 1x32 MB.
Since I've never done this before, let's try desoldering the chip and dumping it! To get it off, I taped the area around the chip with Kapton tape to prevent accidentally blowing away components. I then used a hot-air station around 290°C and heated up the chip's legs. Once the solder started to look liquid, I carefully lifted the chip with tweezers.
Then, I used an 8-pin SOP clamp and a CH341A programmer to dump the memory with
flashrom:
$ flashrom -p ch341a_spi -r firmware.bin -VV
which worked flawlessly and gave me a 32MiB file as expected.
Unpacking the Flash Dump
I started off with binwalk to get an idea what the dump contains:
$ binwalk firmware.bin
---------------------------------------------------------------------------
DECIMAL HEXADECIMAL DESCRIPTION
---------------------------------------------------------------------------
867884 0xD3E2C CRC32 polynomial table, little endian
1048576 0x100000 JFFS2 filesystem, big endian, nodes: 589, total
size: 2093068 bytes
3145792 0x300040 LZMA compressed data, properties: 0x5D,
dictionary size: 67108864 bytes, compressed size:
6300104 bytes, uncompressed size: -1 bytes
10485760 0xA00000 JFFS2 filesystem, big endian, nodes: 21788, total
size: 23068672 bytes
---------------------------------------------------------------------------
Now this is not what I expected based on the U-Boot output we saw earlier:
MTD partitions obtained from built-in array
Creating 7 MTD partitions on "rtk_norsf_g3":
0x000000000000-0x0000000e0000 : "LOADER"
0x0000000e0000-0x0000000f0000 : "BDINFO"
0x0000000f0000-0x000000100000 : "SYSINFO"
0x000000100000-0x000000200000 : "JFFS2 CFG"
0x000000200000-0x000000300000 : "JFFS2 LOG"
0x000000300000-0x000000a00000 : "RUNTIME"
0x000000a00000-0x000002000000 : "RUNTIME2"
As it turns out, MTD does not have a partition table (like it says, loading from built-in array)
so binwalk has no way of finding the MTD partitions and is instead looking for recognizable
filesystems and such.
Since we already know from U-Boot where the partitions are, we can just extract them ourselves. As I was doing this on a Windows machine, here's some Powershell to achieve this:
$bytes = gc .\firmware.bin -Encoding Byte -Raw
sc .\firmware\LOADER.bin ([byte[]]($bytes | select -First 0xe0000)) -Encoding Byte
sc .\firmware\BDINFO.bin ([byte[]]($bytes | select -Skip 0xe0000 -First 0x10000)) -Encoding Byte
sc .\firmware\SYSINFO.bin ([byte[]]($bytes | select -Skip 0xf0000 -First 0x10000)) -Encoding Byte
sc .\firmware\JFFS2_CFG.bin ([byte[]]($bytes | select -Skip 0x100000 -First 0x100000)) -Encoding Byte
sc .\firmware\JFFS2_LOG.bin ([byte[]]($bytes | select -Skip 0x200000 -First 0x100000)) -Encoding Byte
sc .\firmware\RUNTIME.bin ([byte[]]($bytes | select -Skip 0x300000 -First 0x700000)) -Encoding Byte
sc .\firmware\RUNTIME2.bin ([byte[]]($bytes | select -Skip 0xa00000 -First 0x1600000)) -Encoding Byte
Which was horribly slow but seemed to do the trick. dd on Linux would probably have been much faster.
The U-Boot env
From the U-Boot log, we know that the BDINFO.bin file should contain the U-Boot environment. Looking
at it in a hex editor looks like there's 4 bytes of something and then \0-byte separated key-value
pairs. Let's whip up some silly Python to parse it:
f = open('BDINFO.bin', 'rb')
d = f.read()
for var in d[4:].split(b'\0'):
if var == b'':
continue
print(var.decode('ascii'))
running it gives us:
baudrate=38400
boardmodel=RTL9302D_2x8224_2XGE
bootargs=console=ttyS0,38400n8
bootcmd=boota
bootdelay=3
bootmsg=1
ethaddr=00:E0:4C:00:00:00
ipaddr=192.168.1.1
ledModeInitSkip=1
serverip=192.168.1.111
stderr=serial
stdin=serial
stdout=serial
which looks somewhat interesting, as we potentially have 3 seconds to then use the tftboot
command to boot from 192.168.1.111. So far I didn't have any luck interrupting u-boot though.
The main JFFS2 Filesystem
RUNTIME2.bin is the largest partition with around 23 MB and contains a JFFS2 filesystem. To
extract it, we can use the jefferson PyPI package.
As we've already seen in the U-Boot log, there's a large switch.tar.gzfile. The script doing
the decompression is likely the file do, as it contains:
echo "Decompressing /mnt/switch.tar.gz ..."
tar zxvf /mnt/switch.tar.gz -C /tmp/
which matches the log output and the verbose decompression that follows. We can of course also
do the same to look at the contents. Intriguingly, tar tells us it is ignoring some extra
data at the end. This could hint at some sort of checksum or signature mechanism for firmware
updates.
After extraction, do executes /tmp/switch/other/do_next which matches more of the log output we've seen.
One interesting thing it does is unpack openssh.tar.gz so presumably SSH can somehow be turned on.
The rest of the script is loading some kernel modules and starting some daemons. The custom ones seem to
be /tmp/switch/app/imi and /tmp/switch/app/nsm.
We can also find all the Web UI endpoints, which seem to all be ELF binaries run via CGI - but for example
looking at upgradeImg.do, it mostly seems to do its thing by issuing console commands to 127.0.0.1:2700 so
it seems likely the Web UI is basically a frontend to the console.
All binaries seem unstripped, so understanding them should be somewhat easier.
The imi daemon
Based on the strings in this binary, it might be the main console server. Loading it into Ghidra, one
thing in the main function immediately jumps out: It seems to expect some sort of authentication
code on /dev/i2c-encyt.
Some more observations around the startup:
-
In
imi_openssh_start: The SSH port seems to be controllable in/mnt/switch_info/swinfo.txtwith anssh_port:line but defaults to 22. Thesshdseems to be started unconditionally. -
In
http_sock_init: The console open to the webinterface seems to be listening on 0.0.0.0:2700 -
There seems to be some sort of low level management interface in
imi_ztpm_handle_bpdulooking for magic Ethernet frames.
Since port 22 and 2700 do not seem open on the running device, I assume there must be some additional mechanism or packet filtering involved.
Switching gears a bit, let's look at references to switch.tar.gz. The function writeUpgradeFile looks interesting.
It first subtracts 0x1c from the filesize and calls imi_switch_file_additional_region_check. This function
checks that these last 0x1c bytes at the end of the file start with 0x90abcdef. If we go look at the
switch.tar.gz we extracted, it does indeed have that magic number at the expected position. The next 4 bytes
are then checked against a number in string form that depends on the version of the kernel file in /tmp/switch/kernel/.
In my case it's "9312". Finally, with suffix being these last files as uint32_t[], the filesize
must be equal to suffix[3] + suffix[4] + suffix[5] + 0x1c.
Another check is done on suffix[2]: It is also interpreted as a string and used for another file existence check.
In my case it is "7v6\0" and the resulting file /tmp/switch/kernel/kernel_rtl9312_7v6.
suffix[3] seems to contain a length from the start and just before that is an MD5. For the extracted example,
this points just to this suffix, so the MD5 should be before 0x90abcdef. The MD5 computation is a bit silly though
since strlen is used to determine the amount of bytes to be hashed. Here, the first \0 already appears after the
first 3 bytes of switch.tar.gz, so only these 3 bytes are hashed. The hash indeed matches with what is stored at
the end.
patch.tar.gz
There's also a patch feature where a patch.tar.gz can be downloaded from TFTP. The last 16 bytes are the MD5 of the file with the
length again determined with strlen. Inside, a patch/patch.txt is expected, with the first two lines being:
# This is a patch.txt file.cp /home/patch/patch.txt /mnt/patch.txt
The rest of the file is read line by line and every line is passed to system(). So let's give it a quick try
to see if we can execute commands on the switch. First, I installed and started tftpd-hpa on an Ubuntu
machine. We know from the web UI that ping seems to be installed, so let's have it ping our machine with
this:
# This is a patch.txt file.
cp /home/patch/patch.txt /mnt/patch.txt
# Ping
ping 192.168.1.123
Then, we can create an archive:
$ tar -czf patch.tar.gz patch/patch.txt
It's important that the patch.txt is in a patch subdirectory. Now we need to append the magic strlen MD5,
which we can do with a bit of Python:
import hashlib
with open("patch.tar.gz", "rb") as f:
data = f.read().split(b'\0')[0]
hash = hashlib.md5(data).digest()
with open("patch.tar.gz", "ab") as f:
f.write(hash)
Running this gives us the final file we can put into /srv/tftp/ to make it available over TFTP. Now connect
to the switch over telnet and run (assuming the TFTP server is at 192.168.1.123):
Switch>en
Switch#download patch 192.168.1.123 patch.tar.gz
Do you wish to continue? [Y/N]: Y
Downloading file. Don't shutdown the switch. Please wait for a moment ...
Total data bytes sent/received: 203.
Decompressing file. Don't shutdown the switch. Please wait for a moment ...
Updating files. Don't shutdown the switch. Please wait for a moment ...
At this point, it will hang due to the infinite pings, but you should see those with tcpdump arriving at the TFTP host. Yay!
Getting SSH access
As we've seen in the do_next script and openssh.tar.gz, we do have an sshd binary available
in /tmp/openssh/sbin/sshd and an SSH client in /tmp/openssh/bin/ssh. At first, I tried
to just run sshd on a different port - but that wasn't accessible from the outside. Running
on the standard port 22 also didn't work - on the serial console I could see that the port
was already in use.
I then tried starting sshd on a different port, then connecting back to my TFTP host with a
ssh key file I shipped within patch.tar.gz and opening a reverse tunnel with the -R option.
Doing this while actually running the client in the background with an & at the end worked...
Just to reveal that they did some custom vty logic to basically provide the CLI on any terminal.
I didn't have much luck with the -T option trying to bypass PTY allocation :(
Building a custom binary
Back when I was messing with a Rigol oscilloscope, I experimented with cross-compiling little rust binaries and running them on the scope. We can try doing the same here. So let's get the toolchain:
$ sudo apt install gcc-mips-linux-gnu gcc-multilib-mips-linux-gnu
$ rustup target install mips-unknown-linux-gnu
$ cargo new rshell
$ cd rshell
This time, we will set the linker in .cargo/config.toml:
[target.mips-unknown-linux-gnu]
linker = "mips-linux-gnu-gcc"
rustflags = ["-C", "target-feature=+crt-static"]
And apply the same optimization settings to get a small binary in Cargo.toml:
[package]
name = "rshell"
version = "0.1.0"
edition = "2024"
[dependencies]
[profile.release]
lto = true
strip = "symbols"
codegen-units = 1
opt-level = "z"
And finally compile:
$ cargo build --target=mips-unknown-linux-gnu -Zbuild-std --release
And we get a ~1M binary in target/mips-unknown-linux-gnu/release/rshell. However,
running this via patch.tar.gz just prints "Illegal Instruction" on the serial
console. So it turns out the MIPS core used in these Realtek chips might be
Lexra cores that do not fully support MIPS :(
Back to basics: C hello world
The RTL93xx chip is also used in other switches, for example the Zyxel XGS1210 series where some OpenWRT support seems to exist. I didn't really find anything hinting at requiring the RTL SDK toolchain to compile for these, so I'm wondering whether GCC actually avoids the problematic instructions while Rust doesn't. So let's give it a try with C and the MIPS GCC cross compiler we already installed.
#include <stdio.h>
int main() {
printf("Hello World!");
return 0;
}
and compile with
$ mips-linux-gnu-gcc -o hello main.c -static
sadly that runs into the same Illegal Instruction :(
Now the problem here is that I don't necessarily know the right search terms, so trying to find more information had a tendency to just end up with a million browser tabs. But luckily it's 2025 and tools like Gemini 2.5 Pro exist. Gemini proposed that this particular core does not have hardware floating point support, and that could also be a problem. After going back and forth a couple of times, the proposal was to build a MUSL softfloat toolchain:
$ git clone https://github.com/richfelker/musl-cross-make.git
$ cd musl-cross-make/
$ make TARGET=mips-linux-muslsf
$ make TARGET=mips-linux-muslsf install
$ export PATH=`pwd`/output/bin:$PATH
$ export LIBRARY_PATH=`pwd`/output/lib
We can then go back to our source code and compile our hello world:
$ mips-linux-muslsf-gcc -march=34kc -static main.c -o hello
And packaging this as a patch.tar.gz and running it on the switch actually works and prints
Hello World! on the serial console, yay!
Getting a shell
We can use something like this to get a shell on the switch. In my experimentation it seemed like while packets egressing from the switch worked fine for any port, the responses didn't make it to the binary. However, if we have our machine listen on the telnet port 23 and have the reverse shell binary connect to it, we can get a root shell on the switch.
Further Work
As far as I've seen, the other binaries and kernel modules in the firmware are all unstripped with nice symbols - so it should be in the realm of the possible to get something like OpenWRT working. Though that requires a lot more work of course.