吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 806|回复: 1
上一主题 下一主题
收起左侧

[转贴] 某ERP系统外挂工具的无源码逆向迁移实录

[复制链接]
跳转到指定楼层
楼主
top777 发表于 2026-9-4 15:18 回帖奖励

服务器没了之后:某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% 在服务端,客户端只是个壳。数据库不在,程序连登录窗口都过不去。
所以整个"破解"其实是两件事:

  1. 凭空重建数据库(表/视图/存储过程,逻辑一半埋在服务端);
  2. 让写死了旧服务器地址的 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 是好习惯):

  1. 与原文件逐字节 diff —— 差异必须全部落在那 152 字节窗口内
    (实测只有 62 字节不同:新旧串的公共前缀/后缀字节本来就一样);
  2. BSJB 五个流的尺寸与原文件逐一相等;
  3. US 堆逐条目重新走一遍,几百条字符串全部可解码、抽查关键 SQL 原样无损。

另外两个实战细节:

  • server=127.0.0.1回环地址不能用 Windows 集成验证(NTLM 视 IP 为
    不受信任域,报 18452),本机连库要么用机器名,要么老老实实 SQL 账号;
  • Express 版默认 TCP 关闭 + 动态端口 + 仅 Windows 验证,注册表三处
    SuperSocketNetLib\Tcp\EnabledIPAll\TcpPortMSSQLServer\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 内网工具的通病:

  1. sa 密码硬编码在 exe 里——拿到程序 ≈ 拿到 ERP 数据库管理员权限;
  2. 用户密码明文存账套库,且管理界面直接可见;
  3. 一半业务逻辑活在存储过程里,服务器一没,逻辑就"失传",
    客户端二进制只能告诉你签名,告诉不了你算法;
  4. 顺带:全程序零参数化查询,SQL 拼接遍地走。

迁移完成后顺手做的加固:弃用 sa 改专用低权账号、改掉印在 exe 里的旧密码、
每日自动备份 + 异地副本。老系统不丢人,丢备份才丢人。


完。文中工具链:Python 3.10(纯标准库手撕元数据)+ pyodbc + PyInstaller。
欢迎交流 .NET 元数据手工解析的奇技淫巧,但请勿拿去动不属于你的东西。

免费评分

参与人数 4吾爱币 +3 热心值 +3 收起 理由
Tx1171 + 1 我很赞同!
only2025 + 1 谢谢@Thanks!
hero888 + 1 + 1 谢谢@Thanks!
kuiur0810 + 1 + 1 我很赞同!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
andyzhou132 发表于 2026-9-4 21:55
学习。。。
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-9-5 06:44

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表