上周接到一个内部授权评估任务目标是一个用 Go 写的气象数据管理后端。对方安全负责人给的描述很简单登录接口似乎有点不对劲但代码仓库已经失联只留了一个 linux/amd64 的可执行文件。我的第一反应是又要从头逆一个 Go 程序了。但随后我就发现这个二进制不仅带着完整的 Go 运行时指纹连符号都没删干净JWT 签发的密钥甚至就躺在只读数据段里。从 Go 二进制到登录绕过整个链路用了一个工作日就打通了。这篇文章就是这次实战的完整复盘适合正在学二进制逆向的测试人员也适合 Go 服务端开发者换个视角看看自家认证代码的防御短板。1. 从拿到二进制开始确认 Go 身份与整体气质1.1 file、strings 先“看脸”拿到文件的第一步永远是file这一条命令能帮你省掉后面大量的瞎猜时间。当时我这边跑出来的结果是$ file weather-backend weather-backend: ELF 64-bit LSB executable, x86-64, dynamically linked, for GNU/Linux 2.6.32, Go BuildID...看到Go BuildID这几个字其实已经不用再纠结了。这是一个标准的 Go 编译产物而且大概率是静态链接因为 Go 运行时本身就是一套独立的用户态调度体系除非你用 cgo 调了 C 库否则不会出现 libc 的依赖。紧接着用strings刷一遍基本就能判断这个二进制有没有做过处理。我习惯用strings -n 4这种带最小长度的方式把太短的无意义符号过滤掉然后grep几个关键词$ strings -n 4 weather-backend | grep -iE go1\.|runtime\.|main\. | head -30 go1.18.3 runtime.main runtime.morestack fmt.Fprintf net/http.(*Server).ServeHTTP github.com/golang-jwt/jwt/v4到这里就能确认两件事第一编译用的是 Go 1.18.3第二代码里引用了第三方 JWT 库。也就是说登录认证有很大概率走的就是标准 JWT 流程。这两条信息直接决定了后续逆向的进攻路线不是去抠密码哈希算法而是优先看 token 的签名密钥和 claims 校验逻辑。1.2 Go 二进制的三张身份证接触过逆向的朋友都知道C/C 编译出来的二进制就算带符号函数表通常也就帮你定位个大概。而 Go 二进制有三样东西是编译器默认就给出来的想藏都不容易首先是.gopclntab里面存了从程序计数器到函数名的完整映射Ghidra 和 IDA 都能靠它恢复出大量符号。其次是字符串表Go 编译器会把包路径、类型名、结构体字段名都写进去比如之前 grep 出来的github.com/golang-jwt/jwt/v4这条路径本身就暴露了依赖版本。第三是go version字符串它不只在 ELF 头里在二进制数据段里也有多处备份。反过来讲如果你拿到的是一个被 strip 过的 Go 二进制也先别慌。.gopclntab并不会因为 strip 就消失它在链接阶段就已经是二进制的一部分了用开源工具可以重新提取出符号信息。我后面的章节会专门讲这个。1.3 工具链选择为什么用 Ghidra 而不是 IDA修复完基本信息之后我直接在 Ghidra 里新建项目分析。不是因为它比 IDA 强而是因为在处理 Go 二进制这件事上Ghidra 的开源生态有天然优势。IDA 也有自带的 Go 支持但版本较老的 IDA Pro 对 Go 1.10 以上的 pclntab 解析经常出问题需要装第三方插件才能正确处理 goroutine 和接口类型。Ghidra 则有一批专门的 Go 辅助脚本比如重命名符号、恢复函数签名、识别go:buildid等虽然在分析速度上慢一些但结果反而更齐整。我实际用下来的体验是Ghidra 打开这个 40MB 左右的二进制初始分析大概花了几分钟函数列表里已经能直接看到main_loginHandler、main_authMiddleware这种名字了。如果你后续还要做补丁对比、fuzz 输入构造之类的活儿Ghidra 的 Python 脚本接口也会让你省很多事。2. 信息收集用字符串把服务“听写”出来2.1 字符串表里最先出场的几位有了符号表其实就已经赢了一半。但字符串依然是最快建立全局认知的手段。我当时把strings结果重定向到文件然后分几轮筛选。第一轮筛路由和接口关键词是api、admin、login、users很快就看到了这几条/api/admin/login /api/admin/users /api/v1/monitor /api/admin/settings这几条路由直接勾勒出了一个后台管理服务的轮廓。登录接口、用户列表、监控、设置这就把资产面盘出来了。第二轮筛认证相关关键词是token、auth、jwt、secret、password。结果里出现了一个看起来很像密钥的字符串jx-k8s-api-gw-secret-2023。还有签发者标识internal-sso。你可能觉得一个生产二进制的密钥这么直白地躺在字符串表里太离谱了。但现实就是如此很多内部服务的开发为了方便调试会把密钥写死在代码里编译后直接进只读数据段。字符串表等于把家门钥匙放在门口脚垫下面。2.2 从路由字符串反推 Handler字符串表只告诉你有什么路径但真正处理这段逻辑的函数是谁还得靠交叉引用。在 Ghidra 里对字符串/api/admin/login右键 Find References就能看到它被引用的地方。我看到的引用关系大致是这样一个main_newRouter类型的函数里调用了mux.HandleFunc并且把路径和 handler 关联起来。顺着这个方向追很快就定位到了main_loginHandler这个函数。这步看起来简单但在实际分析中很容易踩坑Go 的接口和方法调用比较绕反编译结果里会出现大量itab、*os.File之类的间接跳转。如果你只在 CPU 指令层找地址引用很可能会被带偏。正确做法是先在符号层级建立“路由字符串 - 处理函数”的映射再进入函数内部看伪代码。2.3 先判断认证架构再决定进攻方向看到github.com/golang-jwt/jwt/v4之后我其实已经可以推断整个认证流程长什么样了。大概率是登录接口拿到用户名密码校验成功后签发一个 JWT后续请求带着Authorization: Bearer token经过中间件中间件验签并解析 claims然后根据 claims 里的角色字段决定能不能访问。这种架构下登录绕过的切入点通常是这几个密钥是否硬编码、签名算法是否可被降级、claims 里有没有不校验时效的漏洞、以及角色字段是不是直接信任前端传过来的值。我这次的目标很清晰只要拿到签名密钥我就可以自己伪造任意角色、任意用户的 token。所以接下来的重点就是把这个“密钥从哪里来、怎么被签进去”的代码路径看清楚。3. 核心逻辑还原登录校验函数是如何工作的3.1 恢复 Go 函数符号的两种方法进了 Ghidra 后我发现符号表虽然还在但函数名是残缺的很多地方显示成了func_xxx。这是因为 Ghidra 默认的 Go 符号恢复依赖 pclntab 解析遇到某些结构体方法时会失败。这时可以用两个手段补救。第一个是命令行工具$ go tool nm weather-backend | grep -i main_loginHandler e.g. 1234567 t main_loginHandlergo tool nm能直接从二进制里读出 pclntab 中的符号和地址。即便二进制被 strip只要.gopclntab还在这个命令通常就不会罢工。拿到符号地址后在 Ghidra 里按地址跳转手动进行命名。第二个手段是 Ghidra 插件社区里有专门处理 Go 二进制的脚本能批量把func_xxx重命名为 Go 源码里的真实名称。我这次直接用了插件省了很多手动的功夫。装好插件后重新分析函数列表立刻变得亲切起来main_loginHandler、main_issueToken、main_authMiddleware全部归位。3.2 用伪代码讲清密钥逻辑拿到重命名后的函数main_loginHandler的反编译结果已经能看出逻辑骨架。我结合常见实践把它还原成近似 Go 源码的样子func main_loginHandler(w http.ResponseWriter, r *http.Request) { var req struct { Username string json:username Password string json:password } json.NewDecoder(r.Body).Decode(req) user, err : main_fetchUserFromDB(req.Username) if err ! nil || user.PasswordHash ! main_hashPassword(req.Password, user.Salt) { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte({error:invalid username or password})) return } token, _ : main_issueToken(user) w.Write([]byte({token: token })) }别看反编译界面里是一堆runtime.convT64、encoding/json.*decodeState的调用把那些运行时噪音去掉之后核心逻辑就是这个样子先查库再比哈希最后签发 token。真正关键的是main_issueToken。这个函数里会出现这样的关键调用序列jwt.NewWithClaims、jwt.SigningMethodHS256、token.SignedString。SignedString的参数就是从全局变量里取出来的字节数组而这个全局变量的内容正是我们在字符串表里看到的jx-k8s-api-gw-secret-2023。Ghidra 里对这个全局变量的引用是data_...但对照字符串交叉引用可以确认它就是那个硬编码密钥。3.3 追到 JWT 签发函数那一行main_issueToken反编译出来的逻辑里除了密钥还有几个值得注意的字符串常量。一个是internal-sso另一个是24h。前者作为iss写入 claims后者说明 exp 的有效期是 24 小时。此外还看到sub、username、role这几个 claim 的赋值调用。到这里整个认证体系的关键布局已经摸清了签名算法HS256对称密钥密钥来源二进制内的硬编码字符串签发 claim 结构sub、username、role、iss、iat、exp认证中间件从 Authorization 头取 Bearer token解析验证签名也就是说只要我能伪造一个签名正确的 token登录接口的用户名密码校验就可以完全跳过。下一步就是把这个想法落地成 PoC。4. 绕过落地伪造 Admin Token 的完整流程4.1 漏洞属性判定这算密钥泄露还是算法缺陷动手写 PoC 之前我要先给这个漏洞定性。它能被利用的直接原因是密钥泄露而不是算法本身有数学上的弱点。HS256 是个对称签名算法验证方和签发方共用同一个密钥。一旦密钥出现在攻击者手里攻击者就可以自己签名任意内容。换个场景说如果服务端用的是 RS256非对称签名即使攻击者拿到公钥也没法伪造 token因为私钥不在二进制里。所以这类漏洞的修复思路不是换 HS256 为 RS256 这么简单而是要从密钥管理源头入手。这一点我放到最后一部分细说。但也别把话说死很多 Go 服务还会犯另一个错在验证时直接调用jwt.Parse而不是jwt.ParseWithClaims并且没有限制签名算法。攻击者可以提交一个alg: none的无签名 token这在某些老版本库中就会直接通过。我这次遇到的场景没有这个低级问题但排查时务必也看一眼。4.2 离线伪造 JWT 的 Python 脚本既然拿到了密钥和 claims 结构伪造 token 就变成一件纯粹的体力活。我用 Python 的 PyJWT 写了个离线生成脚本import jwt import time SECRET bjx-k8s-api-gw-secret-2023 now int(time.time()) claims { sub: 1, username: admin, role: admin, iss: internal-sso, iat: now, exp: now 3600, # 给 1 小时实际有效期是 24 小时完全够用 } token jwt.encode(claims, SECRET, algorithmHS256) print(token)这里有个细节jwt.encode会默认生成exp检查规则但我们需要显式传iat和exp。之前逆向时看到服务端会校验exp如果 token 没有 exp 字段认证中间件大概率会直接拒绝。所以伪造时要把时间戳字段补齐并且让 exp 落在当前时间之后。另外claims 里的role字段必须和后台鉴权接口对得上。我在二进制里看到管理后台的权限判断是读role admin所以直接注入 admin 即可。如果你逆到的是is_admin布尔字段就按实际的名称来。4.3 在线验证与参数微调脚本跑出来的 token 长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiIsImlzcyI6ImludGVybmFsLXNzbyIsImlhdCI6MTcxMDAwMDAwMCwiZXhwIjoxNzEwMDAzNjAwfQ.xxxxxxxx我用 curl 做了在线验证TOKEN$(python3 forge_jwt.py) curl -s -o /dev/null -w %{http_code}\n \ http://127.0.0.1:8080/api/admin/users \ -H Authorization: Bearer $TOKEN第一次请求返回 403。我以为是 role 字段类型不对于是又去翻认证中间件的伪代码发现里面的一个字符串比较操作是claims[username].(string) admin而我在伪造时把 username 也写成了 admin但用户 ID 对应的真实管理员账号不一定是 admin可能是admin_ops之类。问题出在服务端虽然不查库但会额外校验 username 是否存在于预置的管理员名单里。修正 claims 里的username和sub之后再次请求返回 200响应体里直接列出了所有管理后台用户的列表。登录接口就这样被绕了过去。整个过程中还有一个值得注意的细节这个后端部署在内网登录接口本身没有限流也没有任何设备指纹绑定。也就是说光是拿到密钥这一件事就足以让攻击者在离线状态下批量伪造任意身份而不触发任何登录失败日志。5. 实操中的那些坑逆向 Go 二进制问题排查5.1 Ghidra 对 Go 函数的“脸盲”第一坑Ghidra 默认分析会把很多 Go runtime 函数标注成不可读或无返回。这是因为 Go 的栈增长逻辑runtime.morestack会干扰 Ghidra 的反编译流程导致后续调用分析出错。解决办法是在 Ghidra 里把runtime.morestack和runtime.morestack_noctxt设置为无返回函数。右键函数 - Function Attributes勾上No Return然后重新分析。这个操作能明显减少反编译中出现的胡言乱语比如凭空多出来的$LN4标签和不可达分支。另一个常见问题是Go 1.18 之后编译器默认开启内联很多小函数会被直接展开到调用点你在反编译里看到的大块逻辑其实是几个函数拼在一起的。分析时不要纠结于还原到源码的每一行重点是抓住数据的流向请求体 - 解码 - 比对 - 签发。5.2 丢失符号时的自救手段如果交给你的二进制做过 strip 或者 UPX 压缩连go tool nm都读不出符号也别直接放弃。首先是 UPX 脱壳用 UPX 自带参数-d即可如果 UPX 版本不匹配用upx -d -v加上强制参数试。其次开源工具 redress 可以从 Go pclntab 里恢复函数名和文件名信息即使 ELF 符号表被删干净。我见过的很多 Go 二进制只是普通 strip.gopclntab完整保留redress 一把就能拉回来。最后实在不行还可以靠字符串引用来标注函数作用比如谁引用了/api/admin/login谁就大概率是登录 handler。5.3 常见问题速查表现象可能原因解决思路反编译出现大量func_xxxGhidra 未正确解析 pclntab跑 Go 符号恢复插件或用go tool nm手动补名JWT 伪造后被拒返回 401签名密钥不对或 claim 字段名不一致回到二进制确认密钥字符串和结构体字段名返回 403 而非 401认证通过但鉴权失败检查 role 字段是否匹配管理员判定逻辑字符串表有中文乱码源码编码是 GBK或者你看的方式不对用iconv -f GBK转换来辅助阅读函数列表有大量runtime.Go 运行时本身包含大量线程调度代码过滤符号只关注main.前缀和第三方库函数token 的 exp 字段缺失导致拒收伪造成员 seal 没加 expJWT 库默认不强制校验但业务代码里往往手动检查curl 测试时端口路径不确定二进制没直接暴露监听信息搜strings中的端口数字、反向代理配置特征词6. 加固与复盘从攻击视角看如何防住这类绕过6.1 密钥管理层面的修复这次绕过的根因就一个HS256 的对称密钥被编译进了二进制。那不是开发者的错吗这话不准确。准确说是把“密钥”和“代码”耦合在一起这件事错了。修复方案也很明确。第一密钥要从配置中心或环境变量读取编译期不写入二进制第二密钥要支持轮换最好有版本号透传一个 key id校验时按版本选择密钥第三如果必须用对称算法那至少要做到一个服务一个密钥不要搞一个全局密钥通吃所有环境。另外日志里也不要打印 token 或者密钥这次评估时我发现登录接口的 debug 日志会打印请求头一旦线上环境开了 debug即使没拿到二进制密钥也可能从日志里漏出去。6.2 验证逻辑层面的修复再看服务端验证代码。很多 JWT 漏洞不是因为算法不够强而是因为验证方太“宽容”。务必使用ParseWithClaims而不是Parse并且显式指定期望的签名算法token, err : jwt.ParseWithClaims(tokenString, MyClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok : token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf(unexpected signing method: %v, token.Header[alg]) } return []byte(cfg.JWTSecret), nil })这段代码里最关键的是检查token.Method是不是 HMAC 类型的签名方法。如果不做这个检查攻击者可以直接传alg: none库就会认为不需要签名直接通过。同时claims 里的 exp、nbf、iss 都要校验不能只信任role字段。管理员的判断最好是查询固定的权限表而不是从客户端传来的 claims 里直接读布尔值。我的实际建议是角色信息不要放进 token 作为唯一判断依据至少要做一层服务端映射。6.3 二进制加固与评估频次最后聊点对抗层面的策略。有人会问既然符号表、字符串这些信息泄露这么多那编译时加-ldflags-s -w是不是就能挡住攻击者我的回答是能抬高门槛但挡不住有耐心的人。Go 的 pclntab 是运行时机制的一部分删掉符号表它还在字符串表在.rodata里几乎不可能不留痕迹。你真的想藏密钥不被人发现唯一靠谱的办法是别把密钥放在二进制里而不是依赖编译器去帮你删信息。发布前的安全评估也建议做成自动化。每次发版都把二进制的字符串表当成输入数据扫一遍凡是出现secret、password、token这类关键词并匹配到高熵字符串的直接拦截发版流程。我后来在自己负责的项目里就是这么做的一个正则加一个小脚本成本很低但能挡住大部分因为赶进度把密钥写死在代码里的情况。再回头看看这次评估起点只是一个普通的 Go 二进制兜兜转转到最后其实是输在了一个不起眼的字符串上。JWT 这个协议本身并没有被攻破被攻破的是密钥的存储方式。防御方觉得“反编译门槛高没人会去看”攻击方则只是老老实实跑了一遍 strings就拿到了通往后台的钥匙。相较于打造一座未来可能被绕过的高墙把地基里的秘密钥匙撤走才是真正省力且有效的做法。
阅读完成 · 觉得有帮助?