乐于分享
好东西不私藏

源码丢了,我在 EXE 里改了 3 个字节

源码丢了,我在 EXE 里改了 3 个字节

一次使用Hxd修改EXE安装包硬编码IP地址的实战经历,踩过八进制前导零的坑后,反向利用inet_addr的八进制解析规则完成精确替换

源码丢了,我在 EXE 里改了 3 个字节

场景:一个尘封多年的 EXE 安装包,源码早已遗失,但服务器迁移后 IP 地址必须修改。 

工具:Hxd(十六进制编辑器) 

战果:不仅成功完成了字节码级别的 IP 替换,还在踩坑之后反向利用了 inet_addr() 的八进制解析规则——把陷阱变成了解法


一、那个下午,客户说"必须得用"

事情是这样的——

客户有一套老旧的业务系统,运行了很多年。最近服务器要整体迁移,从旧的 10.30.127.132 迁移到新的 10.244.27.90。数据库迁完了、配置文件改了、防火墙规则也调好了,一切看起来都在掌控之中。

然后客户抛过来一个 EXE 安装包:"这个也要改。"

"源码呢?"

"早不记得放哪儿了,就剩这一个 EXE 了。"

这就有了今天这篇文章——在没有源码的情况下,如何通过 Hxd 十六进制编辑器直接修改二进制文件中的硬编码 IP 地址。


二、Hxd 上阵:十六进制里的"手术刀"

Hxd[1] 是一款轻量但功能强大的十六进制编辑器,免费、免安装,在逆向工程和二进制分析领域有着极佳的口碑。

拿到 EXE 后,操作思路很直接:

  1. 用 Hxd 打开 EXE,搜索源 IP 的十六进制表示
  2. 找到目标位置,替换为新 IP
  3. 保存,测试运行

搜索源 IP 10.30.127.132 的 ASCII 十六进制:

31 30 2E 33 30 2E 31 32 37 2E 31 33 32 1  0  .   3  0  .   1  2  7  .   1  3  2

Hxd 强大的搜索功能很快在文件中定位到了这个字符串——它被硬编码在程序的网络连接初始化逻辑里,可能是一个 connect() 调用、也可能是一个配置文件路径、也可能是一段内置的默认服务器地址。

不管哪种情况,替换它


三、位数不对:一个不起眼的大问题

目标 IP 是 10.244.27.90,十六进制表示为:

31 30 2E 32 34 34 2E 32 37 2E 39 30 1  0  .   2  4  4  .   2  7  .   9  0

问题来了——长度不一致

项目
IP 地址
字符数
源 IP
10.30.127.132
13
目标 IP
10.244.27.90
12

在二进制文件中,字符串通常以 00(NULL)结尾。如果直接替换,目标 IP 会比源 IP 少一个字节。这个多出来的字节如果处理不当——

  • 可能会破坏后续数据的对齐
  • 可能会留下一个残留的 00 导致字符串被截断
  • 或者更糟,程序在解析时读到预期之外的数据

直观的"聪明"做法:在 27 前面补一个 0,变成 027

10.244.027.90  ← 正好 13 个字符,长度完美对齐!

替换后的十六进制:

31 30 2E 32 34 34 2E 30 32 37 2E 39 30 1  0  .   2  4  4  .   0  2  7  .   9  0

看起来天衣无缝。保存,运行——炸了


四、八进制陷阱:前导零的隐秘杀机

程序启动后,连接的不是 10.244.27.90,而是一个完全陌生的地址。排查了一圈,最终把目光锁定在那个"聪明"的补零操作上。

027,在 C 语言里,是八进制数。

这不是玩笑。在 C 语言及大量派生语言中,以 0 开头的数字字面量会被解析为八进制

int a = 027;   // 八进制 27 = 十进制 23int b = 27;    // 十进制 27int c = 010;   // 八进制 10 = 十进制 8

而很多网络库——特别是基于 C 语言标准库 inet_addr() 或旧版 Windows API 的 inet_aton() 实现——在解析 IP 地址的每一段时,会用 strtol() 或类似的函数。这些函数默认遵循 C 语言的字面量规则

  • 以 0x 开头 → 十六进制
  • 以 0 开头 → 八进制
  • 其他 → 十进制

所以,当程序读到 027 时:

027(八进制) = 2×8¹ + 7×8⁰ = 16 + 7 = 23(十进制)

程序实际连接的地址变成了 10.244.23.90,而不是期望的 10.244.27.90

这个特性从 UNIX 诞生之初就存在了。Ken Thompson 和 Dennis Ritchie 在设计 C 语言时沿用了 B 语言的八进制约定,而这个约定又来自更早的 PDP-7 汇编习惯。半个世纪后,它仍然静静躺在每一行 inet_addr() 的源码里,等着给粗心的程序员上一课。


五、不只是 inet_addr:哪些场景会踩这个坑?

这个问题影响面比想象中广。任何调用以下函数的程序,传入带前导零的 IP 分段时,都可能触发八进制解析:

函数
来源
行为
inet_addr()
Windows Sockets / POSIX
解析点分十进制 IP,支持八进制
inet_aton()
POSIX
同上
inet_pton()
POSIX.1-2001
严格十进制,不会误解析
(但老代码不一定用它)
gethostbyname()
POSIX
如果传入的是 IP 字符串,内部调 inet_addr()
getaddrinfo()
POSIX.1-2001
现代 API,但某些旧实现仍有此行为

特别需要警惕的是 Windows 平台的老旧程序。Windows Sockets 的 inet_addr() 实现明确遵循 BSD 规范,支持八进制和十六进制表示法:

127.0.0.1       → 标准点分十进制0177.0.0.1      → 八进制,等同于 127.0.0.10x7F.0.0.1      → 十六进制,等同于 127.0.0.1

