就像代码审查和模糊测试一样,逆向工程也是一个可以写成整本书的专题(事实上已经有好几本了)。本书不会详细深入探讨每个学科的细节,而是聚焦于策略,如何高效调配有限资源以实现明确且重要的目标。要达成这个目标,你需要先了解整体环境,然后再深入细节。这样才能把时间和精力集中在更可能发现新漏洞的技术手段上。
对于逆向工程来说,不要一开始就直接把二进制文件扔进 Ghidra 或 IDA Pro,硬着头皮去看汇编代码。你首先应该学会对二进制进行初步筛选和分类,挑选出值得深入分析的目标。毕竟,并非所有二进制都是“同等”的(或者说,编译出来的效果各不相同)。
本章你将了解三类常见二进制:脚本、中间表示(Intermediate Representations,IR),比如字节码,以及机器码。然后你会对每一类做逆向示例分析。此外,还会探讨这些二进制的若干子类,它们各自需要不同的逆向方法。
超越可执行二进制和共享库
理解不同类型的二进制,有助于你选择合适的工具和分析方法。通过将二进制分为几个大类,你可以快速筛选目标,优化逆向流程。
通常,我们提到二进制时,脑海中浮现的是两种:可执行二进制和共享库。顾名思义,可执行二进制可直接从命令行或用户界面运行;共享库则导出函数,供其他二进制通过静态或动态链接调用。有时共享库也能被执行,比如 Windows 上通过 rundll32
调用动态链接库(DLL)。
这类二进制文件一般采用 Windows 的 Portable Executable (PE) 格式,Linux 的 Executable and Linkable Format (ELF) 格式,macOS 和 iOS 的 Mach-O 格式。这些格式由操作系统原生支持,包含了执行指令以及诸如导入导出表、动态链接信息、全局变量等额外数据。
虽然这种分类简单明了,但遗漏了很多重要细节,尤其是在当今的现代开发环境中。比如一些流行的通信软件,如 WhatsApp、Slack 和 Zoom,虽然以可执行二进制形式发布,但它们实际上还包含其他格式,如 Node.js 脚本、WebAssembly 二进制代码和通用中间语言(CIL)字节码。不同于标准的 PE 和 ELF 文件格式,这些格式是在其他运行环境中执行,比如 Node.js 环境或 .NET 框架的公共语言运行时(CLR)虚拟机。这些运行环境各自带有独特的安全边界、默认保护措施以及潜在的错误配置风险。
举例来说,Electron(基于 Node.js 的桌面应用框架)早期版本中,攻击者可以很容易地将一个简单的跨站脚本(XSS)漏洞升级为代码执行漏洞。Electron 允许开发者开启 nodeIntegration
设置,使 Node.js API 和模块在网页渲染进程中可用,从而实质上禁用了浏览器的沙箱保护。尽管开发者们早已从 ActiveX 和 Flash 的浏览器沙箱经验中吸取了教训,但这种沙箱与桌面操作系统 API 之间的桥接,极大地扩大了网页漏洞的影响范围。原本限制在单一网站的漏洞,现在可能变成受害者电脑上的远程代码执行。随着网页技术不断渗透到桌面和服务器端执行环境,这种界限的模糊只会愈发明显。
从漏洞研究者的角度看,这种界限模糊实际上扩大了逆向分析的目标范围。相比纯汇编代码,反编译诸如 Java 字节码和 CIL 这样的中间表示相对容易。实际上,有了适当的元数据,你甚至能还原出接近原始的源码。这还不包括像 Node.js 或 Python 这样的脚本语言,这些语言常被打包成包含解释器的二进制文件,运行内嵌的脚本。与其反编译机器码,不如将这类二进制解包、去混淆脚本,再按常规代码审查方式分析。
此外,这些组件之间存在很多交互。例如,Node.js 脚本可能实例化 WebAssembly 模块,或者 CIL 字节码加载非托管库。为了对应用逻辑的各种执行路径保持宏观理解,你需要掌握不同二进制类型及其最有效的分析方式。
下面,我们从脚本开始深入探讨。
脚本
脚本文件是用一种可以由解释器直接执行而无需编译成二进制的编程语言编写的。常见的脚本语言包括 JavaScript、Python 和 Ruby。例如,在 Node.js 环境中,JavaScript 脚本由浏览器外的 V8 JavaScript 引擎执行。
但这并不意味着解释器完全不对脚本进行编译。许多现代解释器会在运行时采用某种即时编译(JIT)或预编译(AOT)机制,将脚本编译成字节码或机器码,这样相比逐行解释执行,性能更优且运行更快。
部分基于脚本的可执行文件中可能只包含已编译的字节码,而非原始脚本。还有些情况下,执行文件里的脚本经过了混淆或压缩(最小化),增加了分析难度。在理想情况下,可执行文件充当源代码文件的包装器,内置解释器直接执行这些脚本。本节将通过两个以脚本语言编写、以可执行文件形式发布的开源项目进行探讨:Node.js 的 Electron 应用 DbGate,以及 Python 的 PyInstaller 应用 Galaxy Attack。
逆向工程 Node.js Electron 应用
如今,在桌面环境中你很可能会遇到至少一个 Node.js Electron 应用,因此理解如何逆向这类应用非常重要。现代应用开发的一个显著趋势是混合软件的兴起,即结合网页技术和原生解决方案。传统上,桌面和服务器端的原生软件多用 C++ 等编译语言编写,因其编译时优化和直接执行机器码的优势,运行速度远快于解释型语言(如 JavaScript 和 Python)。
但自 2008 年强大的 V8 引擎支持即时编译后,网页开发者可以用更高效的方式运行 JavaScript。随后 2009 年 Node.js 发布,基于 V8 提供了服务端 JavaScript 运行环境。开发者不再仅限于浏览器内的 JavaScript,而是能编写代码读写文件、查询数据库、执行服务器端操作。
Node.js 的非阻塞事件驱动架构还支持开发可扩展的实时应用,能同时处理多个连接。这使得 Node.js 在网页服务器领域迅速被采纳,开发者可用 JavaScript 同时编写前端和后端。
紧接着,Electron 框架诞生(最初名为 Atom Shell,源于它为 Atom 编辑器开发)。Electron 利用 Node.js 和其他网页技术(如 HTML、CSS)构建桌面应用。开发者无需为不同操作系统的 API 和构建流程苦恼,只要用 Node.js 和 Chromium 浏览器引擎等成熟环境,就能用 JavaScript 轻松开发跨平台桌面应用。尤其当桌面应用越来越依赖网页功能时,开发效率大幅提升。
Electron 应用由预编译的 Electron 二进制文件(含 Node.js 和 Chromium 执行环境)和应用源码组成,源码通常打包在 Atom Shell Archive(ASAR)文件中。你可以通过 DbGate 的发布版来探索这一结构。DbGate 是基于 Electron 的开源数据库客户端,Linux 版同时提供 Debian 包和 AppImage。你可从 github.com/dbgate/dbga… 下载 Debian 包,使用 dpkg-deb
工具解包:
$ dpkg-deb -x dbgate-5.2.7-linux_amd64.deb dbgate
$ tree --charset ascii dbgate
dbgate
|-- opt
| `-- DbGate
| |-- chrome_100_percent.pak
| |-- chrome_200_percent.pak
| |-- chrome_crashpad_handler
| |-- chrome-sandbox
➊ | |-- dbgate
| |-- icudtl.dat
➋ | |-- libEGL.so
| |-- libffmpeg.so
| |-- libGLESv2.so
| |-- libvk_swiftshader.so
| |-- libvulkan.so.1
--snip--
| |-- resources
➌ | | |-- app.asar
| | `-- app.asar.unpacked
| | |-- node_modules
| | | |-- better-sqlite3
| | | | `-- build
| | | | `-- Release
| | | | `-- better_sqlite3.node
| | | `-- oracledb
| | | `-- build
| | | `-- Release
| | | |-- oracledb-5.5.0-darwin-x64.node
| | | |-- oracledb-5.5.0-linux-x64.node
| | | `-- oracledb-5.5.0-win32-x64.node
| | `-- packages
| | `-- api
| | `-- dist
| | |-- 45c2d7999105b08d7b98dd8b3c95fda3.node
| | `-- 9bf76138dc2dae138cb17ee46c4a2dd1.node
| |-- resources.pak
| |-- snapshot_blob.bin
| |-- swiftshader
| | |-- libEGL.so
| | `-- libGLESv2.so
| |-- v8_context_snapshot.bin
| |`-- vk_swiftshader_icd.json
从目录可见,包中包含一个名为 dbgate
的可执行二进制文件 ➊,它是预编译的 Electron 二进制,用于加载打包的 ASAR 文件。还有用于图形渲染和媒体解析的共享库 ➋,这些是 Chromium 和 Node.js 的依赖。ASAR 文件 app.asar
➌ 位于 resources
目录下,Electron 会自动从这里加载应用。
这不仅是 Electron 应用的典型模式,也是许多基于脚本的可执行文件的通用模式。应用包通常包括一个通用脚本解释器、一些库文件和脚本包。当你遇到更多此类可执行文件时,会学会识别特定模式,比如 ASAR 文件的存在,判断它们用的是哪种框架。
如果你装有 Node.js,可以用 asar
工具解包 ASAR 文件:
$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash
$ source ~/.zshrc
$ nvm install --lts
$ npm install -g asar
$ npx asar extract dbgate/opt/DbGate/resources/app.asar dbgate-src
$ tree --charset ascii dbgate-src
dbgate-src
--snip--
|-- icon.png
|-- node_modules
| |-- @yarnpkg
| |-- argparse
--snip--
|-- package.json ➊
|-- packages
| |-- api
| | `-- dist
| | |-- 45c2d7999105b08d7b98dd8b3c95fda3.node
| | |-- 9bf76138dc2dae138cb17ee46c4a2dd1.node
| | `-- bundle.js
| |-- plugins
| | |-- dbgate-plugin-csv
| | | |-- dist
| | | | |-- backend.js
| | | | `-- frontend.js
| | | |-- icon.svg
| | | |-- LICENSE
| | | |-- package.json
| | | `-- README.md
--snip--
`-- src
|-- electron.js
|-- mainMenuDefinition.js
|-- nativeModulesContent.js
`-- nativeModules.js
解包后文件中有许多有趣的文件名,但通常第一个查看的应是清单文件(manifest),它包含包的重要元数据,比如入口文件。不同语言的包有各自的清单文件,Node.js 是 package.json
➊,Java 是 MANIFEST.MF
,Go 是 go.mod
等。来看下 DbGate 的 package.json
(见示例):
{
"name": "dbgate",
"version": "5.2.7",
"private": true,
"author": "Jan Prochazka <jenasoft.database@gmail.com>",
"description": "Opensource database administration tool",
"dependencies": {
"electron-log": "^4.4.1",
"electron-updater": "^4.6.1",
"lodash.clonedeepwith": "^4.5.0",
"patch-package": "^6.4.7"
},
➊ "repository": {
"type": "git",
"url": "https://github.com/dbgate/dbgate.git"
},
"homepage": "./",
➋ "main": "src/electron.js",
"optionalDependencies": {
"better-sqlite3": "7.6.2",
"oracledb": "^5.5.0"
}
}
这里有两条重要信息:一是清单告诉你源码仓库地址 ➊,如果你遇到该二进制而不知它是开源项目,这信息非常有用;二是入口文件在 main
字段指定的 src/electron.js
➋,这是接下来要调查的文件。
你已取得不错进展,但很快可能会在 electron.js
遇到难题:
if (!apiLoaded) {
const apiPackage = path.join(
__dirname,
process.env.DEVMODE ? '../../packages/api/src/index' : '../packages/api/dist/bundle.js' ➊
);
global.API_PACKAGE = apiPackage;
global.NATIVE_MODULES = path.join(__dirname, 'nativeModules');
// console.log('global.API_PACKAGE', global.API_PACKAGE);
const api = require(apiPackage);
代码在生产环境中从 packages/api/dist/bundle.js
➊ 导入包,但该文件代码混乱且变量名晦涩,无法手工分析。
这是因为 DbGate 使用了 Webpack 和 Rollup 这类 JavaScript 模块打包器,将多个源码文件合并压缩成一个或多个更优化的输出文件。你可以在 DbGate 源码中找到 Webpack 配置(packages/api/webpack.config.js
)和 Rollup 配置(packages/web/rollup.config.js
)。要深入分析,你需要对压缩代码做还原(反混淆)。
解包源映射(Source Maps)
由于输出文件经过压缩(最小化),通常无法从 Webpack 或 Rollup 生成的输出文件恢复出原始未压缩的代码版本。然而,有时开发者会配置这些工具(以及 Babel、TypeScript 等)同时输出一个源映射文件。JavaScript 源映射是一种特殊文件,能够将变换后的代码(例如经过压缩的 Webpack 输出)映射回原始源码及其目录结构,从而方便开发时调试 JavaScript 代码。
以 DbGate 为例,开发者没有为 Webpack 启用源映射,但为两个 Rollup 输出文件启用了源映射,分别是 query-parser-worker.js
和 bundle.js
,配置示例如下:
// rollup.config.js
export default [
{
input: 'src/query/QueryParserWorker.js',
output: {
➊ sourcemap: true,
format: 'iife',
➋ file: 'public/build/query-parser-worker.js',
},
plugins: [
commonjs(),
resolve({ browser: true }),
production && terser(),
],
},
{
input: 'src/main.ts',
output: {
sourcemap: true,
format: 'iife',
name: 'app',
file: 'public/build/bundle.js',
},
},
];
sourcemap: true
➊ 表示 Rollup 在生成输出文件时会包含源映射文件,位置如 ➋ 所示。
在 DbGate 解包的文件中,bundle.js
和 bundle.js.map
位于同一目录 packages/web/public/build
。仔细比较这两个文件,bundle.js
是高度压缩的 JavaScript 代码,难以阅读;而 bundle.js.map
是一个 JSON 文件,包含可识别的文件路径和源码内容。
有了源映射文件,你可以把混乱难懂的 bundle.js
转换还原成原始源码文件。可以使用 Mozilla 的 source-map
库快速写一个脚本来完成。将 bundle.js.map
和下面示例中的 unpack.js
放到同一目录(该脚本也可在本书代码仓库 chapter-04/unpack-sourcemap 中找到):
// unpack.js
const fs = require('fs');
const path = require('path');
const sourceMap = require('source-map');
const rawSourceMap = JSON.parse(fs.readFileSync('bundle.js.map', 'utf8'));
fs.mkdirSync('output');
sourceMap.SourceMapConsumer.with(rawSourceMap, null, consumer => {
➊ consumer.eachMapping(mapping => {
const sourceFilePath = mapping.source;
const sourceContent = consumer.sourceContentFor(mapping.source);
// 去除路径中的相对路径符号
➋ const normalizedSourceFilePath = path
.normalize(sourceFilePath)
.replace(/^(..(/|\|$))+/, '');
const outputFilePath = path.join('output', normalizedSourceFilePath);
const outputDir = path.dirname(outputFilePath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
➌ fs.writeFileSync(outputFilePath, sourceContent, 'utf8');
});
});
脚本会解析源映射,并遍历每条映射记录 ➊,提取源文件路径和源码内容。检查 bundle.js.map
会发现,有些源文件路径是相对路径,导致无法准确还原目录结构。为此,脚本将移除相对路径部分 ➋,统一放在同一根目录下。但源码内容仍然完整写入输出目录 ➌。
安装 source-map
库并运行脚本,过程可能需要几分钟:
$ npm install source-map
$ node unpack.js
对比输出目录和原始源码目录,虽然目录结构并非完全一致,但大致对应原始源码中的 packages/web/src
。另外,你会发现 TypeScript 文件(如 packages/filterparser/src/getFilterType.ts
)被转换成了 JavaScript 文件(如 filterparser/lib/getFilterType.js
)。这是因为构建时 TypeScript 被转译(即编译成另一种语言)成 JavaScript,以便 JavaScript 引擎执行。下面是原始 TypeScript 和转译后的 JavaScript 代码对比示例。
Listing 4-4:原始 TypeScript 代码
➊ import { isTypeNumber, isTypeString, isTypeLogical, isTypeDateTime } from 'dbgate-tools';
import { FilterType } from './types';
➋ export function getFilterType(dataType: string): FilterType {
if (!dataType) return 'string';
if (isTypeNumber(dataType)) return 'number';
if (isTypeString(dataType)) return 'string';
if (isTypeLogical(dataType)) return 'logical';
if (isTypeDateTime(dataType)) return 'datetime';
return 'string';
}
原始 TypeScript 使用 import
关键字导入依赖 ➊(仅在较新 JavaScript 版本支持,如 ECMAScript 6),并带有类型注解 ➋(JavaScript 原生不支持)。
Listing 4-5:转译后的 JavaScript 代码
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getFilterType = void 0;
➊ const dbgate_tools_1 = require("dbgate-tools");
➋ function getFilterType(dataType) {
if (!dataType)
return 'string';
if ((0, dbgate_tools_1.isTypeNumber)(dataType))
return 'number';
if ((0, dbgate_tools_1.isTypeString)(dataType))
return 'string';
if ((0, dbgate_tools_1.isTypeLogical)(dataType))
return 'logical';
if ((0, dbgate_tools_1.isTypeDateTime)(dataType))
return 'datetime';
return 'string';
}
exports.getFilterType = getFilterType;
转译的 JavaScript 使用了 CommonJS 规范的 require
关键字 ➊,去掉了类型注解 ➋(这些类型检查已在转译阶段完成)。这会导致逆向时丢失一部分信息(例如类型声明有助于理解函数预期输入)。
除了类型注解信息,转译 JavaScript 还往往更加冗长。随着遇到更多转译或压缩代码,你会逐渐学会将常见的转译模式映射回其 TypeScript 原型,比如标准导出代码、polyfill(用旧版 JS 实现新版 JS 功能的代码)等。
如果 TypeScript 尚未转译为 JavaScript,你可能会发现一些细微差异。比如看看下面 Listing 4-6 中原始代码片段:
Listing 4-6:原始 TypeScript 代码
import { apiCall, enableApi } from './utility/api';
import { getConfig } from './utility/metadataLoaders';
// --snip--
➊ export async function handleAuthOnStartup(config) {
if (config.oauth) {
console.log('OAUTH callback URL:', location.origin + location.pathname);
}
if (config.oauth || config.isLoginForm) {
if (localStorage.getItem('accessToken')) {
return;
}
redirectToLogin(config);
}
}
该代码使用了 async
关键字定义异步函数 ➊,异步函数返回 Promise,允许程序调用它后继续执行其他事件。
Listing 4-7:转译后的 JavaScript 代码
import { __awaiter } from "tslib";
// --snip--
export function handleAuthOnStartup(config) {
➊ return __awaiter(this, void 0, void 0, function* () {
if (config.oauth) {
console.log('OAUTH callback URL:', location.origin + location.pathname);
}
if (config.oauth || config.isLoginForm) {
if (localStorage.getItem('accessToken')) {
return;
}
redirectToLogin(config);
}
});
}
转译代码用 TypeScript 的 __awaiter
polyfill 函数实现异步功能 ➊,行为等同于原始 async
函数。
这些差异不会对代码分析造成显著影响,但我们仍然会丢失部分目录结构信息,例如抽取后的代码缺少 packages
或 web
目录,影响分析时确定文件相对位置。遇到带有目录遍历路径的源映射时请注意。
另外,测试文件、配置文件等非核心文件信息也会缺失,而这些文件通常能提供软件编译方式等有用线索。
总体来说,源映射的存在并不保证你能完全还原源码,它们通常只包含代码库中的相关部分,转译过程中会丢失信息。比如 DbGate 的例子,源映射只包含 Rollup 配置覆盖的客户端代码,且转译时丢失了一些信息。但只要有它们,分析难度会大大降低。没有源映射时,只能依赖代码格式化工具等不那么准确的还原手段。
对压缩代码使用美化工具
美化工具是一种可以让代码更易读的工具,通常通过添加统一的空格和换行来格式化代码。这样可以更方便地分析压缩代码——压缩代码本质上尽可能去除不必要的空格和换行,以减小体积,同时解释器也不需要这些空白字符来解析代码。
回到解包后的应用源码归档文件,你会发现 packages/api/dist
目录下有一个不同的 bundle.js
文件。与 packages/web/public/build
中的 bundle.js
不同,它没有配套的源映射文件,无法帮助你进一步解包。查看原始源码中 packages/api/webpack.config.js
的 Webpack 配置,开发者注释掉了一个禁用压缩的选项:
// optimization: {
// minimize: false,
// },
packages/plugins
目录下的其他插件发行文件也类似。Webpack 优化了输出包,缩短了变量和函数名,移除了空白和死代码,生成了紧凑但难以阅读的代码块。尽管如此,如果仔细查看,还是能辨认出一些可理解的字符串和函数名,因为 Webpack 会保留部分常量值和导出函数名。
你可以使用美化工具对代码进行格式化和部分反混淆,从而提升代码可读性。虽然市面上有多种选择,js-beautify
包基本够用。安装并对主 bundle 运行美化,命令如下:
$ npm -g install js-beautify
$ npx js-beautify packages/api/dist/bundle.js > bundle.beautified.js
美化后的代码展示了一组比较规整的函数定义列表。你甚至可能发现部分代码与之前通过源映射解包的文件类似,因为服务器端和客户端代码共用一些导入函数。其中之一是 compileMacroFunction
:
function compileMacroFunction(macro, errors = []) {
if (!macro) return null;
let func;
try {
➊ return func = eval(getMacroFunction[macro.type](macro.code)), func
// 换行
} catch (e) {
return errors.push(`Error compiling macro ${macro.name}:
${e.message}`), null
}
}
请注意这里的危险 eval
汇点 ➊,它会将字符串参数作为 JavaScript 代码执行。如果参数可被攻击者控制,就极易导致代码注入漏洞。由于 Webpack 默认不会混淆像 eval
这样的标准函数名,你可以对美化后的代码运行自动化代码分析工具,快速定位这类危险汇点,尤其当代码量大难以人工审核时,这样的工具尤为重要。
分析危险汇点
由于 compileMacroFunction
同时出现在前端和后端代码中,且包含一个危险的汇点,因此值得深入挖掘。利用你在前几章学到的技术,你可以分析解包并美化后的代码,判断它是否存在可被利用的漏洞。
该函数首先接收一个 macro
参数,并将其传递给 getMacroFunction
,最终结果被传给 eval
执行。来看解包后的源映射中 getMacroFunction
的代码:
const getMacroFunction = {
➊ transformValue: code => `
(value, args, modules, rowIndex, row, columnName) => {
${code}
}
`,
➋ transformRow: code => `
(row, args, modules, rowIndex, columns) => {
➌ ${code}
}
`,
};
代码表明 getMacroFunction
是一个字面量对象,只有两个键:transformValue
➊ 和 transformRow
➋。它们对应的值都是函数,这些函数接收一个参数 code
,并把它插入到定义另一个函数的字符串模板中 ➌。回想一下,这个字符串最终会被传给 eval
执行。
因此,只要攻击者能控制 macro.code
,就很可能触发代码注入漏洞。接下来你可以用“汇点到源头”的分析方法倒推其来源。
在美化和解包的后端代码中,compileMacroFunction
被 runMacroOnChangeSet
函数调用:
function runMacroOnChangeSet(
➊ macro,
macroArgs,
selectedCells,
changeSet,
display,
useRowIndexInsteaOfCondition
) {
var _a;
const errors = [];
➋ const compiledMacroFunc = compileMacroFunction(macro, errors);
该函数接收一个 macro
参数 ➊,直接未经修改地传给了 compileMacroFunction
➋。但如果你在美化代码中搜索 runMacroOnChangeSet
,找不到结果,说明不存在汇点到源头的直接路径;在解包代码中,你会发现它在一些 .svelte
文件中被调用,这些文件是用 Svelte 前端框架定义的组件。例如,在 TableDataGrid.svelte
中:
➊ function handleRunMacro(macro, params, cells) {
➋ const newChangeSet = runMacroOnChangeSet(macro, params, cells,
changeSetState?.value, display, false);
if (newChangeSet) {
dispatchChangeSet({ type: 'set', value: newChangeSet });
}
}
$: reference = config.reference;
$: childConfig = config.childConfig;
</script>
<VerticalSplitter isSplitter={!!reference}>
<svelte:fragment slot="1">
<DataGrid
{...$$props}
gridCoreComponent={SqlDataGridCore}
formViewComponent={SqlFormView}
{display}
showReferences
showMacros
hasMultiColumnFilter
➌ onRunMacro={handleRunMacro}
这里,前端组件定义了一个 handleRunMacro
函数,它接受一个 macro
参数 ➊,并直接传给 runMacroOnChangeSet
➋。这个函数由 onRunMacro
事件处理器触发 ➌,当用户在前端点击按钮运行宏时触发。
这看似是一个可行的汇点到源头路径,但并不算特别严重。毕竟,用户必须自己输入宏代码并主动点击按钮才会触发漏洞,这更像是用户自行“自残”的代码执行,需要较大的人为交互。尽管如此,这里依然是一个不错的起点,用来深入挖掘类似的易受攻击代码模式。
逆向工程 Python 应用
除了 Node.js Electron 应用,其他编程语言的应用程序(如 Python 和 Ruby)也可以打包成可执行文件。毕竟,脚本语言的一大优势是可移植性;只要有兼容的解释器,几乎可以在任何平台运行大多数脚本。Electron 应用最为常见,但了解如何解包其他类型的应用(如 PyInstaller 可执行文件)同样有用。
PyInstaller 允许开发者将 Python 应用打包成单一包文件,例如单文件可执行程序。执行该二进制文件时,PyInstaller 启动一个引导程序,先解压编译后的 Python 脚本(.pyc
)和本地库,然后用捆绑的 Python 解释器运行主脚本。此打包采用相对标准的方式,包括目录表和归档文件。
通常,附加在可执行文件末尾的压缩归档数据包含以下内容:
- Python 动态库,包括解释器
- 主 Python 脚本
- Python zip 应用归档(通常命名为
PYZ-00.pyz
),包含额外的 Python 脚本 - 库文件
- 媒体资源等支持文件
与其他基于脚本的打包可执行文件类似,你通常可以通过查看字符串或文件头识别 PyInstaller 打包文件:
$ strings main.exe | grep pyinstaller
xpyinstaller-4.7.dist-info\COPYING.txt
xpyinstaller-4.7.dist-info\INSTALLER
xpyinstaller-4.7.dist-info\METADATA
xpyinstaller-4.7.dist-info\RECORD
xpyinstaller-4.7.dist-info\REQUESTED
xpyinstaller-4.7.dist-info\WHEEL
xpyinstaller-4.7.dist-info\entry_points.txt
xpyinstaller-4.7.dist-info\top_level.txt
$ strings ~/Downloads/main.exe | grep python
bpython310.dll
6python310.dll
你可以用 PyInstaller 自带的 pyi-archive_viewer
工具检查 PyInstaller 打包可执行文件的 CArchive,确认是否为 PyInstaller 文件。本节以简单的 PyInstaller 游戏 Amegma Galaxy Attack 为例,Windows 可执行文件可从 GitHub 发布页下载(github.com/Amegma/Gala…)。然后安装 PyInstaller 并运行归档查看工具:
$ pip install pyinstaller
$ pyinstaller -v
6.8.0
$ pyi-archive_viewer main.exe
pos, length, uncompressed, iscompressed, type, name
[(0, 217, 287, 1, 'm', 'struct'),
(217, 1018, 1754, 1, 'm', 'pyimod01_os_path'),
(1235, 4098, 8869, 1, 'm', 'pyimod02_archive'),
(5333, 7116, 16898, 1, 'm', 'pyimod03_importers'),
(12449, 1493, 3105, 1, 'm', 'pyimod04_ctypes'),
(13942, 833, 1372, 1, 's', 'pyiboot01_bootstrap'),
(14775, 466, 696, 1, 's', 'pyi_rth_inspect'),
(15241, 698, 1067, 1, 's', 'pyi_rth_pkgutil'),
(15939, 1187, 2154, 1, 's', 'pyi_rth_multiprocessing'),
(17126, 1999, 4202, 1, 's', 'pyi_rth_pkgres'),
(19125, 2103, 3574, 1, 's', 'main'),
--snip--
(5175013, 1985630, 4471024, 1, 'b', 'python310.dll'),
(7160643, 13440, 25320, 1, 'b', 'select.pyd'),
(7174083, 405123, 1117936, 1, 'b', 'unicodedata.pyd'),
(7579206, 56136, 108544, 1, 'b', 'zlib1.dll'),
--snip--
(38446628, 12, 4, 1, 'x', 'pyinstaller-4.7.dist-info\INSTALLER'),
(38446640, 2714, 7085, 1, 'x', 'pyinstaller-4.7.dist-info\METADATA'),
(38449354, 13562, 56668, 1, 'x', 'pyinstaller-4.7.dist-info\RECORD'),
(38462916, 8, 0, 1, 'x', 'pyinstaller-4.7.dist-info\REQUESTED'),
(38462924, 104, 98, 1, 'x', 'pyinstaller-4.7.dist-info\WHEEL'),
(38463028, 141, 361, 1, 'x', 'pyinstaller-4.7.dist-info\entry_points.txt'),
(38463169, 20, 12, 1, 'x', 'pyinstaller-4.7.dist-info\top_level.txt'),
(38463189, 2076778, 2076778, 0, 'z', 'PYZ-00.pyz')]
列表中你会发现 python310.dll
➊,说明 PyInstaller 使用的是 Python 3.10 版本。但除 main
和媒体资源外,没有明显源码文件,因为它们都被打包进了 PYZ-00.pyz
➋(Zlib 归档文件)。你可以在交互式会话中查看:
? O PYZ-00.pyz
Contents of 'PYZ-00.pyz' (PYZ):
is_package, position, length, name
0, 17, 1893, '__future__'
0, 1910, 1651, '_aix_support'
0, 3561, 1388, '_bootsubprocess'
0, 4949, 2937, '_compat_pickle'
0, 7886, 2213, '_compression'
0, 10099, 5991, '_osx_support'
0, 16090, 2422, '_py_abc'
0, 18512, 51188, '_pydecimal'
0, 69700, 7845, '_strptime'
0, 77545, 2863, '_threading_local'
0, 80408, 25050, 'argparse'
0, 105458, 22331, 'ast'
1, 127789, 453, 'asyncio'
你会发现部分模块名与原始源码文件匹配,其他是导入的支持模块。然后可以提取 models.button
文件,退出交互式会话:
? X models.button
to filename? models.button.pyc
? q
提取的文件是编译后的 Python 文件。查看内容时大多是乱码,因为编译后的 Python 文件是字节码而非源码。这样做能加快运行速度,Python 解释器可以跳过解析纯文本代码,直接执行更底层且优化的指令。
然而,直接提取字节码会丢失 Zlib 归档文件的起始魔数字节,这部分字节包含 Python 版本信息(2 字节)及回车换行符(0D0A)。Python 版本很重要,因为新版本会对解释器和字节码结构做出调整,影响反编译。
魔数字节丢失是因为 PyInstaller 在 PYZ-00.pyz
开头仅存储了一份魔数。举例,PYZ-00.pyz
前 16 字节为:
50595A00 6F0D0D0A 001F8838 00000000
前 4 字节是 ASCII 字符串 PYZ
,紧接着的魔数字节是你需要的 6F0D0D0A
。
将这些魔数字节和 12 个空字节填充,预先加到 models.button.pyc
前:
$ echo -n -e '\x6F\x0D\x0D\x0A' > fixed.models.button.pyc
$ printf '\x00%.0s' {1..12} >> fixed.models.button.pyc
$ cat models.button.pyc >> fixed.models.button.pyc
准备好编译后的 Python 文件后,需要对其进行反编译。众多开源反编译器中,Decompyle++ 尝试支持任意版本的 Python 字节码,适合 Galaxy Attack 使用的较新版本。克隆并编译 Decompyle++,然后运行反编译:
$ git clone https://github.com/zrax/pycdc
$ cd pycdc
$ cmake .
$ make
$ make check
$ cd ..
$ pycdc/pycdc fixed.models.button.pyc
如果操作正确,你会得到相当连贯的输出,如下所示(节选):
# Source Generated with Decompyle++
# File: fixed.models.button.pyc (Python 3.10)
import pygame
from utils.assets import Assets
from config import config
from constants import Font, Colors
class Button:
def __init__(self, color, outline_color, text = ('',)):
self.color = color
self.outline_color = outline_color
self.text = text
self.outline = False
self.rect = pygame.Rect(0, 0, 0, 0)
def draw(self, pos, size):
self.default_outline = pygame.Rect(pos[0] - 5, pos[1] - 5, size[0] + 10, size[1] + 10)
self.on_over_outline = pygame.Rect(pos[0] - 6, pos[1] - 6, size[0] + 12, size[1] + 12)
self.rect = self.default_outline
default_inner_rect = (pos[0], pos[1], size[0], size[1])
onover_inner_rect = (pos[0] + 1, pos[1] + 1, size[0] - 2, size[1] - 2)
inner_rect = onover_inner_rect if self.outline == True else default_inner_rect
pygame.draw.rect(config.CANVAS, self.outline_color, self.on_over_outline \
if self.outline == True else self.default_outline, 0, 7)
pygame.draw.rect(config.CANVAS, self.color, inner_rect, 0, 6)
if self.text != '':
font = pygame.font.Font(Font.neue_font, 40)
Assets.text.draw(self.text, font, Colors.WHITE, \
(pos[0] + size[0] / 2, pos[1] + size[1] / 2), True, True)
return None ➊
def isOver(self):
return self.rect.collidepoint(pygame.mouse.get_pos())
将反编译输出与原始源码 models/button.py
比较,你会发现反编译代码仅有少许差别(例如多了一个 return None
➊)。对 PyInstaller 可执行文件中提取的其他编译 Python 文件重复此过程,应该能基本还原出接近原始的源码。
虽然 PyInstaller 可执行文件相比 Electron 应用少见得多,但这个简单示例有助于说明脚本语言软件逆向的一些常见模式。即使源码被编译、转译或打包,也不可能完全消除源码的存在。但信息丢失的程度会显著影响分析难度。
中间表示(Intermediate Representations)
从抽象层次来看,中间表示介于机器码和源代码之间。顾名思义,它们是源代码的更高级表示形式,可以被运行时环境解释和执行。
使用中间表示有诸多优势。例如,运行时可以接管许多常规任务,如内存管理、垃圾回收和异常处理,开发者无需在源代码中手动实现这些功能,从而专注于构建应用。中间表示还便于运行时执行类型检查或调试,使程序更健壮。
虽然编译后的 Python 字节码也可视为一种中间表示,但它与本节将要分析的 C# 和 Java 的中间表示有所不同。Python 字节码编译的抽象层级较高,因此更容易恢复原始源代码。脚本型二进制的逆向主要集中在提取和还原,而中间表示二进制的逆向则聚焦于反编译和重构。
Python 字节码由 Python 解释器执行,而 Java 和 C#(即 .NET)二进制则在各自的虚拟机运行时环境中执行。Java 类文件只要有兼容的 Java 虚拟机(JVM)就能在任何操作系统上运行,因此比针对特定指令集和架构的机器码二进制更易逆向。
另一个中间表示二进制的特点是通常包含额外的元数据,用以配置运行时环境。例如,Java 的归档包格式 JAR 包含一个清单(manifest),告诉 JVM 应用入口的类、依赖关系等重要信息。类似地,.NET 二进制(也称为程序集)中包含描述版本号、包含文件和引用等元数据的清单。程序集还包含所有使用的类型和成员的元数据,这对反编译极为有用。
识别中间表示非常重要,因为这使你可以采用更直接的逆向和反编译方法,从而获得更准确的输出。预期参数类型、类和变量的信息极具价值,能为你节省数小时分析时间。然而,你也可能遇到专门为防止逆向而设计的混淆技术,这可能迫使你采用动态分析策略(我们将在下一章探讨)。
和上一节一样,本节通过两个开源实例探讨中间表示二进制的逆向。延续前一组示例的主题,它们分别是一个数据库客户端和一款游戏。C# 方向,你将研究 LiteDB Studio;Java 方向,你将挑战 Pixel Wheels。
通用语言运行时程序集(Common Language Runtime Assemblies)
.NET 是一个开源的开发平台,用于构建用 C#、F# 和 Visual Basic 编写的应用程序。.NET 的核心基础是通用语言运行时(CLR),它执行公共中间语言(CIL)中间表示指令。为了实际执行代码,CLR 通过即时编译(JIT)或预编译(AOT)将 CIL 转换为特定处理器的指令。
.NET 二进制文件以程序集(assembly)的形式分发,通常是 .exe
或 .dll
文件。程序集格式本质上是可移植可执行文件(PE)格式的扩展,封装在标准 PE 结构内。PE 头之后,二进制文件包含 CLR 特有的数据:
- 程序集清单(Assembly manifest)
- 程序集元数据(Assembly metadata)
- 类型元数据(Type metadata)
- 定义类型和成员的元数据表(Metadata tables)
- CIL 代码(在 CLR 中执行的实际中间语言代码)
- 资源(资源文件,如图片、配置及其他数据)
- 强名称签名(Strong name signature):可选的数字签名用于验证程序集完整性
你可以通过分析 LiteDB Studio 来探索这些内容。LiteDB Studio 是一个用于查看和编辑 LiteDB 数据库文件的图形界面程序。因为该可执行文件是为 Windows 编译的,且逆向工具主要基于 Windows 平台,建议尽可能在 Windows 上执行下述步骤。如果条件不允许,也可以在其他平台尝试,但难度和支持程度不同。
从 github.com/mbdavid/Lit… 下载 LiteDB Studio 可执行文件。然后用 PE-Bear 工具查看程序集的一些属性;该工具最新版本可从 github.com/hasherezade… 下载。
顾名思义,PE-Bear 解析并反汇编 PE 文件,也支持 .NET 程序集。除了标准 PE 头外,主窗口应有一个 .NET Hdr 选项卡,对应程序集清单。在此标签页中,可以查看 CLR 特定元数据,如 MajorRuntimeVersion、其他元数据流的虚拟地址和大小,包括 Metadata(类型元数据)、Resources 和 StrongName Signature。StrongNameSignature 的虚拟地址和大小均为 0,说明此程序集未设置强名称签名。
需要注意的是,.NET 头位于 PE 文件的 .text 段中,紧跟标准 PE 头,这说明 .NET 程序集实际上是 PE 格式的扩展。如果查看 Section Hdrs 标签页中 .text 的原始地址,会发现它与 .NET Hdr 选项卡中的第一个偏移相匹配。但 PE-Bear 无法进一步分析 .NET 头部。
查看 Metadata 或 Resources 流的十六进制转储,可以发现一些熟悉的字符串和大量非 ASCII 字节。例如,元数据表开头类似:
00000000 42 53 4a 42 01 00 01 00 00 00 00 00 0c 00 00 00 |BSJB............|
00000010 76 34 2e 30 2e 33 30 33 31 39 00 00 00 00 05 00 |v4.0.30319......|
00000020 6c 00 00 00 5c ba 02 00 23 53 74 72 69 6e 67 73 |l...\ο..#Strings|
00000030 00 00 00 00 c8 ba 02 00 24 2b 02 00 23 55 53 00 |....Èο..$+..#US.|
00000040 ec e5 04 00 d6 3a 02 00 23 42 6c 6f 62 00 00 00 |ìå..Ö:..#Blob...|
00000050 c4 20 07 00 10 00 00 00 23 47 55 49 44 00 00 00 |Ä ......#GUID...|
00000060 d4 20 07 00 c8 4a 08 00 23 7e 00 00 00 49 6d 6d |Ô ..ÈJ..#~...Imm|
00000070 47 65 74 44 65 66 61 75 6c 74 49 4d 45 57 6e 64 |GetDefaultIMEWnd|
00000080 00 53 65 6e 64 4d 65 73 73 61 67 65 00 43 72 65 |.SendMessage.Cre|
这些字节需要按照 .NET 程序集格式特定方式解析。你无需手动完成此事,可以借助工具。正如前文所述,多个高级语言都可编译成 CIL,CLR 负责解释。CIL 是面向对象且基于栈的指令集,不依赖于特定处理器。你可以用 Visual Studio 附带的 IL Disassembler(ILDASM)将任何 .NET 程序集反汇编成 CIL。
如果你尚未安装 Visual Studio,建议安装带有 .NET Framework 工具的版本以访问 IL Disassembler。安装完成后,在 Visual Studio 开发者命令行中运行 ildasm.exe
。作为快速测试,在 Visual Studio 中用 Console App (.NET Framework) 模板编译下列 C# 代码:
using System;
public class Hello
{
public static void Main(String[] args)
{
Console.WriteLine("Hello World!");
}
}
查看输出目录后,在开发者命令行用 IL Disassembler 反汇编:
> ildasm.exe /out=disassembled.il C:\repos\ConsoleApp1\ConsoleApp1\bin\Debug\ConsoleApp1.exe
反汇编的 CIL 文件应类似如下(节选):
// Metadata version: v4.0.30319
.assembly extern mscorlib ➊
{
.publickeytoken = (B7 7A 5C 56 19 34 E0 89 )
.ver 4:0:0:0
}
.assembly ConsoleApp1 ➋
{
--snip--
}
.module ConsoleApp1.exe ➌
// MVID: {796768DC-788B-4A50-85E3-0615D98C7C6D}
.imagebase 0x00400000
.file alignment 0x00000200
.stackreserve 0x00100000
.subsystem 0x0003 // WINDOWS_CUI
.corflags 0x00020003 // ILONLY 32BITPREFERRED
// Image base: 0x00000274A3D40000
// =============== CLASS MEMBERS DECLARATION ===================
.class public auto ansi beforefieldinit Hello ➍
extends [System.Runtime]System.Object
{
.method public hidebysig static void Main(string[] args) cil managed ➎
{
.entrypoint
.custom instance void System.Runtime.CompilerServices.
NullableContextAttribute::.ctor(uint8) = ( 01 00 01 00 00 )
// Code size 11 (0xb)
.maxstack 8
IL_0000: ldstr "Hello World!"
IL_0005: call void [System.Console]System.Console::
WriteLine(string)
IL_000a: ret
} // end of method Hello::Main
.method public hidebysig specialname rtspecialname ➏
instance void .ctor() cil managed
{
// Code size 7 (0x7)
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance void [System.Runtime]System.Object::.ctor()
IL_0006: ret
} // end of method Hello::.ctor
} // end of class Hello
CIL 以外部程序集声明开始 ➊,注意 .publickeytoken
指令用来唯一标识引入的强名称程序集,确保使用正确版本。随后是实际程序集声明 ➋,再后是模块声明 ➌,包含镜像基址、应用环境等重要属性。
实际类声明 ➍ 包括你定义的 Main 方法 ➎ 和隐式构造函数方法 ➏。CIL 指令相对简单,如 ldstr
和 call
。不过,对于更复杂的应用(比如 LiteDB Studio),自己阅读会更费力。
若你不带 /out
参数运行 ildasm.exe
,会打开一个图形界面,以树形结构显示程序集。此界面对深入逆向不够用。你可以改用开源反编译工具 ILSpy,从 github.com/icsharpcode… 下载最新安装包,打开 LiteDB Studio 的 .exe
文件。
ILSpy 会自动解析 .NET 头信息,加载程序集时显示如下信息:
// C:\Users\Default\Downloads\LiteDB.Studio.exe
// LiteDB.Studio, Version=1.0.3.0, Culture=neutral, PublicKeyToken=null
// Global type: <Module>
// Entry point: LiteDB.Studio.Program.Main ➊
// Architecture: AnyCPU (32-bit preferred)
// Runtime: v4.0.30319
// Hash algorithm: SHA1
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
[assembly: CompilationRelaxations(8)]
[assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)]
[assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)]
[assembly: AssemblyTitle("LiteDB.Studio")]
[assembly: AssemblyDescription("A GUI tool for LiteDB v5")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("LiteDB")]
[assembly: AssemblyProduct("LiteDB.Studio")]
[assembly: AssemblyCopyright("MIT")]
[assembly: AssemblyTrademark("")]
[assembly: Guid("0002e0ff-c91f-4b8b-b29b-2a477e184408")]
[assembly: AssemblyFileVersion("1.0.3.0")]
[assembly: TargetFramework(".NETFramework,Version=v4.7.2", FrameworkDisplayName = ".NET Framework 4.7.2")]
[assembly: ComVisible(false)]
[assembly: AssemblyVersion("1.0.3.0")]
这里最重要的信息是入口点 ➊,你可以在 ILSpy 中点击它,快速跳转到反编译的方法。
ILSpy 的一个非常有用功能是“分析(Analyze)”,右键点击任意成员名即可访问。它会显示一个树状结构,列出使用该成员或被其使用的其他成员,特别适合做汇点到源头的追踪分析。例如,若你识别 LiteDB.Studio.MainForm.ExecuteSql
作为潜在的漏洞汇点,可以用分析功能找到它被五个其他方法调用,然后沿“Used By”树一路追溯至合适的祖先方法。
当然,你不必局限于 ILSpy 界面。你也可以在左侧栏右键程序集,选择“保存代码(Save Code)”导出反编译的源码。然后可运行自动化代码分析工具或手工审核。类似的,很多 IDE 也支持打开反编译源码并提供类似 ILSpy 的分析工具。其它反编译器如 JetBrains dotPeek 和 dnSpyEx 也内置调试器,支持 .NET 程序集的动态分析。
Java 字节码
类似于 .NET 框架中的公共中间语言(CIL),Java 也使用一种中间表示,由通用运行时平台——Java 虚拟机(JVM)执行。与 CIL 一样,Java 字节码使用比机器码更高级的指令集,但不同于 CIL,Java 字节码还使用局部变量数组形式的寄存器。
通常,你会见到以 Java 归档文件(JAR,后缀 .jar
)形式分发的 Java 二进制文件。
与 .NET 程序集类似,JAR 文件将字节码(Java 类文件)、资源和元数据打包成单个文件。但不同于封装 .NET 程序集的 PE 格式,JAR 文件本质上就是 ZIP 压缩包,可以用任何归档工具解压。执行时必须用 Java 可执行程序运行,不能直接执行,这点稍显不便。
下面通过将“Hello World”示例程序改写为 Java,来观察 CIL 和 Java 字节码之间的一些区别,如 Listing 4-10 所示。
// Hello.java
class Hello {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
安装 Java 开发工具包(JDK),编译并运行该程序:
$ sudo apt install default-jdk
$ javac Hello.java
$ java Hello
Hello World!
接着,使用 Java 自带的反汇编器,显示所有类和成员及堆栈信息:
$ javap -p -v Hello.class
Classfile Hello.class
Last modified 30 May 2023; size 416 bytes
SHA-256 checksum 4f0ee00df8e3ff6d3cdf8cac7ad765819369ee1602b15e9a2a2b67076fb36e44
Compiled from "Hello.java"
class Hello ➊
minor version: 0
major version: 63
flags: (0x0020) ACC_SUPER
this_class: #21 // Hello
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool: ➋
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Class #10 // java/lang/System
#9 = NameAndType #11:#12 // out:Ljava/io/PrintStream;
#10 = Utf8 java/lang/System
#11 = Utf8 out
#12 = Utf8 Ljava/io/PrintStream;
#13 = String #14 // Hello World!
#14 = Utf8 Hello World!
#15 = Methodref #16.#17 // java/io/PrintStream.println:(Ljava/lang/String;)V
#16 = Class #18 // java/io/PrintStream
#17 = NameAndType #19:#20 // println:(Ljava/lang/String;)V
#18 = Utf8 java/io/PrintStream
#19 = Utf8 println
#20 = Utf8 (Ljava/lang/String;)V
#21 = Class #22 // Hello
#22 = Utf8 Hello
#23 = Utf8 Code
#24 = Utf8 LineNumberTable
#25 = Utf8 main
#26 = Utf8 ([Ljava/lang/String;)V
#27 = Utf8 SourceFile
#28 = Utf8 Hello.java
{
Hello();
descriptor: ()V
flags: (0x0000)
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public static void main(java.lang.String[]); ➌
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #7
3: ldc #13
5: invokevirtual #15
8: return
LineNumberTable:
line 3: 0
line 4: 8
}
SourceFile: "Hello.java"
除了实际的字节码指令,类文件还包含类元数据 ➊、常量池 ➋ 和方法信息 ➌。
通过分析元数据和指令,反编译器能够近似还原原始源码。但从源码编译到中间表示时,会丢失部分信息,因此反编译回源码并不完全一致。例如,变量的导入可能被替换为直接使用其解析后的值。
你可以通过逆向 Pixel Wheels(一个用 Java 编写的俯视角赛车游戏,支持 Linux、macOS、Windows 和 Android)来验证这点。从 github.com/agateau/pix… 下载 pixelwheels-0.24.2-linux64.zip,解压后会得到 pixelwheels 二进制文件和 pixelwheels.jar 文件。类似之前 PyInstaller 示例,直接用 strings
查看二进制会有提示:
$ strings pixelwheels
--snip--
/lib/server/libjvm.so ➊
/...
JNI_GetDefaultJavaVMInitArgs
JNI_CreateJavaVM
/proc/self/exe
*Z4mainEUlSt8functionIFPvS0_EERK14JavaVMInitArgsE_
void sajson::value::assert_type(sajson::type) const
/storage/gitlab-runner/builds/HVzmC8hq/0/NimblyGames/packr/PackrLauncher/src/main/headers/
sajson.h ➋
Error: failed to create Java VM!
这里有多条 Java 相关字符串,强烈暗示该二进制是 JAR 文件的包装器 ➊。此外还有 “PackrLauncher” ➋,这是一个为 JAR 文件打包本地可执行程序的工具(github.com/libgdx/pack…),说明你应将重点放在 JAR 文件上。
首先选择一个 Java 反编译器。常见免费或开源选项有 IntelliJ IDEA 自带的 Fernflower、Procyon 和 JD-GUI。Fernflower 更新更及时,JD-GUI 带有图形界面,便于快速浏览类和成员间关系,类似 ILSpy。Fernflower 和 Procyon 是命令行工具,需要结合 Java IDE(如 IntelliJ IDEA)查看结果。
目前,你只需比较反编译输出和原始源码,可以使用 Fernflower。访问 mvnrepository.com/artifact/co…,选择最新版本,下载对应的 JAR 文件。
将反编译器 JAR(重命名为 java-decompiler-engine.jar
)和 pixelwheels.jar
放在同一目录,运行:
$ mkdir output
$ java -jar java-decompiler-engine.jar pixelwheels.jar output/
几秒钟后,output
目录会生成反编译后的 pixelwheels.jar
,解压即可获得源码。
初学者可能不知从何下手,因资源文件和目录较多,如 musics
,Java 文件分布在不同目录如 com
和 javazoom
。
较好的起点是查看 META-INF/MANIFEST.MF
文件,里面指明主类为 com.agateau.pixelwheels.desktop.DesktopLauncher
,对应源码文件路径 com/agateau/pixelwheels/desktop/DesktopLauncher.java
,为源码分析提供便利入口。
反编译输出与原始源码高度一致,可从发布页获取源码。比如在 DesktopLauncher.java
中,除了注释和空白外,主要差异是使用了导入的常量值。
为观察从源码编译到中间表示再反编译时丢失多少信息,看看 DesktopLauncher.java
中的 setupLogging
函数(Listing 4-11):
private static void setupLogging(PwGame game) {
String cacheDir = FileUtils.getDesktopCacheDir();
File file = new File(cacheDir);
if (!file.isDirectory() && !file.mkdirs()) {
System.err.println(
StringUtils.format(
"Can't create cache dir %s, won't be able to log to a file", cacheDir));
return;
}
String logFilePath = cacheDir + File.separator + Constants.LOG_FILENAME; ➊
LogFilePrinter printer = new LogFilePrinter(logFilePath, Constants.LOG_MAX_SIZE);
NLog.addPrinter(printer);
NLog.addPrinter(new SystemErrPrinter());
game.setLogExporter(new DesktopLogExporter(printer));
}
这里,Constants.LOG_FILENAME
被导入并用于构造 logFilePath
➊。
对比反编译代码(Listing 4-12):
private static void setupLogging(PwGame game) {
String cacheDir = FileUtils.getDesktopCacheDir();
File file = new File(cacheDir);
if (!file.isDirectory() && !file.mkdirs()) {
System.err.println(StringUtils.format(
"Can't create cache dir %s, won't be able to log to a file", cacheDir));
} else {
String logFilePath = cacheDir + File.separator + "pixelwheels.log"; ➊
LogFilePrinter printer = new LogFilePrinter(logFilePath, 1048576L);
NLog.addPrinter(printer);
NLog.addPrinter(new SystemErrPrinter());
game.setLogExporter(new DesktopLogExporter(printer));
}
}
代码中未再导入 Constants
,而是用字面量字符串 "pixelwheels.log"
➊ 替代。编译过程中,为优化性能,导入变量被解析为本地常量池中的值。
你可用 javap
反汇编类文件确认:
private static void setupLogging(com.agateau.pixelwheels.PwGame);
descriptor: (Lcom/agateau/pixelwheels/PwGame;)V
flags: (0x000a) ACC_PRIVATE, ACC_STATIC
Code:
stack=6, locals=5, args_size=1
0: invokestatic #23
3: astore_1
4: new #24 // Class java/io/File
7: dup
8: aload_1
9: invokespecial #25 // Method java/io/File."<init>":
(Ljava/lang/String;)V
12: astore_2
13: aload_2
14: invokevirtual #26 // Method java/io/File.isDirectory:()Z
17: ifne 47
20: aload_2
21: invokevirtual #27 // Method java/io/File.mkdirs:()Z ➊
24: ifne 47
--snip--
64: ldc #38 // String pixelwheels.log ➋
66: invokevirtual #35 // Method java/lang/StringBuilder.append:
(Ljava/lang/String;)Ljava/lang/
StringBuilder;
即使不精通 Java 字节码,也能将字节码与源码对应,比如 File.mkdirs
方法调用后跟着条件跳转指令 ifne
➊,对应源码中的 if-else 语句。常量字符串通过 ldc #38
➋ 从常量池加载,并用 StringBuilder.append
调用。
反编译源码后,你可以用前几章学的代码审查策略分析代码,但需注意反编译结果不一定完全准确。比如,代码中有个名为 RemoteInput
的类(com/badlogic/gdx/input/RemoteInput.java
),它打开了默认端口 8190,但该类在应用其它部分未被使用,可能是开发者没有启用远程游戏功能。
机器码
机器码是本章探讨的三类二进制中抽象层级最低的一种。与一般二进制文件一样,机器码二进制也不是“天生平等”的。不同的编程语言,如 C++、Golang 和 Rust,会以不同方式编译成机器码,而这些差异会显著影响逆向工程的难易程度。
目前,不必直接分析他人编写的软件,你可以通过自己调整各种编译器设置,近距离观察这些差异。
我之前多次提到机器码,那它究竟是什么呢?机器码由 CPU 可直接执行的二进制指令组成,且依赖于 CPU 的指令集。一个重要的点是,机器码并不等同于汇编代码。汇编代码是机器码的人类可读形式,也就是机器码的明文表示。由于机器码和汇编的紧密关系,逆向编译机器码二进制时,通常依赖汇编语言进行分析,因为无法直接反编译回原始源码。
通过匹配机器码和汇编中的常见模式,可以将它们转成伪代码,伪代码是对原始源码的较高级别近似描述。虽然伪代码只是最佳猜测,且可靠性有限,但对于简单的例程已经足够辅助分析。
为了快速比较机器码、汇编和伪代码,你可以分析下面的用 C 语言写的“Hello World”程序:
// hello-world.c
#include <stdio.h>
int main() {
printf("hello world\n");
return 0;
}
先用 gcc 编译该程序:
$ gcc hello-world.c -o hello-world
然后在 Linux 下用 objdump -D <文件名>
反汇编机器码(macOS 用 otool -tvV <文件名>
,Windows 用 dumpbin /disasm <文件名>
):
$ objdump -D hello-world
hello-world: file format elf64-x86-64
--snip--
Disassembly of section .text:
0000000000400526 <main>:
400526: 55 push %rbp
400527: 48 89 e5 mov %rsp,%rbp
40052a: bf c4 05 40 00 mov $0x4005c4,%edi
40052f: e8 cc fe ff ff callq 400400 <puts@plt>
400534: b8 00 00 00 00 mov $0x0,%eax
400539: 5d pop %rbp
40053a: c3 retq
40053b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
输出会因操作系统和 CPU 架构不同而异,但总体模式一致,包含虚拟地址、机器码十六进制表示和对应汇编指令。
接下来,从 github.com/NationalSec… 下载并安装软件逆向框架 Ghidra,或通过命令 sudo apt-get install -y ghidra
安装。创建新项目,使用 CodeBrowser 工具分析该二进制。在右侧面板,CodeBrowser 会输出如下伪代码:
undefined8 main(void)
{
➊ puts("hello world");
return 0;
}
你可能会注意到,伪代码中调用的是 puts
而非 printf
➊。这不是 Ghidra 的错误。查看反汇编代码,会发现二进制实际上调用的是 puts
。这是 gcc 的编译器优化,将资源消耗更大的 printf
自动替换为更轻量的 puts
。(详见 github.com/gcc-mirror/…)
逆向这类二进制时,你会频繁在汇编代码的文本视图、图形视图和伪代码之间切换,还会参考可能由编译器选项编译进二进制的元数据。
接下来的章节,我们将快速探讨不同编译方式如何影响逆向机器码二进制的难度。
静态链接(Statically Linked)
静态链接的二进制文件在编译时将所使用的所有库都包含进来,而不是在运行时从系统加载外部库。这种做法有优点也有缺点。一方面,它使得二进制文件更具可移植性,因为它可以独立执行,不依赖于操作系统上是否安装了外部库。另一方面,它会导致生成的二进制文件体积更大,因为更多的机器码被包含在输出文件中。
你可以用 Go 语言实现一个“Hello World”程序来测试这一点,因为 Go 默认生成静态链接的二进制文件:
// hello-world.go
package main
import "fmt"
func main() {
fmt.Println("hello world")
}
安装 Go 并编译为 Linux x86-64 可执行文件:
$ sudo apt install golang
$ GOARCH=amd64 GOOS=linux go build hello-world.go
$ ./hello-world
hello world
$ file hello-world
hello-world: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked
该二进制文件是静态链接的。如果用 objdump
反汇编它,你会看到大量输出,因为二进制文件中包含了每个导入函数的机器指令。此外,如果你尝试查看动态符号表,你不会得到任何结果,因为没有动态链接的函数。相反,你需要导出完整符号表,才能看到静态链接进二进制的函数:
$ objdump -t hello-world
hello-world: file format elf64-x86-64
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 go.go
0000000000401000 l F .text 0000000000000000 runtime.text
00000000004021a0 l F .text 000000000000022d cmpbody
0000000000402400 l F .text 000000000000013e memeqbody
0000000000402580 l F .text 0000000000000117 indexbytebody
000000000045a760 l F .text 0000000000000040 gogo
000000000045a7a0 l F .text 0000000000000035 callRet
000000000045a7e0 l F .text 000000000000002f gosave_systemstack_switch
000000000045a820 l F .text 000000000000000d setg_gcc
--snip--
000000000047b9a0 g F .text 0000000000000042 fmt.glob..func1
000000000047ba00 g F .text 0000000000000092 fmt.newPrinter
000000000047baa0 g F .text 000000000000011a fmt.(*pp).free
000000000047bbc0 g F .text 000000000000010a fmt.(*pp).Write
000000000047bce0 g F .text 00000000000000e5 fmt.Fprintln
除了 fmt.Fprintln
,还有很多其他 Go 包和函数被包含在最终的二进制中。虽然 Go 链接器会尝试去除死代码和未使用的符号,但仍需静态链接许多 fmt
包的函数。
如果你用 Ghidra 的 CodeBrowser 生成 main
函数的伪代码,可能会得到如下结果:
void main.main(void)
{
long unaff_R14;
undefined local_18 [16];
while (&stack0x00000000 < *(undefined **)(ulong *)(unaff_R14 + 0x10) ||
&stack0x00000000 == *(undefined **)(ulong *)(unaff_R14 + 0x10)) {
runtime.morestack_noctxt.abi0();
}
local_18._8_8_ = &PTR_DAT_004b71c8;
local_18._0_8_ = &DAT_004893e0;
➊ fmt.Fprintln(1,1,&PTR_DAT_004b71c8,local_18);
return;
}
虽然这个二进制做的和之前 C 语言 “Hello World” 示例一模一样,但 Go 编译器生成的机器码对 Ghidra 来说更难理解。这是因为 Go 二进制包含了 Go 运行时,运行时执行额外的功能,比如垃圾回收和栈管理。此外,你会注意到最终输出调用的是 fmt.Fprintln
而非 Println
➊。这是因为 fmt.Println
是对 Fprintln
的封装,编译器做了优化,直接用更底层的函数,就像之前 printf
优化成 puts
一样。
动态链接(Dynamically Linked)
与静态链接二进制不同,动态链接的二进制在编译时只包含它所依赖的库的信息,而不包含库本身。操作系统会解析这些信息,并在运行时将相应的库加载到内存中。简单对比一下,可以查看 C 语言“Hello World”程序的动态符号表,使用动态符号选项:
$ objdump -T hello-world
hello-world: file format elf64-x86-64
DYNAMIC SYMBOL TABLE:
0000000000000000 DF *UND* 0000000000000000 GLIBC_2.2.5 puts
0000000000000000 DF *UND* 0000000000000000 GLIBC_2.2.5 __libc_start_main
0000000000000000 w D *UND* 0000000000000000 __gmon_start__
在 Ghidra 中,你可以点击 fmt.Fprintln
跳转到其实现的指令代码,点击 puts
会进入一个“thunk 函数”(桥接函数),它代表运行时动态加载的外部 puts
函数:
0060103f ?? ??
//
// EXTERNAL
// NOTE: This block is artificial and allows ELF Relocations
// ram:00602000-ram:0060202f
//
thunk int puts(char * __s)
Thunked-Function: <EXTERNAL>::puts
int EAX:4 <RETURN>
char * RDI:8 __s
puts@@GLIBC_2.2.5
<EXTERNAL>::puts
复杂的软件通常包含不止一个二进制文件,包括多个可执行文件和库文件。因此,在逆向时,你可能需要在不同文件间跳转,分析在一个库中实现、另一个库或可执行文件中调用的函数。
剥离(Stripped)
有时,为了节省空间或者故意阻碍逆向,开发者会选择剥离二进制文件中的调试相关信息,包括符号表。以 Golang 编译器为例,可以通过向链接器传递 -s
(去除符号表和调试信息)和 -w
(去除 DWARF 符号表)选项来实现剥离:
$ GOARCH=amd64 GOOS=linux go build -ldflags="-s -w" -o stripped hello-world.go
$ objdump -t stripped
stripped: file format elf64-x86-64
SYMBOL TABLE:
no symbols
剥离后的二进制文件在逆向时会带来很大挑战。用 Ghidra 的 CodeBrowser 分析时,会跳转到初始化 Go 运行时的默认入口,而不是直接跳转到 main
函数,因为无法再通过符号引用到 main
。不过,剥离的 Golang 二进制仍会在单独的数据结构中包含函数名,可以通过脚本还原符号名;但这对其他语言并不一定适用。
目前,你可以先通过查看未剥离二进制中 main.main
函数的虚拟地址:
objdump -t hello-world | grep main.main
然后在 CodeBrowser 中使用快捷键 G
跳转到该地址。除函数名外,汇编代码和伪代码与未剥离版本应当基本一致。
简而言之,剥离的二进制虽然增加逆向难度,但仍能通过脚本(视编译器而定)或结合机器码的功能特征重建符号名。后者需要熟练掌握汇编语言和丰富的经验,能快速识别标准库函数的常见模式。此外,也可留意日志或错误信息,它们可能提供函数功能的线索,甚至直接包含函数名。
打包(Packed)
为了进一步减小可执行文件的体积,开发者有时会使用打包器(packer)。打包器将程序压缩成自包含的可执行文件,这些文件在运行时会动态地解包、解压缩并执行原始文件。Ultimate Packer for eXecutables(UPX)是一个常用的打包器,你可以从 github.com/upx/upx/rel… 下载,或者通过各种包管理器安装。
下载 UPX 后,你可以用它对原始的 Golang “Hello World” 二进制进行打包:
upx -o hello-world-packed hello-world
根据输出结果,这样压缩后的文件大小缩减了约 60%:
File size Ratio Format Name
-------------------- ------ ----------- -----------
1850090 -> 1146320 61.96% linux/amd64 hello-world-packed
Packed 1 file.
不过,由于打包后的二进制在执行真正的机器码指令前,先运行了 UPX 的解压缩例程,因此你无法直接分析原始的机器码。
处理打包二进制时,最重要的一步是先识别它是被打包过的。下一步就是确定使用了哪种打包器。以 UPX 为例,其初始指令是众所周知的,且 UPX 会在文件头部包含魔数字节 0x55505821
(ASCII 表示为 “UPX!”)。你可以通过简单的十六进制查看确认:
> hexdump -C hello-world-packed | head -n 20
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
00000010 02 00 3e 00 01 00 00 00 08 33 5e 00 00 00 00 00 |..>......3^.....|
--snip--
000000e0 08 00 00 00 00 00 00 00 4f 05 91 f3 55 50 58 21 |........O...UPX!|
000000f0 ec 0a 0e 16 00 00 00 00 ea 3a 1c 00 6a 6c 09 00 |.........:..jl..|
--snip--
幸运的是,UPX 提供了方便的解包功能,可以使用 -d
选项轻松解压 UPX 打包的二进制:
upx -d hello-world-packed
不过,一些打包器和混淆器会采用加密数据、随机化值等复杂技术,使逆向更加困难,可能需要你从内存中导出解包和解密后的字节,或详细分析解包流程。虽然这类情况多见于恶意软件,但提前了解如何识别打包以及如何处理此类二进制文件对逆向工作非常有帮助。
总结
本章中,你浏览了不同类别的各种二进制文件,包括脚本、中间表示和机器码。你还用适当的工具和技术对每种类型的简单示例进行了逆向分析。
当你面对更大、更复杂的软件时,比如固件,可能需要同时处理多种类型的二进制文件。举例来说,一个用 JavaScript(React Native)编写的 Android 应用可能会调用一个用 Java 编译的本地模块,而该模块又可能通过 Java 本地接口(JNI)调用 C++ 库。这也是为什么你需要具备广泛的知识面,而不是过于专注于只适用于一小部分二进制的技术。
虽然本章并未详尽介绍你将遇到的所有二进制类型,但你应能总结出一些通用的分析方法,无论编程语言或编译方式如何。例如,关注元数据的来源,它们可以帮助你更有效地分析机器码,甚至将其反编译回源代码。留意语言或编译器特有的特征和优化,它们有助于识别函数名和导入。发现开发者使用了打包器或混淆器时,确定所用工具,并查阅相关文档了解是否及如何逆向它们。这些技巧将帮助你优先识别程序中最关键、最有用的部分进行逆向,这是下一章将更详细探讨的主题。