资讯动态

自研堡垒机底层协议开发:SSH消息解析与命令审计实战

发布时间:2026/10/22 10:45:16 来源:尧图企业网站定制
前阵子我们把自己内部那套运维入口系统从“跳板转发”改成了“协议级管控”等于把堡垒机底层协议开发这条链路从头走了一遍。堡垒机这个名字不新鲜市面上商用产品和开源跳板机都不少但很多人只见过网页管理界面真正决定一台堡垒机能不能扛住生产环境、能不能把每一条指令都审明白、能不能让录屏和回放不卡顿的恰恰是很少有人愿意碰的底层协议代码。今天这篇就围绕自己动手做堡垒机协议层的核心思路来聊适合正在搭内部运维平台、或者准备做安全管控系统的同学参考。1. 从“套壳跳板机”到“协议层自研”到底在补什么课1.1 表面需求是统一入口真正要解的是会话级管控大多数团队对堡垒机的第一诉求是“让所有人从一个入口登录服务器”。入口统一之后权限收口了操作记录也有了看起来万事大吉。但这只是最浅的一层需求。稍微往下挖一点就会发现真正的难点是会话级管控谁的账号在什么时间登录了哪台机器登录之后执行了什么命令回显内容是什么有没有触发高危操作传了哪些文件这些信息必须完整、准确、可追溯。要实现这种级别的管控光靠在外面套一层转发是不够的。普通的TCP转发只能看到“有人连过来了、有流量在跑”却不知道流量里面是ls还是rm是vi编辑器里的翻页还是故意的危险命令。想拿到命令级、回显级、甚至键盘输入级的内容就必须在SSH协议内部去做解析和介入。这就是“底层协议开发”和“套壳跳板机”之间最本质的差别。1.2 常规跳板方案的三个硬伤既然开头说了“套壳”不够那它到底差在哪我把接触过的几个典型问题归纳成三条硬伤。第一条只有转发没有内容。老式跳板机就是一个透明转发器用户连上来之后流量被原封不动导到目标机器。优点是不碰协议、稳定性高缺点是对流量内容完全无感。等风控部门找过来要“高危命令阻断记录”的时候拿不出一张能看的操作报表。第二条链路是明文拼装扛不住协议状态变化。有人尝试用两个子进程拼接一边接收用户连接另一边再发起目标连接然后把两边的标准输入输出用管道接起来。这种做法在只跑shell的时候还能凑合一旦涉及窗口变化、SCP断点续传、SFTP多通道、双因子认证交互管道拼接就出现各种莫名其妙的断连和花屏因为SSH是带状态的消息协议不是一条简单的字节管道。第三条没有会话上下文没法做基于会话的授权。举个例子一个运维工单只批了三十分钟三十分钟后必须强制踢掉会话或者某个目标主机只允许跑白名单命令其他一律拒绝。这种场景需要把“一次SSH会话”当作一个完整的业务对象来跟踪而套壳方案里根本没有“会话”这个概念只有一对一对的字节流。1.3 必须动到底层协议的三个典型场景什么样的业务需求摆出来基本就宣告了套壳方案的死刑我从实际项目里挑了三个最典型的。场景A高危命令自动阻断。用户在终端里输入了带rm -rf的指令堡垒机要立刻拦截不只是弹个提示而是不让这条命令真正落到目标机。这个需求看着简单真正做起来却发现用户可能是在vim里输入这段文字然后保存也可能是通过粘贴板粘进来的。单纯匹配“有没有rm字符串”会误杀到怀疑人生必须结合当前终端模式、输入来源、命令行的完整性来判断。场景B操作录像和回放要做到命令级检索。录屏不稀奇视频格式的录屏随处可做。但客户要求的是“按命令检索录像”也就是输入一个关键字能把所有执行过包含该关键字的命令的片段找出来。这就要求在协议层同步保存结构化的操作流水而不是只留一份视频。场景C对目标机进行命令白名单管控。只允许特定用户在那台机器上执行特定几条命令其他命令全部拒绝。exec通道下相对好处理因为命令是整串提交的shell通道下就麻烦了用户输入是逐字节到达的必须靠输入缓冲和语义判断才能还原出一条完整命令。这些需求无一例外都指向同一个答案得在协议层做开发。2. SSH会话全链路透视堡垒机看到的不是命令而是消息2.1 SSH不是一次连接而是一组消息的连续流做底层协议开发第一件事是扭转一个观念SSH不是一条“管道”而是一组消息的连续流。如果停留在管道思维后面所有功能都会做得束手束脚。SSH协议可以粗略分成三层。最底层是传输层负责两台机器之间建立加密通道协商算法、交换密钥上面是用户认证层负责验证用户身份再往上是连接层支持在一条加密连接里同时跑多个channel每个channel可以承载一次终端会话、一次端口转发、或者一次SFTP子系统。日常开发里我打交道最多的就是连接层里的channel和消息。生活里可以这样类比SSH连接好比一根电话线channel就是电话线上同时进行的多路通话。每路通话里说的话对应的是channel data消息每路通话的接通和挂断对应的是channel open和channel close。各路通话还不会互相串音是因为每条channel都有独立的窗口大小和编号。协议层开发需要处理的常见消息大致是下面这些消息方向消息类型作用客户端 - 服务端SSH_MSG_SERVICE_REQUEST请求开启服务比如ssh-userauth客户端 - 服务端SSH_MSG_USERAUTH_REQUEST提交认证信息密码、公钥、键盘交互等客户端 - 服务端SSH_MSG_CHANNEL_OPEN申请打开一个新的信道服务端 - 客户端SSH_MSG_CHANNEL_OPEN_CONFIRMATION回复信道已创建客户端 - 服务端SSH_MSG_CHANNEL_REQUEST信道上请求一个动作比如申请终端、执行命令双向SSH_MSG_CHANNEL_DATA信道上传输实际数据双向SSH_MSG_CHANNEL_WINDOW_ADJUST调整信道接收窗口防止数据淹没双向SSH_MSG_CHANNEL_EOF / CLOSE结束信道2.2 堡垒机的插入点在协议状态机里做“看得见的手”堡垒机在协议链路里的角色是中间人对内它要扮演SSH服务端接收用户的连接和认证对外它要扮演SSH客户端去向目标主机发起连接。用户看到的堡垒机IP其实是一个协议中转站。中转不是简单的字节搬运。真正的协议级堡垒机会在消息层做“看得见的手”用户发来CHANNEL_OPEN时堡垒机可以先不急着放行而是根据策略判断这次会话是否允许建立用户发来CHANNEL_REQUEST时如果请求的是打开shell或者执行命令堡垒机可以记录下来并决定是放行、拒绝还是追加提示用户发来CHANNEL_DATA时堡垒机可以把这源源不断的字节流输入一个解析器提取命令、拦截危险操作、同时落盘存证。从实现角度看堡垒机不再是A到B的管道而是从A到B再到C的一次协议状态同步。用户和目标机各自认为自己是在跟对方直接通信实际上它们都在跟堡垒机对话。这个位置非常微妙做得好了用户无感知审计完整做得不好用户的每一次按键都能感觉到延迟断线重连也很容易出错。2.3 命令审计的边界pty下的字节流陷阱命令审计是底层协议开发里最容易被低估的一块。很多第一次做这个事的人都会问不就是把用户输入包里的字符串抓出来看看有没有敏感命令吗真做了才知道这里全是坑。第一个坑是pty模式。用户在终端里操作时SSH客户端会先发送一个“申请伪终端”的请求告诉服务端“我要一个终端”。一旦pt y建立用户的所有按键、终端控制序列、粘贴内容都会走CHANNEL_DATA里的字节流。你在shell里输入cat /etc/passwd其实是先来了一个c、一个a、一个t然后回车。如果堡垒机只按“数据块”抓内容可能前一个包只有ca后一个包才有t和回车直接导致命令识别残缺。第二个坑是终端控制序列。用户按方向键、退格键、CtrlC、CtrlL都会产生特殊字节序列。正则表达式如果直接匹配用户的原始输入会把rm -rf拆得七零八落。正确做法是先做控制序列清洗再按“回车”为边界拼装命令行。第三个坑是exec与shell的差异。exec模式下用户提交的是“单条命令整体执行”比如SCP会在远端执行scp -t /tmp这样的内部命令这种时候命令是完整可见的。shell模式下则完全不一样用户是在一个持续会话里交互无法只靠一个包判断一条命令是否结束。所以协议层的审计模块必须维护每个会话的“输入行缓冲”结合是否申请了pty、是否处于shell模式、控制序列情况才能还原出真正的命令。这也是命令识别从“能用”走向“好用”的分水岭。3. 底层协议开发的技术选型我为什么最终选了这套组合3.1 语言选型并发模型决定开发效率堡垒机底层协议开发的语言选型本质上是在问一个问题你要用什么样的并发模型去处理成百上千条同时存在的SSH会话每条会话都是独立的它们之间的数据不能互相干扰且每条会话内部还包含两个方向的读写流。C/C性能当然没问题但手写状态机和内存管理会占掉大量开发时间尤其是团队规模和项目周期都有压力的时候。Python上手快却会在高并发连接时被GIL和内存占用拖住SSH本身是CPU密集加IO密集的混合体纯Python方案越到后期越吃力。Java可以只是协议开发里大量用到轻量级的并发单元偏向“每个会话一个协程”的模型更自然一些。最终我选了Go为主力语言。原因很直接goroutine天生适合“每会话一个协程”的模型channel语义和SSH的channel概念非常契合编辑器级别的协议库生态比较完整编译产物又是一个静态二进制部署到跳板机上省去一堆依赖管理问题。用Go写代理类服务代码量通常比C少一半以上并发行为又比Python可控得多。3.2 协议库与状态机别重复造轮子但要知道轮子在哪漏气真的从零实现SSH协议的所有细节是不推荐的。光是加密算法协商、消息填充、Hmac验证、密钥交换就能耗掉一个开发组数月时间而且极难保证与所有客户端版本兼容。当前的主流做法是找一个成熟的SSH协议库作为底座库负责加密、消息编解码、连接维持而业务层负责策略注入和数据处理。但这不意味着我们只是调几个API。协议层开发的核心工作是把库暴露的“原始消息事件”和业务层的“会话策略”粘起来。以Go生态为例底层的SSH库提供了ServerConfig和ClientConfig可以分别创建服务端和客户端角色库内部已经把连接层channel、窗口、数据包都处理好了但要想在中间插一段审计钩子就要自己动手组装一条“先入站认证、再出站连接、再桥接双方channel”的链路。用库还有一个容易被忽视的点要关注库对channel请求的处理粒度。有些库会把pty-req、shell、exec、subsystem这些请求封成高级API直接暴露一个“Session”给你用这种封装对写普通工具很方便但对写堡垒机反而碍事因为我们需要精确获取每个channel请求的类型、参数、返回状态。所以选型时我会偏向“越接近消息层越好”的库宁可多写一点类型判断也要保住对协议细节的控制力。3.3 模块划分连接接入、认证转发、指令提取、会话录像四个子系统架构上我把整个协议层拆成四个子系统它们各自独立、通过接口协作。这个划分经历了几轮重构才稳定下来最初把所有逻辑塞在一个大文件里结果连加一个日志字段都要小心翼翼。连接接入模块负责监听端口、限制并发连接数、管理连接生命周期。任何一个潜在恶意连接或异常断线都该在这一层被兜住而不是漏到下游。认证转发模块负责用户身份验证和目标主机连接的建立。它要区分三类凭据用户的堡垒机口令、用户的动态验证码/双因子信息、目标主机的托管凭据。很多初版实现都会在这里犯同一个错把目标主机的密码直接暴露给客户端正确做法是用户只需要认证堡垒机身份目标主机那一步由堡垒机用自己的托管凭据去建立。指令提取模块负责解析字节流还原命令输入并执行危险指令的匹配与阻断。它不直接碰网络收发而是消费上游送来的消息队列。会话录像模块负责把会话全过程的字节流、时间戳、终端尺寸变化、操作命令流水存储下来。它是审计体系里最吃存储的一块合理的落盘策略会在后文展开。子系统核心职责常见坑连接接入监听、限流、生命周期管理忘记做最大连接数限制认证转发用户认证、目标机凭据管理密钥暴露给客户端指令提取字节流解析、命令还原、策略匹配直接用正则抓原始包会话录像数据落盘、回放格式、检索索引终端尺寸变化导致画错位4. 落地实战手写一个最小SSH协议代理内核4.1 服务端骨架监听、握手与鉴权所有复杂功能都是从最小闭环开始的。我建议先实现一个“用户从堡垒机登录后能成功到达目标机并执行命令”的最小代理再逐步往上加审计、策略、录像。第一步是生成堡垒机自己的主机密钥对用于和用户建立SSH连接。密钥生成后配置到服务端结构中。代码骨架大致长这样// 实际工程中请替换为项目选用的SSH协议库 import ( sshlib net ) func handleConn(raw net.Conn, cfg *sshlib.ServerConfig) { // 接受SSH握手完成用户认证返回一个连接对象 conn, chans, reqs, err : sshlib.NewServerConn(raw, cfg) if err ! nil { log.Printf(握手失败: %v, err) raw.Close() return } // 服务端连接建立成功后进入请求分发 go sshlib.DiscardRequests(reqs) for newChannel : range chans { // 每个channel代表一次会话的申请 go handleChannel(newChannel) } }这段代码里ServerConfig里面要配置好认证回调函数当用户提交用户名密码时回调会被触发。这里就可以接上你自己的人员库、双因子校验、甚至是用户密码改为“一次性动态口令”的逻辑。4.2 认证接管从用户密码到目标机密钥的跨层传递认证是堡垒机存在感最强的一环也是协议层开发最先遇到完整业务逻辑的地方。用户在客户端执行ssh user堡垒机IP -p 2299输入密码后这个密码首先流到堡垒机的ServerConn认证回调里。校验通过后堡垒机再充当客户端向真正的目标机发起连接。很多人在这里踩了坑以为用户输入的密码可以直接“透传”给目标机。这样做的坏处非常明显一旦堡垒机侧的记录被翻看所有目标机的密码都会以明文或可逆密文的形式存在。更合理的做法是用户密码只用于登录堡垒机目标机连接用堡垒机托管的凭据去建立。用户的身份和可访问的目标机范围是两套数据在第一次握手时就彻底分开。代码上目标机连接的建立逻辑大致是func dialTarget(targetAddr string) (*sshlib.ClientConn, error) { clientCfg : sshlib.ClientConfig{ User: deploy, Auth: []sshlib.AuthMethod{sshlib.PublicKeys(loadManagedKey())}, HostKeyCallback: verifyHostKey, // 目标机指纹校验 } conn, err : sshlib.Dial(tcp, targetAddr, clientCfg) return conn, err }这段逻辑里最关键的是HostKeyCallback稍后我会单独讲为什么它是安全底线。4.3 信道转发与窗口处理认证通过后用户发来的CHANNEL_OPEN请求会到达堡垒机。堡垒机要做两件事一是接受用户侧的channel二是同时向目标机发一个CHANNEL_OPEN。两边都成功后就进入了数据转发状态。数据转发的核心是两个方向独立搬运数据但在SSH里搬运的是channel data消息不能简单用TCP的io.Copy就完事。SSH底层有窗口机制接收方会通告自己还能接收多少字节发送方只能在窗口范围内发数据防止接收方缓冲区被冲爆。代理层如果没处理好窗口最直接的表现是“终端操作卡顿、输入响应慢、滚动日志时掉帧”。一个实用的做法是给每条channel配置独立的读写缓冲区并设定合理的窗口初始值。代码形态可以这样简写func bridgeChannels(userCh, targetCh sshlib.Channel) { done : make(chan struct{}, 2) go func() { io.Copy(userCh, targetCh) // 目标返回给用户 userCh.CloseWrite() done - struct{}{} }() go func() { io.Copy(targetCh, userCh) // 用户输入给目标 targetCh.CloseWrite() done - struct{}{} }() -done -done }这段代码还没有审计钩子但已经能跑通基础转发。生产环境里我会在io.Copy的位置换成自定义的copyWithAudit函数在中间插入字节流的旁路处理。4.4 审计钩子的正确挂点在数据通路上拦而不是在外面数包审计钩子最容易犯的错误是把日志放在“连接结束之后”才统一分析。有的初版实现是转发结束后再从临时文件里读回数据做命令提取。这种方案只能做事后审计根本做不了实时阻断。正确做法是把钩子挂在数据通路的时间线上。具体来说在用户数据包到达目标机之前、目标机回显数据包返回用户之前各放一个处理点。用户输入经过“输入审计钩子”这里做命令还原、危险指令匹配匹配命中时直接切断channel或向用户写入一段红色警告再向目标机发送一条合法的exit命令。目标回显经过“输出审计钩子”主要做日志落盘和敏感信息脱敏比如登录回显里偶尔会带出数据库密码。代码上的插入点实际上就是在每次CHANNEL_DATA消息被解包之后、调用目标channel的send之前。这个位置是协议层开发里最有价值的单点它同时决定了命令审计、高危阻断、录像回放的质量。5. 上线前必须踩平的那些坑兼容性、并发与录像5.1 终端尺寸漂移录屏错位的真实元凶底层代理跑起来之后最早暴露的问题之一就是录屏错位。用户明明把窗口拉宽了一倍录像却不跟随变化回放时左边大半块屏幕被截掉命令换行也全乱了。查了半天根因在协议消息上。用户调整本地终端窗口大小时SSH客户端会向服务端发送一个CHANNEL_REQUEST请求类型是window-change附带行数和列数。如果一个纯透明代理只做数据转发不把这个请求转发给目标机目标机上的shell其实感知不到终端尺寸变化依然按老尺寸排版输出。到了录像回放阶段因为录像上下文里记录的尺寸和真实窗口不一致画面就错位了。修复方案并不复杂在通道请求这一层做一次“显式放行”识别到window-change之后更新会话上下文里终端宽高再把这个请求原样转发给目标channel。同理pty-req里带有的初始尺寸也要被解析和存储作为录像文件的初始画面参数。5.2 高并发下的背压与连接池参数堡垒机上线的第一个大促日我盯着监控面板看并发会话数一路爬升。等会话数量超过一个阈值之后新连接开始频繁握手失败内存曲线也出现了明显台阶。问题出在两方面。一方面是最大会话数没有限制。每个SSH会话都对应着若干goroutine和缓冲区无上限地接受连接最终一定会把内存打爆。后来我在连接接入模块加了全局信号量超过设定的最大会话数就直接拒绝新连接并给出友好提示。这个阈值要根据机器内存和单会话占用反复压测我们的经验值是普通配置条件下单机压到几百个并发在线会话就差不多了。另一方面是缓冲区参数的设置。默认的读写缓冲区应对普通命令还好可一旦用户执行tail -f或批量任务回显数据量会瞬间打满缓冲区。这时候如果目标侧的输出速度大于用户侧的TCP消费速度背压会一路传导到SSH窗口调整表现就是回显延迟。我们最后把回显方向的数据缓冲区调大并给数据传输方向加了单独的流量控制参数。5.3 目标机指纹校验和“中间人”那个坑这是整个底层协议开发里我几乎用最高优先级对待的一个问题。堡垒机向目标机建立连接时如果不对目标机的主机密钥做校验就等于打开了中间人攻击的大门谁都能伪造一台同名目标机坐在堡垒机前面接收所有运维操作和敏感命令。解决方案是目标机指纹的完整生命周期管理。第一次连接目标机时记录主机公钥指纹并让管理员人工确认之后的每一次连接都严格比对指纹一旦发现变化立即中止连接并告警。这里要提醒一点不能只校验IP或者主机名因为IP可以被抢占主机名可以被DNS劫持。只有公钥指纹才是目标机身份的唯一可靠凭据。Go的SSH库原生支持配置HostKeyCallback在实际项目里我把这个回调映射到一个内部API连接前查询“这台目标机当前登记的指纹是什么”连接时比对“收到的指纹是什么”不匹配直接返回错误。这一层判断放在协议握手阶段成本极低收益极高。5.4 子系统兼容SFTP/SCP 直接被通道“吃掉”SSH代理上线初期我们接到最多的工单是“SFTP连接非常慢”和“SCP传大文件老断”。当时很困惑因为shell连接完全正常。排查之后发现问题出在子系统请求上。SFTP并不是在普通shell通道里执行命令而是在SSH连接层申请一个subsystemchannelchannel请求里携带sftp字样。初版实现只处理了shell和exec遇到subsystem请求时直接忽略或者错误处理导致客户端能握手成功但子通道迟迟建立不起来表现就是反复超时、连接假死。SCP则又有不同它底层实际是一个exec scp -t 目标路径的命令走的是exec通道但在传输过程中会混合文本协议和二进制数据如果代理层把带有二进制的数据当纯文本解析就会把文件内容冲坏。处理办法是在通道请求分发的地方把请求类型完整分类shell、exec、subsystem、pty-req、window-change、env全部按各自语义处理。对于SFTP可以选择两条路要么直接放行不做内容审计要么走独立的文件传输审计模块记录文件名、大小、方向等元数据。两条路各有取舍直接放行性能好但审计粗独立处理审计细但开发成本高。我建议第一阶段先放行并记录元数据跑稳定了再考虑文件级深度扫描。如果让我重来一遍我会先跑通最小SSH代理再考虑RDP先把shell和exec的审计做扎实再扩展SFTP录像不能只想着一帧一帧存屏记录字节流和时间戳才是性价比最高的方案。底层协议开发最大的特点就是“开弓没有回头箭”消息解析的粒度定粗了后面每个功能都跟着受影响定细了前期工作量翻倍。多做增量少做宏大的第一版这是我在这个项目上最有体感的一条经验。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