甚至支持混合表示法:0177.0.0.0x1 —— 每一段独立解析,各用各的进制。极度灵活,也极度危险。


六、反败为胜:既然你认八进制,那我就写八进制

当我把 027 会被解析为 23 这件事搞清楚之后,脑子里突然闪过一个念头——

既然 inet_addr() 认八进制,那我能不能反过来利用它?

我需要的是一个字符串,它写在二进制里恰好 13 个字符(和源 IP 等长),但被 inet_addr() 解析后,第三段的值是 27。

换句话说:找一个值 X,使得 X 的八进制写法写在 IP 字符串里,但解析出来的十进制值等于 27。

X(八进制) = 27(十进制)X = 3×8 + 3 = 24 + 3 = 27所以 X = 33(八进制) → 写作 "033"

验证一下:

inet_addr("10.244.033.90")// 第三段 "033" → 八进制解析 → 3×8 + 3 = 27 ✓// 结果:10.244.27.90 ✓

而且长度呢?

项目
字符串
字符数
源 IP
10.30.127.132
13
方案 IP
10.244.033.90
13

完全对齐!

这是一个巧妙的逆向思维——陷阱本身变成了解决方案。那个让你掉进去的坑,换个角度看,恰好是一把为你量身定做的钥匙。

最终替换

在 Hxd 中,将源 IP 的十六进制:

31 30 2E 33 30 2E 31 32 37 2E 31 33 32 1  0  .   3  0  .   1  2  7  .   1  3  2

替换为:

31 30 2E 32 34 34 2E 30 33 33 2E 39 30 1  0  .   2  4  4  .   0  3  3  .   9  0

保存、运行——通了。程序正确连接到了 10.244.27.90

那个下午最妙的一刻,不是问题被解决,而是意识到:让你栽跟头的东西,换个姿态就能为你所用。八进制没有好坏,全看你怎么用它。


七、换个思路:如果不用八进制还有哪些解法

虽然最终方案靠八进制反杀,但这个过程中也探索过其他可能的路径,作为知识储备也值得记录:

备选方案一:NULL 字节填充

如果源 IP 字符串后面紧跟着一个 00(NULL 终止符),而 00 后面还有一些填充字节,可以直接写入目标 IP,把多出的位置也填成 00

原始:10.30.127.132\0 [padding]替换:10.244.27.90\0\0 [padding]

但这条路的风险在于,不确定多写一个 NULL 是否会破坏后续数据结构。需要完整的二进制分析才能确认。

备选方案二:重新编译

用 inet_pton() 替代 inet_addr(),前者严格解析十进制,不会有八进制歧义。但这需要源码——在这件事里,这条路走不通。

为什么八进制方案是最优解

对比下来,033 方案有三个无可替代的优势:

  1. 字节完全对齐:13 个字符精确替换 13 个字符,零副作用
  2. 不依赖文件结构:不需要分析 NULL 之后有什么,不需要了解 PE 布局
  3. 符合程序原有行为inet_addr() 天生就认八进制,这不是 hack,这是它设计的一部分

八、经验总结

这次经历看似是一个小插曲,但背后折射出几个值得深思的问题:

1. 字节码修改永远优先考虑"安全替换",而非"直觉替换"

补 0 的思路在直觉上完全合理——位置刚好、长度一样、看起来就是一个无辜的占位符。但二进制文件没有"直觉",它只认字节,以及字节背后整个工具链的解析规则。任何看似无害的修改,都要从底层协议的视角审视一遍。

2. 陷阱和方案,有时是同一枚硬币的两面

027 是陷阱——它让 IP 解析错误,程序连不上服务器。033 是答案——它利用同样的八进制规则,把 27 正确地送进了 inet_addr() 的解析结果里。

同一个规则、同一个函数、同一个文件位置。唯一的区别是:你是在无意识地触发它,还是在有意识地驾驭它。

3. C 语言的八进制遗产比你想象的更"长寿"

0 开头的数字是八进制——这可能是 C 语言最古老的特性之一。但因为它深植于 POSIX 标准和无数网络库的实现中,直到今天仍在发挥作用。写代码时尽量避免前导零,读代码时务必意识到前导零的存在。

4. Hxd 是二进制分析的利器,但用它的人才是关键

Hxd 提供了精准的十六进制查看和编辑能力,但它不会告诉你 027 是八进制,也不会帮你算出 033(八进制) = 27(十进制)工具解决"怎么做",领域知识解决"为什么能这样做"。

5. 保留源码,保留源码,保留源码

重要的事情说三遍。这次能救回来纯属运气——IP 地址恰好是可读的 ASCII 字符串、恰好 inet_addr() 的八进制规则可以被反向利用。如果碰到的是编译进代码的混淆常量、或者加密过的配置段,结果可能完全不同。


九、写在最后

那台老服务器最终成功迁移了。客户那边一切正常,仿佛什么都没发生过。

但我们知道,在那个下午,一个 EXE 文件的某个偏移处,30 32 37 被改成了 30 33 33——三个字节之差,承载的却是对一个半世纪前诞生的八进制约定的理解、对 inet_addr() 解析规则的逆向思考,以及一次从"掉进坑里"到"踩着坑爬上去"的认知跃迁。

二进制不会骗人。规则也不会偏袒谁——它只是静静地在那里。你用错了,它是 bug;你用对了,它是 feature。

如果你也曾有过类似的经历——用十六进制编辑器改过 DLL、修过 PE 头、或者在二进制文件里留下过自己的"签名"——欢迎在评论区聊聊。


本文基于真实项目经历撰写,敏感信息已做脱敏处理。

引用链接

[1]Hxd: https://mh-nexus.de/en/hxd/