服务器没了之后:某ERP外挂工具的无源码逆向迁移实录
声明:本文记录的是对本单位自有、且被授权维护的遗留内部工具做数据抢救与技术迁移的过程。
目标软件为无任何授权校验/加密壳的普通 .NET 内部程序,本文不涉及也不适用于绕过版权保护措施。
文中所有可识别信息(软件名/厂商/IP/账套/凭据)均已脱敏。请勿将方法用于未授权场景。
0. 背景:一次"服务器没了"的事故
公司一套 2013 年定制的用友 U8 外挂排产工具(下称 P.exe),C/S 架构,客户端直连
SQL Server 上的 U8 账套库做排产。后来老服务器整机下线——IP 回收、机器重装,
什么都没留下。2026 年业务又要用这套工具,手头只有:
P.exe(.NET WinForms 主程序,524 KB)
- 三个 2009 年的 Office Interop DLL(导 Excel 用)
- 没有源码、没有安装包、没有数据库备份、没有任何文档
分析完结构就会发现:这程序是典型的两层直连架构(窗体拼 SQL → ADO.NET → SQL Server),
数据 100% 在服务端,客户端只是个壳。数据库不在,程序连登录窗口都过不去。
所以整个"破解"其实是两件事:
- 凭空重建数据库(表/视图/存储过程,逻辑一半埋在服务端);
- 让写死了旧服务器地址的 exe 指向新库。
1. 侦察:先搞清楚它是什么
$ file P.exe
P.exe: PE32 executable (GUI) Intel 80386 Mono/.Net assembly, for MS Windows
PE32 + .Net assembly → 32 位 .NET Framework WinForms 程序。版本资源里还躺着
2009 年的 Office PIA 引用和 2017 年的编译时间——一个不折不扣的活化石。
常规剧本是 dnSpy/ILSpy 一把梭。但我手头的环境装不上 .NET SDK 也开不了网,
于是有了本文唯一有点意思的部分:标准库 Python 手撕 .NET 元数据。
2. 无工具环境:纯 Python 解析 BSJB 元数据
.NET 程序集的元数据藏在 PE 可选头 DataDirectory[14] 指向的 CLR 头里,
元数据根以魔数 BSJB 开头,后面挂着五个堆/表流:
#~ 元数据表流(TypeDef/MethodDef/Field...)
#Strings ASCII 标识符堆(类型名/方法名)
#US 用户字符串堆(ldstr 的字面量:UI文案、SQL、连接串全在这)
#GUID / #Blob
对侦察最有价值的是 #US 堆——它不需要解析任何表结构,只要顺着
"压缩长度前缀 + UTF-16LE" 一条条走:
def parse(path):
data = open(path,'rb').read()
e_lfanew = struct.unpack_from('<I', data, 0x3C)[0]
opt = e_lfanew + 4 + 20 # COFF(20) 后是 OptionalHeader
cli_rva = struct.unpack_from('<I', data, opt+96+14*8)[0] # DataDirectory[14]
# ... 区表 RVA->文件偏移 换算(略) ...
md = rva2off(struct.unpack_from('<I', data, rva2off(cli_rva)+8)[0])
assert struct.unpack_from('<I', data, md)[0] == 0x424A5342 # 'BSJB'
ver_len = struct.unpack_from('<I', data, md+12)[0]
p = md + 16 + ver_len
flags, nstreams = struct.unpack_from('<HH', data, p); p += 4
streams = {}
for _ in range(nstreams): # 流头:偏移+大小+名字(4字节对齐)
off, size = struct.unpack_from('<II', data, p); p += 8
name = b''
while data[p] != 0: name += data[p:p+1]; p += 1
p += 1; p = (p + 3) & ~3
streams[name.decode()] = (md+off, size)
return data, streams
def walk_us(data, us_off, us_size): # #US 堆:压缩长度 + UTF-16LE
blob, i, out = data[us_off+1:us_off+us_size], 0, []
while i < len(blob):
b0 = blob[i]
if b0 & 0x80 == 0: ln, hl = b0, 1
elif b0 & 0xC0 == 0x80:
ln, hl = ((b0 & 0x3F) << 8) | blob[i+1], 2
else:
ln, hl = ((b0&0x1F)<<24)|(blob[i+1]<<16)|(blob[i+2]<<8)|blob[i+3], 4
i += hl
if ln > 0 and i + ln <= len(blob):
out.append(blob[i:i+ln-1].decode('utf-16-le')); i += ln
else: break
return out
一把捞出 700 多条字符串,程序的家底全亮了:
- 界面文案拼出业务全貌:预订单/排单/排产/模号/毛坯烤漆/成品烤漆/纸箱……
- 明晃晃的登录 SQL:
select ryid,rymc,rymm,tjqx,rysx from XX_PcCzy
where ryid='<输入的用户编码>' and rymm='<输入的密码>'
密码列能被 SELECT 出来在客户端做字符串等值比较——明文存储实锤。
- 以及本文的主角,硬编码连接串:
server=10.0.0.x;database=UFDATA_001_20xx;uid=sa;pwd=********;App=XXX
sa 直连 ERP 账套库,密码印在每个拿到 exe 的人面前。
想看类结构和调用关系,再解 #~ 表流即可:读 valid 位掩码得到每张表的行数,
按 ECMA-335 的行尺寸逐表推进。踩坑点:所有跨表索引(TypeDefOrRef、
ResolutionScope 这类 coded index)都是"候选表中最大行数 > 0xFFFF 才占 4 字节,
否则 2 字节"——算错一个宽度,后面整个表错位。本程序 28 个类型、359 个方法、
537 个字段,规模不大,2 字节索引通吃。
再进一步可以写个迷你 IL 遍历器:方法体 RVA → 读 fat/tiny 头 → 按操作码长度表
步进,只关心 ldstr(0x72)、call/callvirt(0x28/0x6F),把 token 解析回
MemberRef 名字,就能还原"每个方法拼了什么 SQL、调了谁"——后面重建存储过程
签名全靠它。
3. 让 exe 指向新库:等长十六进制补丁
顺藤摸瓜,连接串出自业务层的 ldstr 字面量——编译进了元数据,
丢一个 xxx.exe.config 过去是没用的,必须改二进制。
直接改?危险。#US 堆里每条字符串都有长度前缀,各条目背靠背排列,
后续所有 ldstr 的 token 都是堆内偏移。改动任何一条的长度,
后面几百条字符串全部错位,程序集当场报废。
唯一安全的姿势是等长替换:
- 原串 76 字符(UTF-16 共 152 字节),长度前缀
80 99(压缩算法:153 = 152+1);
- 新串不足 76 字符 → 在末尾
App= 的值后面补空格凑齐(连接串解析对
Application Name 的尾随空格不敏感,无害);
- 超过 76 字符 → 拒绝并提示:给新服务器在 hosts 里起个短别名,或缩短库名/密码
(新库的 sa 密码是自己设的,长度可凑)。
补丁核心逻辑:
ORIG = 'server=10.0.0.x;database=UFDATA_001_20xx;uid=sa;pwd=********;App=XXX'
CONN_LEN = len(ORIG) # 76
def patch(exe, new_conn):
assert len(new_conn) <= CONN_LEN
new_conn = new_conn.ljust(CONN_LEN) # 尾部补空格
data = bytearray(open(exe,'rb').read())
pat = 'server='.encode('utf-16-le')
i = data.find(pat) # 生产环境应遍历校验 database=/pwd= 特征
old = data[i:i+CONN_LEN*2].decode('utf-16-le')
assert len(old) == CONN_LEN and 'database=' in old
shutil.copy2(exe, exe + '.bak') # 永远先备份
data[i:i+CONN_LEN*2] = new_conn.encode('utf-16-le')
open(exe,'wb').write(data)
补丁后必做的三重验证( paranoia 是好习惯):
- 与原文件逐字节 diff —— 差异必须全部落在那 152 字节窗口内
(实测只有 62 字节不同:新旧串的公共前缀/后缀字节本来就一样);
- BSJB 五个流的尺寸与原文件逐一相等;
-
US 堆逐条目重新走一遍,几百条字符串全部可解码、抽查关键 SQL 原样无损。
另外两个实战细节:
server=127.0.0.1 的回环地址不能用 Windows 集成验证(NTLM 视 IP 为
不受信任域,报 18452),本机连库要么用机器名,要么老老实实 SQL 账号;
- Express 版默认 TCP 关闭 + 动态端口 + 仅 Windows 验证,注册表三处
(SuperSocketNetLib\Tcp\Enabled、IPAll\TcpPort、MSSQLServer\LoginMode)
改完重启服务才有 1433 可连。
4. 凭空重建数据库:exe 就是唯一的"建表文档"
数据模型全部从二进制里反推:
- INSERT 语句给出完整列名:
insert XX_PcInfoDetail(TableID,TableDetailID,ddh,ddhh,...)
一条不落——七张业务表的 DDL 直接照抄反推,类型按语义猜(订单号 nvarchar、
数量 decimal、日期 datetime),主外键按用法定;
- SELECT 的列清单 = 视图契约:
View_XX_GetDepartment 要返回
cdepcode/cdepname,就造一张最小 Department 表再罩个视图;
- 存储过程最麻烦:exe 里只有名字和参数个数(数 ldstr 里
',' 拼接段的段数),
过程体全在死掉的服务器上。首版只能按界面绑定列写"推断实现"。
然后上演了本次迁移最戏剧性的一幕:程序自带的报错框成了最好的老师。
在真实界面点查询,错误框直接打出实际发出的 SQL:
exec Proc_GetSaleOrder '','2026-09-01','2026-09-01','',''
——5 个参数,而推断版只给了 2 个。顺着报错把 14 个过程的真实签名全部
校准(5参/6参/3参/2参各就各位),再按网格数据绑定补列名时又发现一层:
WinForms 网格的列绑定中英文混用,部分列绑的是中文列名("客户简称""存货编码"),
于是 SELECT 里英文别名和中文别名各来一份,界面才算彻底填满。
最后 14/14 过程按真实签名调用通过,界面查询、排产、登录全链路出数。
5. 收尾打包的几个坑
- 三件套(备份/补丁/重建)用 PyInstaller 打成单文件 exe 给普通操作员,
--onefile 之后 __file__ 指向临时解压目录,定位自身目录必须改用
sys.executable,否则备份文件全进临时文件夹;
- 中文批处理必须存 GBK:UTF-8 +
chcp 65001 的组合会让 cmd 按字节偏移
错位解析,行直接断成乱码命令;
- 压缩长度前缀里
0x80 0x99 这种细节,是"76 字符*2+1=153 需要 2 字节形式"
算出来的——补丁工具里对它做了校验,不匹配就拒绝动手。
6. 安全后视镜(写给同样在养老系统的人)
这次事故级的脆弱点,每一个都是老 C/S 内网工具的通病:
- sa 密码硬编码在 exe 里——拿到程序 ≈ 拿到 ERP 数据库管理员权限;
- 用户密码明文存账套库,且管理界面直接可见;
- 一半业务逻辑活在存储过程里,服务器一没,逻辑就"失传",
客户端二进制只能告诉你签名,告诉不了你算法;
- 顺带:全程序零参数化查询,SQL 拼接遍地走。
迁移完成后顺手做的加固:弃用 sa 改专用低权账号、改掉印在 exe 里的旧密码、
每日自动备份 + 异地副本。老系统不丢人,丢备份才丢人。
完。文中工具链:Python 3.10(纯标准库手撕元数据)+ pyodbc + PyInstaller。
欢迎交流 .NET 元数据手工解析的奇技淫巧,但请勿拿去动不属于你的东西。