ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

正反向隔离装置下的TCP/UDP穿透:可改可测的实现方案

正反向隔离装置下的TCP/UDP穿透:可改可测的实现方案 简介正反向隔离装置是工业控制网络中保障安全隔离与数据交换的关键设备这份demo面向网络工程师、安全测试及电力自动化开发人员解决隔离场景下TCP/UDP协议穿透的落地问题。资源在已有TCP穿透的基础上新增UDP支持可帮助快速验证隔离装置两侧业务的实时通信能力。压缩包共包含11个文件总大小仅1.68MB结构清晰两套C工程测试程序xb_gl_tcp_test、xb_gl_udp_test、代理程序xb_gl_proxy及其输出目录、INI配置、txt说明文档还有一份《隔离服务整体方案》PPTX从原理到部署层面给出完整参考。目前已有1248人学习下载。借助源码和说明读者可掌握代理框架、配置参数与穿透流程直接编译或运行demo验证效果为实际项目中的隔离装置通信方案提供可复用的代码与设计思路。1. 正反向隔离装置的 TCP/UDP 穿透一个能直接改的 demo做过电力安全区数据接入的人都知道正向隔离装置把内网到外网的方向做成了只能过特定 UDP 报文的单向通道TCP 连接一上去就被丢跨区业务却偏偏大量用 TCP比如 Modbus TCP、文件传输。这个 demo 干的事就是在装置两侧各放一个透明代理把 TCP 会话封装成 UDP 帧穿透单向通道再用反向通道把 ACK 带回来让上层应用感觉不到 TCP/IP 协议栈被断开。适合被隔离装置卡过端口、想从零搭一套可改可测的穿透链路的工程师。下文按网络模型、正向封装、反向回执到验证和踩坑顺序展开照着配置就能复现。2. 正反向隔离装置的网络模型确认单向通道的边界条件隔离装置不是普通三层防火墙。它更像物理单向阀正向装置一侧只能从安全区 I/II 向 III/IV 区推数据反向装置则反过来而且多数型号要求应用层报文带明确格式否则直接丢。所以做穿透的第一步不是写代码而是把通道边界画清楚。画不清楚后续所有“通了但一会儿断”的问题都会回来找你。2.1 正向隔离与反向隔离的差别TCP 为什么会被拦很多第一次接触隔离装置的人会下意识拿防火墙思路去配 ACL结果 TCP 连接根本建立不起来。原因是 TCP 三次握手需要双向路径客户端发 SYN服务端回 SYN-ACK这个 SYN-ACK 在正向通道上是不存在的。正向隔离装置在硬件上就只允许内网口向外网口发送外网口根本无法向内网口回包。站在业务主机的 TCP/IP 协议栈角度看就是发出的 SYN 石沉大海连接一直停在 SYN_SENT直到超时。反向隔离装置的问题更隐蔽。它是允许外网向内的方向发送数据的但通常要求报文符合它内部定义的应用层协议并且要有确认机制。比如你从外网区向内网区传一个文件不能一个 UDP 包扔进去就完了装置会检查文件格式、长度、序号还要你按指定报文格式回 ACK。正因为反向链路有这套强制确认我们的穿透协议才必须把 ACK 机制放在应用层自己的帧里而不是依赖 TCP 的 ACK。这里还有一个容易忽略的点隔离装置内侧和外侧的 IP 网段往往不互通两侧各是独立的三层网络。你不可能用一个 socket 把两端透明连接起来必须在两侧各放一个转发代理由代理去适配装置的单向规则。这个 demo 的拓扑就是建立在这个前提上的。2.2 demo 的拓扑与 IP/端口规划这里的网络拓扑可以按最小两机三网段来搭。在实验室里你可以用两台 Linux 虚拟机模拟安全区 I 和安全区 II用一台配置了双向 UDP 转发规则的主机模拟隔离装置的放行规则。当然真实装置配置会更严格但协议逻辑一致。下面这张表是 demo 里用的 IP 和端口规划IP 换成你自己的安全区规划即可。角色所在区域IP 地址端口说明业务客户端安全区 I192.168.1.10-发起 Modbus TCP 或其他 TCP 业务请求TCP 前端代理安全区 I192.168.1.11监听 8080接收本地业务连接封装为 UDP正向隔离模拟安全区 I 到 II内网口 192.168.10.1UDP 9000只放行正向 UDP 9000TCP 后端代理安全区 II192.168.20.11UDP 9000 / TCP 8000解封装并连接业务服务业务服务安全区 II192.168.30.12TCP 8000实际提供 Modbus TCP 等能力反向隔离模拟安全区 II 到 I外网口 192.168.40.1UDP 9001只放行反向 ACK 帧这张表里正、反向隔离装置各有一个内网口和一个外网口。正向链路走 UDP 9000反向链路走 UDP 9001。9000 和 9001 分开有两个好处一是在装置上配白名单时可以精确区分方向二是避免正反向帧在同一个端口互相干扰排查的时候也更容易抓包。需要提醒的是真实隔离装置的端口规划要遵循项目安全加固要求不要把我这个 IP 直接抄上去。我一般会先写好一张类似的表发给网络组一起评审确认哪些端口是装置厂商允许开放的再进代码。2.3 为什么封装层要选 UDP 而不是 TCP这是整个 demo 最关键的选型决策。既然业务层是 TCP为什么封装层不用 TCP原因不是 TCP 不可以用在隔离装置外而是 TCP 的握手和重传机制在单向链路上会失效。你可以在正向链路用 TCP 发数据但服务端回给客户端的包被物理单向阻断TCP 连接根本建不起来。UDP 无连接一个 socket 发出去就完非常契合“单向发送”这个物理能力。另外UDP 报文可以自定义帧头。我们在每个帧前面加魔数、版本、类型、序号和长度装置侧就能按帧头特征做白名单过滤而不是只按端口放行。这比裸 TCP 传一个私有协议安全得多也更容易通过安全审计。UDP 不可靠的问题则通过 demo 里的 ACK 和超时重传来解决本质上是在 UDP 之上实现了 TCP 的可靠传输子集。有人会问直接用厂商自带的“隔离装置专用转发软件”不是更省事吗是省事但很多项目里装置两侧的设备不属于同一集成商或者需要穿透自定义 TCP 协议厂商工具往往只能转发文件或固定应用。这个 demo 的价值就在于把转发逻辑放到你手里能改、能测、能对接自己的应用。提示真实隔离装置上改端口白名单前先与厂商确认支持哪些自定义协议字段避免帧结构不匹配被静默丢弃。3. 正向穿透实现把 TCP 业务流封装成 UDP 单向报文边界模型清楚后正向链路其实只有三个动作收 TCP、切分、封装 UDP。但细节都埋在“切分”和“封装”里这里每一步都要按报文格式来不能像普通 socket 编程那样直接转字节。3.1 三个角色TCP 前端代理、隔离装置转发、UDP 后端代理正向链路涉及三个角色。TCP 前端代理监听安全区 I 的一个 TCP 端口业务客户端像访问普通 TCP 服务一样访问它隔离装置把 UDP 9000 端口的报文从内网口转运到外网口TCP 后端代理监听外网口一侧的 UDP 9000收到完整业务流后解开封装自己作为 TCP 客户端去连接真正的业务服务。前端代理每接受一个本地连接后端代理就需要一个对应的“会话”来记录目标服务的地址和当前状态。因为 demo 的目标是让你先跑通所以第一版我做的是单会话模式前端同时只处理一个 TCP 连接后端也只有一个到业务服务的 TCP 连接。实际项目里加上会话映射并不难核心数据结构是dictkey 用前端连接的源端口或者全局自增会话 IDvalue 存后端的 TCP socket。后面在避坑章节会专门说漏掉这个映射会踩什么雷。3.2 帧格式定义与参数先看帧头。为什么不用最朴素的“直接塞业务数据”因为 UDP 包可能在装置侧乱序、重复、被广播包混入。如果没有帧头后端代理收到一串字节根本分不清哪里是一帧、是不是自己该收的。这个 demo 定义了一个最简单的定长帧头。偏移长度字段说明03magic固定bISO识别有效帧31version协议版本当前为 0x0141type0x01 数据0x02 ACK0x03 心跳54seq数据帧序号大端无符号整数92lenpayload 长度大端无符号短整型11可变payload业务数据切片用 magic 开头可以过滤掉装置内网口上其他杂散 UDP 包seq 是可靠传输的基础len 用于把数据帧和可能粘在一起的多帧拆开。封装代码很简单# frame.py import struct FRAME_MAGIC bISO FRAME_VERSION 0x01 TYPE_DATA 0x01 TYPE_ACK 0x02 TYPE_HEARTBEAT 0x03 def pack_frame(f_type, seq, payloadb): # 帧头占用 3114211 字节payload 长度由调用方控制 header (FRAME_MAGIC bytes([FRAME_VERSION, f_type]) struct.pack(!I, seq) struct.pack(!H, len(payload))) return header payload这里struct.pack(!I, seq)把 seq 打成 4 字节大端无符号整数!H把 payload 长度打成 2 字节大端。!表示网络字节序也就是大端跨平台不会出错。seq 初始为 0每发一个数据帧加 1回绕时用 0xffffffff处理避免超过 32 位。3.3 TCP 前端代理读 TCP、分片、发 UDPTCP 前端代理的核心循环是accept一个 TCP 连接然后循环recv每读一段业务数据就按 MTU 切分成多个 UDP 帧发出。问题在于业务层一次可能写 2KB、4KBTCP 流没有包边界所以必须自己把字节流切成不超过 MTU 的分片。这里我按 1400 字节分片加上 11 字节帧头、20 字节 IP 头、8 字节 UDP 头总长度 1439 字节不超过标准以太网 MTU 1500。# tcp_front.py import socket from frame import pack_frame, TYPE_DATA UDP_DST (192.168.10.1, 9000) # 正向隔离装置内网口 LISTEN_ADDR (0.0.0.0, 8080) # 业务客户端连这个端口 MTU 1400 seq 0 def handle_conn(conn): global seq udp socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp.bind((0.0.0.0, 0)) try: while True: data conn.recv(65536) if not data: break for i in range(0, len(data), MTU): chunk data[i:i MTU] frame pack_frame(TYPE_DATA, seq, chunk) udp.sendto(frame, UDP_DST) seq (seq 1) 0xffffffff finally: conn.close() udp.close()逻辑说明conn.recv(65536)可能一次性收到多个业务写块但不多收网络包分片循环保证每帧 payload 不超过 1400。udp.bind((0.0.0.0, 0))让操作系统分配一个随机源端口避免多个前端代理进程冲突。参数要改的地方很明确UDP_DST指向隔离装置内网口对应地址而不是外网口MTU要根据实际链路调整专门走隧道时还要再降。这里的seq是进程全局的实际多会话时应该把它挪到每个连接的字典里避免并发写同一个变量。注意这里没有 ACK 等待跑通正向单向转发没问题但业务 TCP 的返回数据和握手回包都还没有解决。别急着拿去现场下一章补上反向通道。3.4 UDP 后端代理解封装、组 TCP、回放给业务服务后端代理要做的事正好反过来绑定 UDP 9000收到帧以后校验 magic、拆字段把 payload 通过一条 TCP 连接发给业务服务。第一版本我用一条固定 TCP 连接演示足够你看清解封装逻辑。# udp_back.py import socket from frame import FRAME_MAGIC, TYPE_DATA UDP_BIND (0.0.0.0, 9000) TARGET_ADDR (192.168.30.12, 8000) # 真实业务服务 def unpack_frame(frame): if frame[:3] ! FRAME_MAGIC: return None f_type frame[4] seq int.from_bytes(frame[5:9], big) payload_len int.from_bytes(frame[9:11], big) payload frame[11:11 payload_len] return f_type, seq, payload udp socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp.bind(UDP_BIND) tcp socket.create_connection(TARGET_ADDR) while True: frame, addr udp.recvfrom(2048) msg unpack_frame(frame) if msg and msg[0] TYPE_DATA: tcp.sendall(msg[2])这里的frame[:3] ! FRAME_MAGIC会丢掉所有杂散包int.from_bytes(frame[5:9], big)和struct.unpack(!I)等价纯标准库实现。后端代理收到数据后直接sendall给业务服务顺序就是网络包到达顺序。因为当前没有 ACK 和乱序重排环境良好时没问题有丢包时业务层会发现数据断了这也正好验证了后面补 ACK 的价值。3.5 调试顺序先 UDP 后 TCP穿透链路最好分三步调不要一上来就 curl。先用 UDP 网络调试助手或者nc -u确认从安全区 I 主机发 UDP 9000 到后端代理能收到再启动后端代理用本地 TCP 工具测试后端代理到业务服务是否通最后才把前端代理拉起来做端到端测试。每一步失败时先看防火墙和隔离装置放行规则再看帧格式。# 安全区 II 后端主机上验证 UDP 9000 能收包 nc -lu 9000 # 安全区 I 前端主机上发一帧测试数据 echo hello | nc -u 192.168.10.1 9000第一次跑通的时候后端nc应该能看到一串不可读的帧头字节这就说明 UDP 转发路径已经通。接下来再把nc换成后端代理进程逐步接近完整链路。如果这里看到的不是乱码而是明文 hello说明你发的不是封装帧前端代理还没接上。4. 反向回执实现确认帧、序列号与超时重传光有正向封装TCP 业务依然跑不起来。你发过去的业务请求到达服务端服务端想回响应但正向隔离装置不提供返回路径。反向回执通道要解决的不是“把响应原路塞回去”而是让前端代理能够确认每一帧 UDP 都到达对端从而在本地维护一个看起来正常的 TCP 连接。4.1 为什么反向隔离路径不能直接回业务数据正向链路建立 UDP 通道后后端代理作为 TCP 客户端连接业务服务时SYN/SYN-ACK 本身已经发生在安全区 II 内部没有问题。问题在业务客户端与前端代理之间的 TCP 连接客户端发完请求后服务端的响应字节流要通过什么路径回到前端代理回不去。于是前端代理必须在收到客户端请求后像真正的 TCP 对端一样替服务端构造响应。这听起来像“伪造 TCP 响应”但实际上在穿透场景里前端代理是整个转发链路的一端它维护的只是一个到业务客户端的 TCP 连接真正的业务响应由后端代理通过反向 UDP 通道传回来。复杂点在于反向装置只允许特定格式、带确认的报文不能直接塞 TCP payload。因此我先实现最小可靠的确认帧后端代理收到正向数据帧后通过反向隔离装置回一个 ACK前端代理收到 ACK 才继续发下一片。这样至少保证了“数据真的进了后端代理”而不是被装置丢掉。至于完整的业务响应数据可以在 ACK 机制稳定后再扩展一个TYPE_DATA_REPLY走同样的反向 UDP 通道回来但需要按反向装置要求封装成它认可的应用层格式。demo 里保留了扩展位不把代码写死。4.2 ACK 帧设计ACK 帧复用前面的帧头type 设成 0x02payload 存“被确认的 seq”的 4 字节大端整数。为什么不直接让 ACK 帧的 seq 等于数据帧 seq因为 ACK 帧本身不参与序号分配这样前端代理收到 ACK 后只管读 payload 里的数字逻辑更简单。# frame.py 增加 ACK 相关函数 def pack_ack(ack_seq): # ACK 帧的 seq 固定为 0真正确认的序号放在 payload 里 return pack_frame(TYPE_ACK, 0, struct.pack(!I, ack_seq)) def parse_ack(frame): msg unpack_frame(frame) if msg and msg[0] TYPE_ACK: return struct.unpack(!I, msg[2])[0] return Nonepack_ack的 payload 固定 4 字节所以整帧长度 15 字节非常短。反向隔离装置做过滤时可以按 type0x02 和 payload 长度两个条件识别 ACK 帧。如果你现场装置要求特别的魔数改FRAME_MAGIC即可但要保证正反方向一致。4.3 前端等待 ACK 与超时重传前端代理每发一帧都要等一帧对应的 ACK。等不到就重发连续重发失败就把这个 TCP 连接标记为异常关闭连接。逻辑用send_with_ack封装起来主循环就简洁了。# tcp_front.py 增加 ACK 等待逻辑 import socket from frame import TYPE_DATA, pack_ack, pack_frame, parse_ack REVERSE_ADDR (192.168.40.1, 9001) # 反向隔离装置外网口 MAX_RETRY 3 ACK_TIMEOUT 1.0 def wait_ack(seq, timeoutACK_TIMEOUT): rv socket.socket(socket.AF_INET, socket.SOCK_DGRAM) rv.bind((0.0.0.0, 9001)) rv.settimeout(timeout) try: while True: frame, _ rv.recvfrom(2048) if parse_ack(frame) seq: return True except socket.timeout: return False def send_with_ack(udp, frame, seq): for _ in range(MAX_RETRY): udp.sendto(frame, UDP_DST) if wait_ack(seq): return True return False这里MAX_RETRY3和ACK_TIMEOUT1.0是 demo 参数。如果现场链路时延高比如跨省专线可以把ACK_TIMEOUT调到 2 到 3 秒如果隔离装置只是偶发抖动重传次数可以到 5 次。注意重传必须使用同一个 seq接收端靠 seq 去重否则同一片数据会被后端重复写进 TCP 流。4.4 最小发送循环完整正向加回执把上面的函数组合进第三章的handle_conn一个最小可用的正向链路就完整了def handle_conn(conn): udp socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp.bind((0.0.0.0, 0)) seq 0 try: while True: data conn.recv(65536) if not data: break for i in range(0, len(data), MTU): chunk data[i:i MTU] frame pack_frame(TYPE_DATA, seq, chunk) if not send_with_ack(udp, frame, seq): print(fseq {seq} 重传失败关闭连接) break seq (seq 1) 0xffffffff finally: conn.close() udp.close()这个循环每发一片就等 ACK吞吐确实不高但它能让你很直观地看到丢包、重传和隔离装置白名单问题。完整脚本里可以改成批量发 N 帧再等 ACK也就是滑动窗口但作为 demo先把可靠性和流程验证明白更重要。5. 常见问题与避坑五条实测记录下面五条都是我在实验室和现场反复遇到过的每一条都按现象、原因、解决来写。芯片时序问题、隔离装置型号差异都可能触发这些坑但大部分都被归到同一个根因把隔离装置当成普通防火墙了。5.1 现象UDP 能收到封包业务 TCP 却一直 SYN_SENT现象是前端代理启动了UDP 也能从内网发到外网但业务客户端连上前端代理后TCP 连接一直卡在 SYN_SENT然后超时。原因是只做了正向封装没有处理反向 ACK 和业务响应。前端代理虽然收到了客户端的 TCP SYN但没有任何人替服务端回 SYN-ACK客户端的 TCP/IP 协议栈自然认为对端不存在。解决方法是先补反向 ACK 通道让前端代理完成本地 TCP 握手再考虑业务转发。你可以先用一个最简单的假 ACK 脚本验证链路方向确认 ACK 真的能从外网侧回到内网侧再接入业务数据。5.2 现象多客户端接入后后端代理把所有请求都打到一个服务的连接上现象是单客户端测试一切正常一旦两个客户端同时连前端代理后端业务服务收到的数据就串线A 请求的字节出现在 B 连接里。原因是后端代理只保留了一个固定 TCP 连接没有按会话映射。前端虽然为每个 TCP 连接创建了新线程但封装帧里没有携带会话 ID后端无法区分这些数据属于哪个客户端。解决方法是给帧头增加至少 4 字节的 session_id并在后端用一个字典维护 session_id 到 TCP socket 的映射。session_id 不要直接用客户端源端口因为反向隔离装置可能做 NAT源端口到对端可能变。我习惯用前端代理启动时的自增整数简单且不会重复。5.3 现象正向 UDP 通了反向 ACK 也通了但高负载丢包现象是低流量测试没问题用 iperf3 一打流丢包率直接到百分之几重传风暴把链路拖垮。原因是 MTU 或者隔离装置 UDP 转发性能上限。我见过 MTU 1500 的 UDP 帧经过某些装置后触发 IP 分片分片包在单向通道被丢弃的案例。另一个可能原因是装置把 UDP 9000 误当成数据平面限速没有按业务通道单独配置带宽。解决办法是把 MTU 调到 1400甚至 1280 再测一轮然后在装置两侧同时抓包看同一帧的 seq 是否完整。如果单帧 1400 字节仍丢就要改小分片长度同时联系装置厂商确认转发阈值。5.4 现象反向 ACK 总是超时重传后链路直接断现象是前端代理一直打印 ACK timeout重发几轮后连接被强制关闭。用 UDP 调试助手手动从外网侧发 ACK 却能收到。原因是反向隔离装置的报文过滤规则把 ACK 帧当作非法应用数据丢了。很多反向装置要求应用层协议里必须包含特定字段比如业务类型、数据长度、CRC而 demo 里只有 11 字节帧头加 4 字节序号不满足装置白名单。解决办法是抓反向装置两侧的包确认 ACK 帧是否到达反向装置外网口。如果到了外网口却没进内网口就是过滤规则问题需要按装置协议要求填充字段或者在装置配置里增加对应协议模板。5.5 现象链路空闲一段时间后第一包数据总是丢失现象是白天一直正常第二天早上第一条业务请求必丢一两个帧前端重传一次后恢复。链路越空闲恢复越慢。原因是隔离装置或者中间 NAT 设备的 UDP 会话老化长时间无流量后映射被回收。等业务数据来了设备需要重新建立会话第一包往往用于探测而被丢弃。解决办法是增加心跳帧。每 30 秒从前端代理发一个TYPE_HEARTBEAT后端收到后回一个 ACK。心跳既保活 UDP 会话也能提前发现半开链路。我连续三次没收到心跳 ACK 就重启代理这个习惯让夜间的“第一包丢失”问题基本消失。6. 进阶用 iperf3、tcpdump 和 netsh 验证穿透链路6.1 用 iperf3 测实际吞吐别只看 ping穿透链路不能只看 pingping 走的是 ICMP而你的业务走 UDP 封装加 TCP 回放两者路径和丢包表现差别很大。我一般会直接把 iperf3 当成业务客户端让它产生的 TCP 流量经过前端代理和后端代理再用 UDP 打流测链路极限。# 安全区 II 的业务服务地址上启动 iperf3 服务端 iperf3 -s -p 8001 # 安全区 I 的客户端用 iperf3 连接前端代理监听端口 iperf3 -c 192.168.1.11 -p 8080 -t 30 -O 5这里的-O 5表示每 5 秒输出一次中间结果便于观察吞吐波动。如果测试结果远低于业务要求优先把 MTU 从 1400 降到 1280 再跑一轮。丢包率超过 0.1% 就要回看装置配置不要盲目加大重传次数。6.2 tcpdump 抓包检查五元组与序号抓包是排除“隐藏转发节点”最直接的手段。前端侧和后端侧同时抓 UDP 9000 的包对比每一帧的 magic、seq 和源 IP。# 前端主机上抓离开发往正向装置的 UDP 包 sudo tcpdump -i eth0 -nn udp port 9000 -X -c 20 # 后端主机上抓收到的 UDP 包 sudo tcpdump -i eth1 -nn udp port 9000 -X -c 20正常情况后端收到的源 IP 应该是隔离装置外网口地址seq 连续递增。如果源 IP 变成别的地址或者 seq 跳变说明中间有 NAT 或者丢帧重传。反向 ACK 的抓包也同样处理把端口换成 9001 即可。6.3 一个让链路更稳的习惯加心跳与序号校验最后把一个验证习惯留给你在测试环境里先关掉 ACK用大文件传输压一次记录数据损坏位置再打开 ACK 和心跳重复同样操作对比两端日志。这个对比能让你直观理解可靠传输的意义也能验证装置白名单是否只认端口不认协议。之前一次上线我图省事把 ACK 关了结果现场丢了两小时数据才排查出来。从那以后我每次都会在脚本里把 ACK 和心跳默认开启跑完抓包记录再关。希望帮到你。本文还有配套的精品资源点击获取
返回列表