Grub2 添加背景图片

今天尝试安装了一下 Ubuntu 18.04.1,现在安装系统越来越简单了,用 Rufus 做一个 U 盘启动盘,然后就正常下一步就行了,Ubuntu 直接提供安装 Windows 和 Ubuntu 双系统的选项。

安装之后想利用 Grub2 引导器页面的背景图片功能装个逼,把笔记本电脑厂商 logo 之后、Windows 启动之前那段黑屏加一个打着自己 logo 的背景图片。研究明白之后还是很简单的,不过查的过程还是挺麻烦的。

Add a grub background image#

First, add grub background image support.

1
sudo apt install grub2-splashimages

Then, copy a image to /boot/grub/.

1
cp bg.png /boot/grub/

Now, you can use

1
sudo update-grub

to apply the change.

You can have a try now. And then, you will find that the 10 seconds timeout is really annoying.

No timeout and no menu#

Edit /etc/default/grub. Add or edit following settings.

1
2
GRUB_TIMEOUT_STYLE=hidden
GRUB_TIMEOUT=1

I will also set GRUB_DEFAULT to 2, so the highlight selected default boot choice will be the 3rd option, which means Windows boot manager.

1
GRUB_DEFAULT=2

It seems everything is ready, but grub do more things. Grub checks if you have multiple systems. If more than one system was found, grub will force timeout_style to be menu. This is not what we expected.

So open /etc/grub.d/03_os-prober, and remove the last line

1
adjust_timeout

Now, run update-grub and restart.

You can see the splash image and no menu is shown. Windows boot after a brief delay.

If you want to switch back to Ubuntu, you can press Esc during that 1 second timeout to get back the boot options menu, and select a different system to boot.

More details#

Related files and functions#

  • /boot/grub/grub.cfg
  • /etc/default/grub
  • /etc/grub.d/00_header function make_timeout()
  • /etc/grub.d/03_os-prober function adjust_timeout()

Related links#

Jekyll v.s. Hexo

最近我想用 GitHub Pages 搭建一个静态博客。最先想到的肯定是 Jekyll 了,因为这个东西是 GitHub 官方支持的。

通常来说,被 GitHub 所看中的项目,应该是非常不错的,不过用了之后才会发现一些问题。

这篇文章既包括正常的安装步骤,也包括一些吐槽,从中能发现对两者的一些看法。最后,我选择了使用 Hexo

Jekyll#

那么我们按照官网的指示来一步一步操作。

快速开始 Quickstart#

  1. 安装一个完全的 Ruby 开发环境

  2. 安装 Jekyllbundler 的 gem 包

    1
    gem install jekyll bundler
  3. ./myblog 创建一个新的 Jekyll 网站

    1
    jekyll new myblog
  4. 进入新的文件夹中

    1
    cd myblog
  5. 构建网站,使他可以在本地服务器访问

    1
    bundle exec jekyll serve
  6. 现在可以访问 http://localhost:4000

实际上我的安装过程#

我是有 Web 开发基础的,安装软件和通过命令行执行命令都是小事,看上去安装过程很方便,那么我们开始吧。

我在安装软件的时候不喜欢选择经典(Typical)或者完全(Full)通常我会选择自定义(Custom),这样我可以看到他到底做了什么,所以我没有安装快速开始的过程直接安装,而是继续往下看开始章节(GETTING STARTED)中的其他介绍。

Ruby 入门(Ruby 101)这个我就不看了,毕竟我以不打算学 Ruby,我只需要知道怎么操作就行了。

我来看看安装(Installation)的内容。

Jekyll 是一个可以安装在绝大多数系统的 Ruby Gem 包。

要求:Ruby、RubyGems、GCC 和 Make。

  • Ruby 和 RubyGems 这两个肯定没有问题,这个肯定是官方支持的。
  • GCC 是用来干什么的?不过没什么问题,大学里学过 C 语言,电脑里还留着呢(Dev-C++ 的 TDM-GCC 用着还不错)。
  • 为什么还要用到 Make 这种东西?在 Windows 下装 Make 感觉很麻烦啊!

暂时先不管,先继续往下看。下面我要看 Windows 下的安装说明。

虽然 Windows 不是官方支持的平台,但可以通过适当的调整来运行Jekyll。此页面旨在收集 Windows 用户已发现的一些常识和经验教训。

看到 不是官方支持 就感觉有些不想用这个,某些服务器软件官方不支持 Windows,这个我不会评论什么,但是给客户用的,为的是帮助用户解决日常写博客问题的软件,不官方支持 Windows 就有点令人反感了。

自己开的坑,至少研究明白再弃坑,我弄成功之后肯定要换个解决方案。

运行 Jekyll 最简单的方法就是使用 RubyInstaller for Windows

用 Installer 来安装其实挺方便的 ,就不用自己配置 PATH 之类的,安装过程基本不会出现什么问题。

不过看到 Ruby+Devkit 版本,我就有点不高兴了。 我要运行一个 Ruby 的软件,为什么还要安装 Devkit 。难道不是像 Java 一样分为 JRE 和 JDK,如果只是运行 jar 只用 JRE 就够了?

那就先下载吧 rubyinstaller-devkit-2.4.4-2-x64.exe。竟然要 115 MB,这什么鬼东西啊!Node.js 才 20MB,php 30MB,Chrome 也就 50MB, 我为什么要为了写个博客装一个 115 MB 的东西

然后就安装吧,一路默认选项,反正我已经准备好卸载了。

然后运行

1
gem install jekyll bundler

然后经过漫长的等待,终于安装好了,国内网还是有点慢的。

1
jekyll new myblog

然后

1
2
cd myblog
jekyll serve

访问 http://localhost:4000,终于成功访问了。

GitHub Pages#

GitHub 本身支持 Jekyll,可以自动编译 Jekyll 静态博客,直接按默认的 .gitignore Commit、Push 就行,部署起来应该不麻烦。

我新建了一个名为 ganlvtech.github.io 的仓库,然后

1
2
3
4
5
git init
git add .
git commit -m "Initial commit"
git remote add origin git@github.com:ganlvtech/ganlvtech.github.io.git
git push origin master

就行了。

其实我用的是 Git GUI

1
2
git init
git gui

然后就可以到 仓库设置 中启用 GitHub Pages,

然后问题就来了,静态页面的 CSS 文件加载不出来,不知道为什么。

我怀疑可能是 GitHub 不支持 minima 这个主题,我尝试修改设置里的“主题”选项,改了之后也没用。

经过一番 Bing 搜索,我找到了 About GitHub Pages and Jekyll

We recommend using Jekyll if you want the built-in support it offers, including the GitHub Pages gem to manage dependencies, specific build failure messages, and more specific help with troubleshooting.

我就打开 GitHub Pages gem 这个链接看了一下,也没太看明白

我在 Gemfile 最后添加了一行

1
gem 'github-pages', group: :jekyll_plugins

然后 Commit、Push,果然可以正常访问 https://ganlvtech.github.io/ 了。

主题#

GitHub 官方支持的主题实在不符合我的审美要求,我在 GitHub 全仓库搜索 jekyll 按 Star 排序挨个看,终于找到一个我感觉很舒服的主题,H2O

浏览过程中我发现,Jekyll 主题的下载方法竟然不是通过包管理器下载,而是手动克隆仓库,相当于把模板和 CSS 文件下载下来。

感觉这种方法有些过时,不过也没有问题,因为自己的博客要有个性化,改 CSS 应该是很常见的问题。

我下载了这个主题,感觉还不错,准备先写篇博客,然后再把主题部署上去吧,要不这个主题可能会显得光秃秃的。

新建文章#

1
jekyll help

哇! Jekyll 竟然没有自动新建文章的功能

Bing 一下 jekyll new post,结果真的没有,这个根本就不人性化,难道每次我新建文章都得自己输入日期,自己把文件前面的描述复制过来。

自己选的 Jekyll,死也要把这个东西弄完。

Gem 包版本冲突#

本来以为问题都解决了,我也没怎么多想,我就用 vscode 写了一篇博客,vscode 自带的感觉还不错。想在 Push 之前看看效果,运行

1
jekyll serve --watch

然后就提示问题了

1
2
3
4
5
6
Bundler could not find compatible versions for gem "jekyll": (Bundler::VersionConflict)
In Gemfile:
jekyll (~> 3.8.3) x64-mingw32

github-pages x64-mingw32 was resolved to 191, which depends on
jekyll (= 3.7.3) x64-mingw32

github-pages 这个 Gem 包竟然是要求版本完全相等,都不能向以后版本兼容。我都想骂人了,那种“有最新版,然而我不能用”的感觉感觉很不爽,而且还是因为一个对我本地运行无作用的包。

最终#

我想想就到这里吧,我要去寻找替代品了。

同类产品#

搜索一下 Jekyll Alternatives,WordPress 肯定是最多的啦,当然我们要的是静态博客,还有 HugoHexo,对比一下:

  • GitHub 上 Star 数差不多
  • Hexo 有中文文档,很多贡献者都是中国人
  • Hexo 之前我听说过
  • Hugo 有已编译版本,不需要下载 Go 的环境
  • 我对 Node.js npm 比较熟悉

由于我对 javascript 还是比较了解的,我想了解一下 Hexo 代码的结构,学习一下其他人代码的写法,所以我选择了 Hexo。

Hexo#

安装#

安装 Hexo 相当简单。然而在安装前,您必须检查电脑中是否已安装下列应用程序:

如果您的电脑中已经安装上述必备程序,那么恭喜您!接下来只需要使用 npm 即可完成 Hexo 的安装。

1
$ npm install -g hexo-cli

实际安装过程#

完全没有任何问题。

访问 Node.js 官方网站,直接点击下载最新版的按钮就行。然后安装时一路确定就行了。

(既然都用 GitHub Pages 了,不可能没装 Git 吧。访问 Git 官方网站,下载最新版,安装时同样一路确定。)

执行

1
npm install -g hexo-cli

在中国,如果直接用 npm 的话,下载速度可能有点慢,在执行 npm install 之前改一下 npm 设置就行了。

1
npm config set registry https://registry.npm.taobao.org -g

然后再

1
2
3
hexo init myblog
cd myblog
hexo server

访问 http://localhost:4000,我已经看到了成果。

写文章#

1
hexo new post "New Blog"

多么舒服的操作啊。

_config.yml 中可以设置默认的文件名格式,可以手动设置成像 Jekyll 那种带日期的文件名。

GitHub Pages#

这个可能是最麻烦的了,因为 GitHub Pages 默认只支持 Jekyll,所以我们必须本地编译成静态网站,然后再用 git 上传到 GitHub。

但是,Hexo 自带 deploy 功能。

先安装 git deployer

1
npm install hexo-deployer-git --save

然后设置一下 _config.yml

1
2
3
4
deploy:
type: git
repo: git@github.com:ganlvtech/ganlvtech.github.io.git
branch: master

最后执行

1
hexo deploy

完美!

自动构建#

那么,我怎么像 Jekyll 那样,上传 Markdown 自动编译成 HTML 呢?

Travis CI - Test and Deploy with Confidence

这是一个专门面向 GitHub 的持续集成的工具,可以干很多复杂的东西,部署一个博客简直太简单了。

操作过程#

首先,登录这个网站,直接通过 GitHub 的 OAuth 登录就行了。

然后,在设置中找到这个网站,激活这个网站的持续集成。

接下来,新建一个 .travis.yml 然后填入以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
language: node_js
node_js:
- "node"

branches:
only:
- dev

before_script:
- npm install -g hexo-cli
- git config --global user.name "Ganlv"
- git config --global user.email "nospam@example.com"
- sed -i "s/git@github.com:/https:\/\/${__GITHUB_TOKEN__}@github.com\//" _config.yml
- git clone -b master https://github.com/ganlvtech/ganlvtech.github.io.git .deploy_git

script:
- hexo deploy

注意,这里需要去 GitHub / Settings / Developer / Personal access tokens 去新建一个拥有 public_repo 权限的 token。然后在 Travis CI 中这个项目的设置中添加一条 __GITHUB_TOKEN__ 的环境变量。

最后把原始的代码 push 到 dev 分支就可以了。

1
2
3
4
5
6
git init
git branch -m master dev
git add .
git commit -m "Use Travis-CI"
git remote add origin git@github.com:ganlvtech/ganlvtech.github.io.git
git push origin dev

Travis 会根据设置自动为我们构建项目,并且推送到 master 分支。

改进的自动部署#

参考另一篇文章 hexo deploy v.s. Travis Pages provider

修改注册表启用 Chrome 插件

为了提高 Chrome 的安全性,我们停用了以下扩展程序,(该扩展程序未在 Chrome 网上应用店中,并且可能是在您不知情的情况下添加的)。

已自动停用

Chrome 现在禁止加载自己打包生成的扩展程序了。

无法启用

解决方法#

修改注册表#

新建一个 reg 文件,写入以下内容

extension-install-whitelist.regview raw
1
2
3
4
5
Windows Registry Editor Version 5.00

[HKEY_LOCAL_MACHINE\Software\Policies\Google\Chrome\ExtensionInstallWhitelist]
"1"="nmgnihglilniboicepgjclfiageofdfj"
"2"="lmclkaiglphlgngpneeplnjmeedgajek"

这个 nmgnihglilniboicepgjclfiageofdfj 是插件 ID,就是勾选 “开发者模式” 之后显示的那个 ID。替换成所用的插件的 ID 即可。

然后双击这个 reg 文件,导入就行了。

现在就可以启用了。

可以启用

参考文章结尾处的 Chrome 组策略模板压缩包中的 .\windows\examples\chrome.reg

修改组策略#

文章结尾处的 Chrome 组策略模板压缩包中还提供了组策略的模板,可以通过组策略允许加载。

二者的效果相同,而且组策略不适用于 Windows 家庭版用户,这里就不再赘述。

为什么 Chrome 要这么做?#

因为 Windows 并没有隔离应用程序的数据,任何程序都可以访问 Chrome 的配置文件,Chrome 自己可以安装扩展程序、启用扩展程序,其他恶意程序也可以这么做,他们可以通过简单的修改配置文件,就达到了修改浏览器行为的目的。但是组策略并不是,修改组策略是需要管理员权限的。

之前就有其他人问:为什么 Chrome 保存的密码不加密,直接就可以显示密码?Chrome 的密码其实是加密过的,只有当前登录的 Windows 用户才能解密。如果你都选择直接把电脑借给别人,而不是用访客账户,那么 Chrome 加密有什么用呢。

相关链接#

CKFinder 3 验证算法分析

这篇文章以 JavaScript 为例讲解了破解的一些通用方法。

你甚至可以将本文章作为学习 Chrome DevTools 的教学文章。

样本#

官方网站:https://ckeditor.com/ckfinder/

本次样本:CKFinder 3.4.2(PHP版)

破解目的#

下载页面

All downloads are full versions with just a few features locked. Use your license key to unlock complete CKFinder functionality, with no need to download a separate package. Try it for a limited period of time and buy it when you are ready. We prepared easy licensing options for your development purposes.

所有下载都是完整版,只有少数功能被锁定。使用您的许可证密钥解锁完整的CKFinder功能,无需下载单独的软件包。尝试一段有限的时间,并在准备就绪时购买。 我们为您的开发目的准备了简单的许可选项。

上面说了,下载到的这个文件是完整版本,只是有一些功能被锁定了。当然自己使用的话完全没什么影响,给别人的话有些麻烦。

其实没什么锁定,就是上面会有一个 Demo 的字样,每 5 分钟会弹出 Demo 版提示框,Demo 版也不能删除文件,其他功能都是全的。

CKFinder 许可协议(CKFinder License Agreement)

Unlicensed Copies

If You did not pay the License Fee, You may use unlicensed copies of the Software for the exclusive purpose of demonstration. In this case You will be using the Software in “demo mode”. Without derogating from the forgoing, You may not use the Software in “demo mode” for any of your business purposes. The Software in “demo mode” shall only be used for evaluation purposes and may not be used or disclosed for any other purposes, including, without limitation, for external distribution. You may not remove the demo notices, if any, from the user interface of the Software nor disable the ability to display such notices nor otherwise modify the Software. Product support, if any, is not offered for the Software in “demo mode”

未经许可的副本

如果您没有支付许可证费用,您可以仅以演示目的使用本软件的未经许可副本。在这种情况下,您将以“演示模式”使用本软件。 在不违背上述规定的前提下,您不得以任何商业目的在“演示模式”下使用本软件。“演示模式”中的软件仅用于评估目的,不得用于任何其他目的,包括但不限于外部分发。 您不能从软件的用户界面中删除演示提示(如有),也不能禁用显示此类通知或修改软件的功能。产品支持(如果有的话)不以提供给“演示模式”软件。

本教程仅供学习交流使用,请勿用于其他用途。

过程#

运行软件#

在代码的根目录下执行

1
php -S 127.0.0.1:8000

开启 php 内置服务器,然后访问 http://127.0.0.1:8000/ckfinder.html 即可。

CKFinder Demo

如何填写许可证信息?#

config.php 中有以下内容

1
2
$config['licenseName'] = '';
$config['licenseKey'] = '';

然后我们就使用 PHPStorm 强大的全局搜索功能吧。

PHPStorm Find in Path

Find where get license key

我们会发现,./src/CKSource/CKFinder/Command/Init.php 中有一写相关代码,然后就下断点分析一下原理吧。

分析 php 部分#

这里调试 php 需要 xdebug 的支持,安装 xdebug 步骤请参照 官方文档,这里不作具体介绍。

1
2
3
4
5
6
7
8
9
10
11
12
$ln = '';
$lc = str_replace('-', '', ($config->get('licenseKey') ?: $config->get('LicenseKey')) . ' ');
$pos = strpos(CKFinder::CHARS, $lc[2]) % 5;

if ($pos == 1 || $pos == 2) {
$ln = $config->get('licenseName') ?: $config->get('LicenseName');
}

$datacommandObject = $ln;
$data->c = trim($lc[1] . $lc[8] . $lc[17] . $lc[22] . $lc[3] . $lc[13] . $lc[11] . $lc[20] . $lc[5] . $lc[24] . $lc[27]);
// 此处省略其他代码
return $data;

似乎并没有什么有用的信息,就是把 licenseKeylicenseName 转换了一下,然后就返回给浏览器了。

分析 js 与 php 交互#

我们发现,php 断点停住以后,Chrome 开发者工具 Network 选项卡中有一个请求的状态一直处于 pending 状态。

The pending request in chrome devtools

我们直接跟踪到 XmlHttpRequest 的调用点。

Function stack when request sent

下个断点?先格式化代码再说吧。

代码格式化#

这一步没什么多说的,什么工具都可以,在 Chrome 开发者工具中打开 Source 标签,点击左下角的 {} 按钮,然后再复制粘贴到 ckfinder.js 中就行了。一般来说这样 uglify 的代码应该不会有文件校验吧。

解码#

我们看到代码中有很多的 S('...') 的东西,我猜是字符串解码函数,应该是作者为了避免字符串搜索。

直接在断点停住时在 Chrome 控制台中把那个表达式粘贴上,执行一次试试。解码成功了,看样子不算太麻烦。

String decode function

Chrome 断点停住时,控制台的上下文是断点语句处的上下文,可以访问局部变量,所以断点处调用了 S('...') 的语句,你在控制台执行的话,S 函数也一定存在。

自动解码#

因为 JavaScript 的字符串太特殊了,使用字符串匹配的话很麻烦,我这里选择分析 AST(抽象语法树),针对 AST 进行替换。

首先安装 acorn 语法分析器和 escodegen 代码构造器,一个用来从代码生成 AST,一个用来把 AST 转换回代码。

1
npm install acorn escodegen

下面是我写的替换代码,判断了一下字符串和三元运算符

decode-ckfinder.jsview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
const acorn = require('acorn');
const walk = require('acorn/dist/walk');
const escodegen = require('escodegen');
const fs = require('fs');
const path = require('path');

function S(e) {
for (var t = '', n = e.charCodeAt(0), i = 1; i < e.length; ++i)
t += String.fromCharCode(e.charCodeAt(i) ^ i + n & 127);
return t;
}

function recursiveDecode(node) {
if (node.type === 'Literal') {
node.value = S(node.value);
// console.log(node.value);
} else if (node.type === 'ConditionalExpression') {
recursiveDecode(node.consequent);
recursiveDecode(node.alternate);
} else {
console.log('Node type is neither Literal nor ConditionalExpression. ' + node.start);
}
}

// 这里改成你的代码位置
var inputFile = path.join(__dirname, 'ckfinder/ckfinder.min.js');
var outputFile = path.join(__dirname, 'ckfinder/ckfinder.js');

fs.readFile(inputFile, {encoding: 'utf-8'}, function (err, data) {
if (err) {
console.log(err);
return;
}
var ast = acorn.parse(data);
walk.simple(ast, {
CallExpression: function (node) {
if (node.callee.type === 'Identifier' && node.callee.name === 'S' && node.arguments.length === 1) {
var arg0 = node.arguments[0];
recursiveDecode(arg0);
if (arg0.type === 'Literal') {
node.type = arg0.type;
node.value = arg0.value;
} else if (arg0.type === 'ConditionalExpression') {
node.type = arg0.type;
node.test = arg0.test;
node.consequent = arg0.consequent;
node.alternate = arg0.alternate;
}
}
}
});
var code = escodegen.generate(ast);
fs.writeFile(outputFile, code, function (err) {
if (err) {
return console.log(err);
}
console.log('The file was saved!');
});
});

使用方法

1
node decode-ckfinder.js

分析过程#

我们用 PHPStorm 打开 ckfinder.js,使用 PHPStorm 的代码定位直接找到 S 函数。

Go To Declaration

然后我们就找到了解码方法,这段代码已经嵌入我的解码代码中了。

1
2
3
4
5
function S(e) {
for (var t = "", n = e.charCodeAt(0), i = 1; i < e.length; ++i)
t += String.fromCharCode(e.charCodeAt(i) ^ i + n & 127);
return t
}

运行结果#

1
2
3
4
5
var CKFinder = function () {
function __internalInit(e) {
return e = e || {}, e['demoMessage'] = 'This is a demo version of CKFinder 3', e['hello'] = 'Hello fellow cracker! We are really sad that you are trying to crack our application - we put lots of effort to create it. ' + 'Would you like to get a free CKFinder license? Feel free to submit your translation! http://docs.cksource.com/ckfinder3/#!/guide/dev_translations', e['isDemo'] = !0, e;
}
// 后面省略了

哈哈,作者发现我们破解了他的软件了。

你好,你们这些破解者!我们真的很伤心,您正试图破解我们的应用程序——我们付出了很多努力来创建它。你想获得免费的 CKFinder 许可证吗?放心地提交您的翻译!http://docs.cksource.com/ckfinder3/#!/guide/dev_translations

其实很多软件作者挺有意思的。这可能是最简单的暗桩吧,就是一个提醒字符串。

继续分析 js#

This is a demo version of CKFinder 3 这句话就是我们要找的。

后面还有一句 e['isDemo'] = !0,就是 e['isDemo'] = true,莫非我改成 false 就 OK 了?

在 Chrome 中下个断点,看看什么情况。

根本就没断下来,看来作者跟我们开了个玩笑。不过想想也对,怎么能这么容易就让你破解了呢。

尝试 DOM 断点#

现在我们的线索断了,不过我们有个笨方法。在 XHR 的调用点断下之后,下 DOM 断点(当 DOM 节点修改的时候会断下),然后运行,直到插入的 node 就是那个 This is a demo version of CKFinder 3 的标题的时候,我们再继续分析。

subtree modifications

这个过程可能比较枯燥,就是不断的继续运行,继续运行,直到那个被添加的 node 是 h2 的时候。

Search through the call stack

非常抱歉,我没找到……

新的想法?#

为什么我们不能直接搜索到 This is a demo version of CKFinder 3 呢?因为肯定是被加密了啊,那么我们直接找出所有乱码字符串就行了。

我在 decode_ckfinder.js 中加了一行 console.log(node.value);(就是上面注释掉的那一行) 这一行会打印所有的一次解码之后的字符串,然后我们就排查一下吧,反正才 6246 行,不到五分钟差不多就能看完。

还真让我找到了。

Unreadable text 1

Unreadable text 2

Unreadable text 3

直接在代码中搜索其中一个字符串,定位到附近,下断点,执行一次。

Break and evaluate in console

这个就是我们要找的了,断点之后单步运行,把这句话运行完,然后修改一下 t['message'] 的值,看看效果。

Change it to some other text

可以手动把 t['message'] = [......]['map'](n)['join'](' ') 这些加密后的东西全部替换为原来的 t['message'] = 'This is a demo version of CKFinder 3' 这种语句。替换后的文件可以在文末附件下载。

看来可行,然后我们就逆着调用栈找,找到判断语句。

Find along call stack

类比推理#

String.fromCharCode

似乎,所有的加密都有 String.fromCharCode,我们直接搜索一下这个语句,应该就能找到所有的字符串加密,他们周围有其他验证的判断语句,直接 if (false) 掉。

if (false) 这种方法在汇编语言里怎么表示?

一种方法是 jnz 变成 jmpnop,另一种是 jnz xxx 变成 jnz 00

内存断点#

上面这种方式好麻烦啊,我们还要猜原来作者是怎么想的。有没有方法直接在读取 $data->c(就是返回给 js 的那个许可证) 的时候断下来。

这个东西不就是内存断点嘛,只不过 Chrome 不支持(据说 Firefox 是支持的),不过 StackOverflow 上的朋友们已经给出了解决方案。

我是用 Bing 搜索 chrome var changed breakpoint 搜到的。

https://stackoverflow.com/questions/11618278/how-to-break-on-property-change-in-chrome/38646109#38646109

https://github.com/paulirish/break-on-access

Create a code snippets

Source Snippets New snippet 中粘贴下列代码,然后右键运行。(如果没有 Snippets 注意一下 >> 这个按钮)

break-on.jsview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
function breakOn(obj, propertyName, mode, func) {
// this is directly from https://github.com/paulmillr/es6-shim
function getPropertyDescriptor(obj, name) {
var property = Object.getOwnPropertyDescriptor(obj, name);
var proto = Object.getPrototypeOf(obj);
while (property === undefined && proto !== null) {
property = Object.getOwnPropertyDescriptor(proto, name);
proto = Object.getPrototypeOf(proto);
}
return property;
}

function verifyNotWritable() {
if (mode !== 'read')
throw "This property is not writable, so only possible mode is 'read'.";
}

var enabled = true;
var originalProperty = getPropertyDescriptor(obj, propertyName);
var newProperty = { enumerable: originalProperty.enumerable };

// write
if (originalProperty.set) {// accessor property
newProperty.set = function(val) {
if(enabled && (!func || func && func(val)))
debugger;

originalProperty.set.call(this, val);
}
} else if (originalProperty.writable) {// value property
newProperty.set = function(val) {
if(enabled && (!func || func && func(val)))
debugger;

originalProperty.value = val;
}
} else {
verifyNotWritable();
}

// read
newProperty.get = function(val) {
if(enabled && mode === 'read' && (!func || func && func(val)))
debugger;

return originalProperty.get ? originalProperty.get.call(this, val) : originalProperty.value;
}

Object.defineProperty(obj, propertyName, newProperty);

return {
disable: function() {
enabled = false;
},

enable: function() {
enabled = true;
}
};
};

debugger; 这条语句可以让调试器直接断在这个位置处,配合数据绑定(给一个对象的属性设置 getter 和 setter),就可以做到内存断点了。然后在调用栈向上找一层,就是断点触发的位置了。

注意:断点断在数据修改之前。

Break on o.c read

在下方控制台执行

1
breakOn(o, 'c', 'read');

这样,任何代码在访问许可证秘钥信息的时候就会断下,然后在调用栈往上找一层就可以了。

注册机#

单步跟踪一下程序的流程就会找到验证函数,可以尝试分析一下算法,然后写一个注册机。

先简单在 Chrome DevTools 中浏览一下,看一下调用栈,大概理解一下过程。

Algorithm 1

Algorithm 2

验证算法分析#

然后,枯燥的东西就来了。我们借助 PHPStorm 强大的静态分析能力,我们可以追踪到全部与这个有关的代码,然后综合在一起分析一下验证算法。

这类加密算法在代码中有很多处,这里以页面上方的 This is a demo version of CKFinder 3 为例。

爆破非常简单,把 if (!(u && a && d && l) || c) 改成 if (false) 就行了,但是注册机的话,我们要研究明白这里的 u a d l c 都代表什么,怎么算。我们的目的就是,找到算法,使 u a d l 均为 true,使 cfalse

u#

1
u = e(s.config.initConfigInfo.c, f(10));

这里的 s.config.initConfigInfo.c 来自后端传来的许可证。然后继续追查 f

1
2
3
4
5
f = f || function(e) {
return function(t) {
return e.charCodeAt(t);
};
}(o(s.config.initConfigInfo.c));

继续追查 o

1
2
3
4
5
6
7
8
9
function o(e) {
var t, n, i;
for (i = '',
t = '123456789ABCDEFGHJKLMNPQRSTUVWXYZ',
n = 0; n < e.length; n++)
i += String.fromCharCode(t.indexOf(e[n]));
return o = void 0,
i;
}

这里的 o 似乎是个一次性函数,在 return 之前它把自己给变成了 undefined(就是 void 0),似乎为了隐藏什么东西。

编译之后的 js 全是各种奇怪的闭包,我们得好好分析一下。

o 的参数是后端传来的许可证,把许可证字符串的每一字节映射一下,比如输入许可证是 'ABCD',就会返回 '\u0009\u000a\u000b\u000c'

然后这个返回值被传给了生成 f 的一个闭包,f 的作用很简单,charCodeAtfromCharCode 用途正好相反,o 的作用是 ASCII 码转字符,f的作用就是字符转 ASCII 码。比如 f(10) 就会返回许可证的第 11 个字符在 '123456789ABCDEFGHJKLMNPQRSTUVWXYZ' 中的位置。

注意:这里说 ASCII 只是针对英文来说的,准确的说,javascript 中 charCodeAtfromCharCode 使用的是 Unicode 编码,在 0 - 127 的范围二者相同。

我们可以简化一下 f

1
2
3
function f(t) {
return '123456789ABCDEFGHJKLMNPQRSTUVWXYZ'.indexOf(s.config.initConfigInfo.c[t]);
}

然后继续研究 e 函数。

1
2
3
4
5
6
7
8
9
10
11
function e(e, t) {
for (var n = 0, i = 0; i < 10; i++)
n += e.charCodeAt(i);
for (; n > 33; ) {
var r = n.toString().split('');
n = 0;
for (var o = 0; o < r.length; o++)
n += parseInt(r[o]);
}
return n === t;
}

参数 1 是许可证,参数 2 是刚刚分析的 f(10)

n 为许可证前 10 个字符的 ASCII 码加和,然后把这个数字的每一位加和,再把结果每一位加和,直到结果小于 33,只要这个结果等于 f(10) 的话,返回值就是 true

为什么是 33 呢?因为这个映射列表 '123456789ABCDEFGHJKLMNPQRSTUVWXYZ' 的长度是 33

我们已经解决一个字符了,我们知道了第 11 位由前 10 位生成的算法了。

化简一下这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function e(licenseKey, f10) {
var tmp = 0;
for (var i = 0; i < 10; i++) {
tmp += licenseKey.charCodeAt(i);
}
while (tmp > 33) {
var tmp1 = tmp.toString().split('');
tmp = 0;
for (var i = 0; i < tmp1.length; i++) {
tmp += parseInt(tmp1[i]);
}
}
return tmp === f10;
}

a#

1
2
3
4
5
6
!function() {
var e = f(4) - f(0);
f(4) - f(0),
0 > e && (e = f(4) - f(0) + 33),
a = e < 4;
}()

分析一下

1
2
var e = (f(4) - f(0) < 0) ? (f(4) - f(0) + 33) : (f(4) - f(0));
a = e < 4;

再化简一下就是

1
a = (f(4) - f(0) + 33) % 33 < 4;

% 是求余数,前面加 33 是因为负数求余数并不会变成正数。

现在,我们知道了许可证第 5 位和第 1 位的约束关系,第 5 位必须在第 1 位之后的 4 个字符以内,比如第 1 位是 A,第 5 位必须是 A B C D 之一。

d#

1
d = t(f(7), e(f(4), f(0)), s.config.initConfigInfo.s);
1
2
3
4
5
function e(e, t) {
var n = e - t;
return 0 > n && (n = e - t + 33),
n;
}

化简一下

1
d = t(f(7), (f(4) - f(0) + 33) % 33, s.config.initConfigInfo.s);

分析 t 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function t(e, t, n) {
var i = window.opener ? window.opener : window.top
, r = 0
, o = i['location']['hostname'].toLocaleLowerCase();
if (0 === t) {
var s = '^www\\.';
o = o.replace(new RegExp(s), '');
}
if (1 === t && (o = ('.' + o.replace(new RegExp('^www\\.'), '')).search(new RegExp('\\.' + n + '$')) >= 0 && n),
2 === t)
return !0;
for (var a = 0; a < o.length; a++)
r += o.charCodeAt(a);
return o === n && e === r + -33 * parseInt(r % 100 / 33, 10) - 100 * ('' + r / 100 >>> 0);
}

化简一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function t(f7, f4_f0, licenseName) {
var hostname = window.opener.location.hostname.toLocaleLowerCase();
if (0 === f4_f0) {
hostname = hostname.replace(new RegExp('^www\\.'), '');
}
if (1 === f4_f0) {
hostname = hostname.replace(new RegExp('^www\\.'), '');
if (('.' + hostname).search(new RegExp('\\.' + licenseName + '$')) >= 0) {
hostname = licenseName;
} else {
hostname = false;
}
}
if (2 === f4_f0) {
return true;
}
var tmp = 0;
for (var i = 0; i < licenseName.length; i++) {
tmp += licenseName.charCodeAt(i);
}
return hostname === licenseName && f7 === tmp % 100 % 33;
}

很明显,这个是检查域名的。

  • 如果 f(4) - f(0) === 0 则只会把最前面的 www. 去掉。
  • 如果 f(4) - f(0) === 1 则会把最前面的 www. 去掉,然后检查是否以 n 结尾,不是则 o = false,是则 o = n,就是那个结尾。
  • 如果 f(4) - f(0) === 2 直接返回 true,这个正合我们的想法。

然后如果 f(4) - f(0) !== 2 的话,计算替换完 o 的 ASCII 码加和,赋值给 r,把 r 的百位数去掉,然后取 33 的余数,如果这个等于 f(7) 则这一条符合。另一条是判断 o === n。两条同时符合则返回 true

这里的第 8 位是受域名约束的,不过我们直接 f(4) - f(0) === 2 就好了。

l#

1
2
3
4
5
function() {
var e = f(5) - f(1);
0 > e && (e = f(5) - f(1) + 33),
l = e - 1 <= 0;
}()

没什么说的,我们已经很熟练了。

1
l = (f(5) - f(1) + 33) % 33 <= 1;

c#

1
c = !e(f(8), f(9), f(0), f(1), f(2), f(3));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function e(e, n, i, r, o, s) {
for (var a = window['Date'], l = 33, u = i, c = r, d = o, f = s, c = l + (u * f - c * d) % l, d = u = 0; d < l; d++)
1 == c * d % l && (u = d);
c = e,
d = n;
var h = 10000 * (225282658 ^ t.m);
return f = new a(h),
12 * ((u * s % l * c + u * (l + -1 * r) % l * d) % l) + ((u * (33 + -1 * o) - 33 * ('' + u * (l + -1 * o) / 33 >>> 0)) * c + u * i % 33 * d) % l - 1 >= 12 * (f['getFullYear']() % 2000) + f['getMonth']();
}
var t = {
s: function(e) {
for (var t = '', n = 0; n < e.length; ++n)
t += String.fromCharCode(e.charCodeAt(n) ^ 255 & n);
return t;
},
m: 92533269
};

看到 Date 了,这个肯定是限制日期的。

我们之前已经用到了 f(0), f(1), f(4), f(5), f(7), f(10),加上这个验证的 f(0), f(1), f(2), f(3), f(8), f(9),除了 f(6) 都用到了,看样子快结束。

先化简一下吧,化简这段算法可能需要点数学水平。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function e(f8, f9, f0, f1, f2, f3) {
var c = 33 + (f0 * f3 - f1 * f2) % 33,
u = 0;
for (var d = 0; d < 33; d++)
if (1 == c * d % 33) {
u = d;
}
var _f1 = 33 - f1,
_f2 = 33 - f2;
return 12 * (
(
u * f3 % 33 * f8
+ u * _f1 % 33 * f9
) % 33
) + (
u * _f2 % 33 * f8
+ u * f0 % 33 * f9
) % 33
>= 211;
}

化简完之后,发现这个其实不是限制日期的,这里的日期用的是一个固定值 t.m 和当前时间无关。这是一个同余问题的验证算法。或许是可解的,但是我比较笨,只能穷举了。(如何穷举后面会讲)

还有 php 的部分#

1
2
3
4
5
6
7
8
9
10
11
$ln = '';
$lc = str_replace('-', '', ($config->get('licenseKey') ?: $config->get('LicenseKey')) . ' ');
$pos = strpos(CKFinder::CHARS, $lc[2]) % 5;

if ($pos == 1 || $pos == 2) {
$ln = $config->get('licenseName') ?: $config->get('LicenseName');
}

$data->s = $ln;
$data->c = trim($lc[1] . $lc[8] . $lc[17] . $lc[22] . $lc[3] . $lc[13] . $lc[11] . $lc[20] . $lc[5] . $lc[24] . $lc[27]);
?>

可以看到,php 代码并没有把全部的许可证信息都传递给浏览器,只传递了 $lc1, 8, 17, 22, 3, 13, 11, 20, 5, 24, 27 这几位,另外检测了 $lc[2] 许可证域名类型。

也就是说这个证书至少 28 位,并且只有其中的一部分是有用的,*???-*?**-?**?-*?**-*?**-?*?*-?**? 这里面 * 表示任意字符,? 表示需要我们计算的地方。

总结一下算法#

php 反向转换函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function lc(c) {
var map = [1, 8, 17, 22, 3, 13, 11, 20, 5, 24, 27];
var key = '*???-*?**-?**?-*?**-*?**-?*?*-?**?'.replace(/-/g, '').split('');
for (var i = 0; i < map.length; ++i) {
key[map[i]] = c[i];
}
var result = [];
for (var i = 0; i < key.length; i += 4) {
var result_part = '';
for (var j = i; j < i + 4 && j < key.length; ++j) {
result_part += key[j];
}
result.push(result_part);
}
return result.join('-');
}

基本转换函数

1
2
3
4
5
6
7
8
function c(f) {
var map = '123456789ABCDEFGHJKLMNPQRSTUVWXYZ';
var result = '';
for (var i = 0; i < f.length; ++i) {
result += map[f[i]];
}
return result;
}

f10f0 ~ f9 生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function f10(c) {
var tmp = 0;
for (var i = 0; i < 10; ++i) {
tmp += c.charCodeAt(i);
}
while (tmp > 33) {
var tmp1 = tmp.toString().split('');
tmp = 0;
for (var i = 0; i < tmp1.length; ++i) {
tmp += parseInt(tmp1[i]);
}
}
return tmp;
}

f4f0 相关,分三种情况:

  • 单域名许可证:www.example.comexample.com 可用
  • 含通配符域名许可证:*.example.comexample.com 可用
  • 不限制域名许可证:任意域名均可使用。
  • 其实还有第四种,就是真正的单域名,完全匹配许可证:仅 foo.example.com 可用

单域名许可证

1
f4 = f0;

含通配符域名许可证

1
f4 = (f0 + 1) % 33; // 或 f0 = (f4 - 1 + 33) % 33;

不限制域名许可证

1
f4 = (f0 + 2) % 33; // 或 f0 = (f4 - 2 + 33) % 33;

完全匹配域名许可证

1
f4 = (f0 + 3) % 33; // 或 f0 = (f4 - 3 + 33) % 33;

如果是不限制域名许可证则不验证 f7,如果是单域名许可证或含通配符域名许可证,算法如下

1
2
3
4
5
6
7
function f7(licenseName) {
var tmp = 0;
for (var i = 0; i < licenseName.length; ++i) {
tmp += licenseName.charCodeAt(i);
}
return tmp % 100 % 33;
}

然后是 f5f1 的制约关系

1
2
3
f5 = f1;
// 或
f5 = (f1 + 1) % 33; // 或 f1 = (f5 - 1 + 33) % 33;

然后就是这个麻烦的算法,f8, f9, f0, f1, f2, f3 这些变量相互制约。这是一个六元一次同余不等式,没有常规方法解出所有组整数解,只有穷举法。还好,这六个变量都不会受到其他不等式的制约。

假设我们已经求出一组 f0, f1, f2, f3, f8, f9,由 f0 得到 f4,由 f1 得到 f5,由域名得到 f7,随机生成一个 f6,再由他们生成 f10,再由 f 函数反推出原始许可证的每一位。这就是我们生成许可证的算法。

穷举法#

现在就差这个穷举这个六元不等式了。不过看起来这个不等式的解是非常多的,任意给定一组 f0, f1, f2, f3,穷举 f8, f9 即可(这些数字的取值集合都是 0 ~ 32 的整数)。

这里提供一个穷举法的例子,只要 f0, f1, f2, f3 不取得过于极端(比如 0, 0, 0, 01, 1, 2, 2),基本上都有上百组 f8, f9 的解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function f8f9(f0, f1, f2, f3) {
var c = 33 + (f0 * f3 - f1 * f2) % 33;
var u = 0;
for (var i = 0; i < 33; ++i) {
if (1 == c * i % 33) {
u = i;
}
}
var _f1 = 33 - f1, _f2 = 33 - f2;
for (var f8 = 0; f8 < 33; ++f8) {
for (var f9 = 0; f9 < 33; ++f9) {
if (12 * ((u * f3 % 33 * f8 + u * _f1 % 33 * f9) % 33) + (u * _f2 % 33 * f8 + u * f0 % 33 * f9) % 33 >= 211) {
return [f8, f9];
}
}
}
return null;
}

成果#

最终的注册机可以在文末下载附件。

社工方法#

在我做到一半的时候,我突发奇想看看官网上的演示页面,然后发现官网上的演示页面用的竟然不是演示版本。

Demo on CKFinder offical website

然后抓包看到了他的秘钥 8EB6AF82KAF,并且这个许可证还是不限域名的许可证,根据上面的算法分析还原得到原来的许可证为 *8?A-*K**-E**8-*F**-*B**-2*6*-A**F,域名随意。

Key from offical demo

试了一下结果真的可以,看来官方并没有意愿去防止破解。

总结#

我们来分析一下破解软件的通用流程,不只是 JavaScript 破解。

这次的 JavaScript 破解#

  • 我们首先找 licenseKey 这个字符串,然后追查到了 XmlHttpRequest。
  • 然后因为破解的需要,找到了 S 函数,然后解码了字符串加密。
  • 然后找到了 This is a demo version of CKFinder 3 这个字符串,发现被作者骗了。
  • 再之后,我们使用 XHR 断点和 DOM 断点,找到了真正写入这个字符串的位置,通过调用栈找到了另一个解码函数。
  • 然后,我们分析了程序逻辑,直接将 if 判断改成 if(false)
  • 接着,我们使用类比法找到了所有含 String.charCodeAt 的位置,把这些位置的判断都去掉了。
  • 我们换了另一种套路,内存断点,轻松地找到了验证函数的位置。

通用思路#

  • 字符串搜索
  • 找到通用的API入口下断点(在 Windows 下就是跨模块调用,Javascript 中就是 XHR 断点或 DOM 断点)
  • 断下之后下内存断点(比如 OD 左下角的内存区域)
  • 其他语言或编译器的特性(比如易语言的按钮、窗口特征等)
  • 单步运行推理逻辑(比如 OD 左上角的汇编指令区域)同时结合上下文变化分析(比如 OD 右上角的寄存器区域)
  • 沿着调用栈向上找(比如 OD 右下角的堆栈区域)
  • 类比推理

附件#

去除字符串加密的版本#

这里提供一个经过字符串解密之后的版本,有兴趣的同学可以尝试破解这个,字符串解密之后,字符串搜索就变得简单了。

注册机#

KeyGen

可以选择四种许可证之一,填好所需的域名,然后生成即可。查看网页源代码就可以看到生成算法了。

KeyGen

CE 实例教程 Getting Over It

注意事项#

学习基础#

本篇默认您有 CE 基础,可以将 CE 自带的 Tutorial 轻松破解,文中有很多东西都是用破解 Tutorial 的操作来说明的。

CE 版本问题#

我用的是目前的最新版 Cheat Engine 6.7

CE 语言问题#

我使用的是英文版,表示某按钮或某菜单的时候我会用英文,说明其用途的时候我会用中文。

我个人不反感汉化版,但也不反感英文原版。有的地方英文原文的语法含义是很微妙的,中文不一定能翻译出原文的意义。我学习老外的软件都是直接读英文文档的,英文文档虽然看起来有些费劲,但是某些软件英文文档比中文的要多得多,这对于软件理解(以及对于英语的学习),等等都是有很大帮助的。

涉及内容#

涉及 CE 技巧#

  • 查找数值、查找未知数值,以及相关技巧
  • 指针查找的技巧
  • 代码注入
  • 调试技巧
  • 游戏变速
  • Cheat Table 的一些功能
  • Lua 脚本的使用
  • 使用 Trainer 生成 exe 文件

教程中会夹杂大量的 CE 使用技巧以及一些破解的常识,这些技巧和常识的作用可能比使用 CE 的熟练度更大(就像电子竞技中的意识与操作一样)。

使用什么游戏?#

《和班尼特福迪一起攻克难关》(掘地求升)

最近很火的游戏,Unity引擎,游戏引擎大了之后,会搜索到很多没有用的数值,这些数值都是中间量,直接改是没有用的,必须找到他们最初始的计算来源,这样才能达到破解的目的。

关于游戏版本的问题,这里讲的是通用的搜索方法,即使游戏版本不一样,搜索过程也应该是相似的,修改方法也应该是相似的,不一样的应该只有基址。

你们看到的,教程里写的,都是我真实经历过的,但这不代表我仅仅经历了这些,实际付出的心血至少是描述出来的好几倍。教程里只写了尝试成功了的部分,尝试失败了的话只有继续尝试。

我们要做什么形式的外挂?#

  1. 游戏的目的是一直往上爬嘛,要是能把 Y 坐标改到足够高就可以了嘛。
  2. 能不能把游戏的重力改小一点,就像最后通关时可以飘着。
  3. 爬雪山的时候太滑了,能不能把雪山的摩擦改大一点。
  4. 我想随意飞行!
  5. 能不能加个存档功能?虽然我不太想开挂,但是总掉下去很烦啊!

我目前只研究出来了第一个,而且还不是很完善。

外挂是 1% 的灵感加上 99% 的汗水。

我能想到的辅助方法有这些,你们有什么想法可以自己尝试。

研究过程#

搜索坐标#

首先,我们猜测,储存坐标数值的是 Float 类型,且 X 轴正方向向右,Y 轴正方向向上。

为什么这么猜测?

没有为什么,因为我是试出来的←_←(开玩笑的,即使是试出来的,也得知道从哪个开始试嘛)

为什么是 Float 类型?

首先,大家要对浮点数有一些基本的了解,计算机中的浮点数使用 IEEE754 标准,Float 大约有 7 位有效数字,Double 大约有 15 位有效数字。

然后,想象自己是开发这个游戏的程序员,我这个游戏是否需要 15 位的精度,7 位精度的 Float 类型够不够用。
如果我使用了 Double 类型,那么这个游戏将比使用 Float 类型要慢,要更耗费资源。
综上所述,我选择把 Float 和 Double 都试一下,先试 Float,然后就发现蒙对了。

然后是坐标轴方向。

数学中坐标系分为左手系和右手系两种,数学中常用的右手系,而计算机屏幕则通常使用左手系。计算机屏幕的原点((0, 0) 点)在左上角,往右是 X 轴正方向,往下是 Y 轴正方向。

计算机中涉及跟图像有关的,一般都用左手系,而涉及跟物理有关的通常都用右手系。比如安卓编程,绘图的 API 都是以左上角为原点,往右是 X 轴正方向,往下是 Y 轴正方向,而和传感器有关的,都是手机屏幕以正常方向朝向自己时,往右是 X 轴正方向,往上是 Y 轴正方向。

两种坐标系

图片引用页

Windows 窗口坐标系

图片引用页

安卓传感器坐标系

图片引用页

综上所述,我在装逼。

其实,我没有去纠结 Y 轴正方向的问题,反正 X 轴都是向右为正方向,我就先找出 X 轴的位置,然后 Y 坐标一般都是 X 坐标偏移 4 字节嘛。

好的,装逼结束。

那我们为什么要知道坐标轴的正方向?

因为,我们不知道任何与坐标有关的信息,包括我现在在哪,移动多远是 1 单位长度。如果不确定正方向的话,我们只能使用 Changed valueUnchanged value 两种搜索类型。如果确定了坐标轴正向之后,就可以使用 Increased valueDecreased value 了,更方便找到准确的数值以及派出错误的数值。

好的开始搜索。

使用 Scan Type: Unknown initial valueValue Type: Float 方式进行 First scan

然后回到游戏,往右挪几锤子,尽量挪远一点,避免因为变化太小,导致 CE 忽略了变化量。再用 CE 搜索 Increased value

然后就重复上述过程。秘计——“反复横挑”。往左挪,搜索 Decreased value,往右挪,搜索 Increased value

这里有个技巧,可以给“搜索 Increased value”、“搜索 Decreased value”设置快捷键,避免总在游戏和 CE 之间来回切换。

设置搜索快捷键

另外,Getting Over It 支持窗口化模式,可以方便在玩游戏的同时,监视 CE 的数值。

Getting Over It 窗口化设置

经过了数十轮的查询,我这里还剩下 96 个数值。这就是当今的游戏引擎,实在烦人,弄出这一堆没用的中间变量,改了也没用,还得自己手动筛选。

经过了数十轮查询的搜索结果

如果你研究《侠盗猎车手:罪恶都市》的时候才不会出现这么多数值呢。越现代的引擎,约有这个问题,随便打开一个游戏都占几百 MB 的内存,一大堆中间数值。还有建模越来越精细,顶点越来越多了,一个物体的每个顶点都坐标都要存在内存中,坐标数值都相近。结果就是,一搜索一大堆数值,剩下的都是同一个物品,靠筛选是去除不掉的。

还剩下 96 个,不算特别多,本来以为会剩下好几千个呢。96 个的话可以直接全部添加到下方的列表中,然后全部锁定,看看有没有效果。

全部添加到下方的列表中,然后全部锁定

好的,有效果,不过不是我们想要的效果。我的搜索结果似乎出了一些问题。锁定之后,我的鼠标动了之后会回弹,相机位置会回弹,但是罐子和人并不会回弹。

这时,我们有两条路可以走,一是先把这些研究完,二是重新来过(不过可能得到的是相同的结果)。

我选择第一条路,先把鼠标位置的内存和相机位置的内存找到,然后在研究人和罐子的事。

从少量搜索结果中筛选#

96 个内存地址,一般来说只有一个是相机的 X 坐标,只有一个是鼠标的 X 坐标。我们怎么找到这两个坐标呢?

方法一:穷举法,96个每个都锁定一下,然后就知道是哪个了(算法时间复杂度 O(1),平均时间 N / 2 )。

方法二:

  • 小时候的智力题,9个球有1个比较重,用天平称两次把它找出来。
  • 高中数学学过什么?二分法求零点。
  • 大学计算机基础学过什么?快速排序法。基数排序法。二叉树。

所以你想到了什么?这是一种思想,叫做“分治”。

只锁定前一半,如果鼠标位置被锁定了,则证明控制鼠标位置的在前一半里,然后再锁定前四分之一,如果鼠标可以移动了,那么证明在 1/4 ~ 2/4 的位置之中,依次类推,最后找到鼠标位置的地址。(算法时间复杂度 O(nlogn),平均时间 log2(N) )。

找到结果,复制粘贴,往后调整 4,得到 Y 坐标地址

然后使用 Ctrl + CCtrl + V 复制一个地址,并把地址偏移设置为 4,因为 Float 类型占 4 字节,所以下一个浮点型就在 4 字节以后。

同理找相机坐标。

游戏崩溃

游戏崩溃了,你们可以想象一下我的表情,就像那个暴漫 WTF 的脸 -_-! 没办法,我们下次小心点吧。正好我们想重新搜索一下呢。

从大量搜索结果中筛选#

搜索剩余大量相近结果

经过我们的“反复横跳”,还剩大约五千个数值(我就说不能只有 96 个嘛,不符合现代引擎的风格),如果继续“反复横跳”,筛选得就非常慢了。这是我们开始我们的“分治”方法。不过,将近五千个数值,如果添加到下方的列表中说不定会卡死,就算添加到列表没卡死,同时锁定的话也说不定会卡死,我们就又白干一场了。

那怎么办呢?

注意我们找到的地址是一组一组的,有单个的,有几个一组的,也有几百个一组的。如果你是编写这个程序员的,你会把人物坐标这种东西和几百个同类的东西连在一起作为一个结构体吗?那个几百个的应该是一个数组。凭我的感觉,应该是某某图形的顶点坐标构成的数组。

所以我们怎么办?先从少的开始试。我觉得从后往前不错,然后就大约 10 个 ~ 20 个一组开始尝试。

我找到了四个能在游戏中体现出具体意义的数值:鼠标坐标,锤子头坐标,罐子坐标,摄像机坐标。

反复尝试得到的结果

鼠标坐标就是,显示鼠标位置那个圆圈。
锤子头坐标,罐子坐标,就是字面意思,这个游戏的锤子头与罐子是两个独立的单元。
摄像机坐标就是画面的位置。

可以直接改一改锤子头和罐子的 Y 坐标,把 Y 坐标改成 100 ,你会发现,飞上去然后又掉下来了。再改成 200300,效果自己尝试,改的恰到好处的话就直接飞上去了,改的不好的话就卡在石头上或者被石头挤飞了。

没玩好的话,就会像下面这样,并且不断地发出“嗯”“啊”“额”,闭上眼睛想象一下“FA♂Q”的场景。

改数值时没玩好,卡在墙里了

其实摄像机还有 Z 轴,不信你改一改 +8 处的值。你再改一改 +C +10 … 的值,发现摄像机还有观察的角度,专业点叫 pitch, yaw, roll 翻译为俯仰角、偏航角、翻滚角。

调整摄像机参数

然后既然摄像机有 Z 轴,有旋转角。难道人就没有吗?

筛选的过程中,我隐约感觉到,除了锤子头和罐子的坐标外,锁定某些数值的话,可能与会影响棍子的坐标和角度,不过暂时先不考虑。

确定地址的位置#

玩笑开完了,我们下一步应该做什么呢?你有两条路可以走:1. 找基址,2. 使用 Advanced Option

找基址的方式可能比较简单,我们之后会简单的提一下,这里先说如何利用这个 Advanced Option ,以及他的作用。

首先选中一个地址(我使用了罐子的 X 坐标),右键然后“找出是什么改写了这个地址”,也可以使用快捷键 F6

然后动一动罐子,找到那个看上去相关的代码,然后把他添加到代码列表中去。

查找写入地址的代码

什么叫“看上去相关”?

并不是找出的所有指令都是有用的,有些指令可能与游戏并没有什么关联。比如图中那几个命中次数非常少的指令。

什么样的代码可能相关?

一种就是,你的坐标移动,那条指令的计数器就增加,不动则不增加。

另一种就是排除法,你的坐标在移动,但是指令计数器不增加,那就不相关。为什么使用排除法?

有些情况,无论你动不动,有些指令的计数器都在增加。这时,如果是单机游戏可以暂停的话,暂停之后那条指令如果还在增加,则通常是不相关的。

看完上面这一步,知道为什么要将游戏窗口化了吗?因为我们要一边在游戏中改变数值,一边看 CE 中的计数器变化。

忽然想到一个名词,生物学中很常用的实验方法——“控制变量法”。

那么我们找到这条指令有什么用吗?

想做修改器,肯定不能用动态的地址,所以必须要找基址。有些情况找基址并不是那么容易,况且,就算找到这个地址,你如果直接修改这个数值,游戏中也依然会出现跳动的情况。我们的应对方案就是代码注入,因为代码在内存中的地址是固定的。

为什么 CE 的锁定不是那么管用呢?

锁定间隔

所谓的锁定,其实就是不断写入。不过这里的写入是在进程外写入,与游戏肯定不会同步。这里就要扯一点游戏引擎了(不同引擎可能有所区别)。

现代的游戏引擎大致是什么原理呢?

游戏的物理引擎和绘图引擎并不是运行在同一线程上的,不同线程都不是一起运行的,他们的栈肯定也不是共用的,所以他们之间只能通过堆内存来沟通。这带给我们一个好处就是,堆内存是可以通过 CE 很方便地进行搜索的。但也带给我们一个问题就是,你修改数值的时机必须恰到好处,恰好在一个线程写入,另一个线程读取之前修改 (这里可能存在一些问题,) ,这通常在线程外是很难做到的。总之多线程技术给我们带了很多麻烦,导致我们不得不去使用代码注入。

什么是线程?

一个进程中可以有多个线程,不同线程是“同时”运行的,按照各自的指令执行,线程之间共享内存,都可以访问进程的内存。)

什么是栈?

栈也是一块内存区域,通常用于储存函数调用的层次、函数的参数、函数内的局部变量,栈是快速变化的,函数被调用的时候栈会增长,函数调用结束之后栈又会复原,同一个内存地址并不是永远归一个函数或结构所拥有。栈本身的内存是可以跨线程访问的,但是由于线程之间不同步,栈又是快速变化的,跨线程访问只会得到不确定的结果。)

什么是堆?

堆内存是通过申请的方式要来的,C语言的函数 malloc 就是申请堆内存的,使用的过程中,这块内存保证不会被分配给其他人,只要不使用 free 释放掉,这块内存就一直会被保留(直到程序运行结束),如果 mallocfree 没有平衡,则会造成内存泄漏。

线程列表

刚才是不是跑题了。我们找到这条指令有什么用吗?

找基址的套路比较固定,但有时候,还真的不能通过套路的方法找到基址,要么需要尝试半天才能找到,要么就是几百个地址等待你去一个一个地尝试。但是如果我们找到了访问或写入该地址的代码,然后在这里下断点,读取寄存器的值就可以找到我们想要的地址了,Advanced Option 正好可以完成这项任务。(我这个教程写了好几天,我总不能每次打开游戏都重新搜索一遍吧,使用“高级选项”的代码列表功能可以在不找出基址的情况下,方便我们下一次打开游戏直接找到内存地址)

找出代码改写的地址

双击找到的地址可以将其添加到地址列表中。

尝试修改坐标#

又扯了一大堆废话,我们在破解的过程中一定要“不忘初心,牢记使命”。

我们想做什么样的外挂来着?改 Y 坐标。

尝试把罐子的 Y 坐标改的足够大,比如说 300,好的,人上去了,锤子还留在下面,然后又被拉回下面了。那我们把锤子的 Y 坐标改的足够大,这回似乎可以,人被锤子拉上去了。不过这样的 用挂体验极差

  • 单纯改 Y 坐标,会导致人物位置出现不确定性。
  • 如果我们锁定住了 Y 坐标,但是重力依然会令其不断的下落。
  • 这么改的话,一点游戏乐趣都没有了。我们的想法是做一个辅助程序,而不是直接通关程序。辅助程序可以根据自己的需要启动和关闭,是受使用者操控的。(就比如网易的《荒野行动》,客户端判断子弹命中,连全屏秒杀都能做出来,体验一下是挺爽,但是没有开挂游戏乐趣了。)

外挂应该怎么做呢?我的想法是,把原本的只能用锤子操控,改成加上一个WASD方向键来辅助。所以先来尝试一下把。

Tutorial 的第五关做了什么?我们先找出谁改写了罐子的 Y 坐标,然后 Replace with code that does nothing,把那条代码替换成无用代码。

替换为无用代码

另外几条指令是怎么回事呢?通过观察发现,每次撞击障碍物的时候,次数会加一,应该是和碰撞有关。

现在我们真的悬空了。不过,与此同时我们的锤子也不能动了。不过问题不大,只要能飞,锤子朝哪里不都一样嘛。

悬空

现在,没有游戏线程写入的干扰了,我们可以在 CE 中随意修改罐子的 Y 坐标,看看是不是可以上下运动。罐子上去了,但是锤子还没有飞上去。

罐子飞上去了,但锤子没飞上去

这个方案看样子不是很好,我突发灵感想到了另一个方案,所以先把这个搁置一会。当我们锁定罐子的 Y 坐标的时候(不要将代码替换成 NOP),我们看到闪烁的画面,人是越落越快的。如果我们固定垂直速度为 0,不就可以悬空了吗。

研究速度#

如何搜索呢?先在空中锁定 Y 坐标,让其在空中不断下落。这是虽然人会回归原位,但是 Y 速度是一直增大的。注意,向下落的速度应该是负数,越落越快应该是速度的 Y 轴负方向越来越大,所以搜索的时候应该是搜索减小的数值,如果因为速度过大,撞到了地面上,那就再搜索增大的数值。

使用“未知初始值”搜索 Float 数据,然后配合“减小的数值”和“增大的数值”。最终剩下了几千个数值,不过不要紧,我们知道角色属性应该是一个结构体,应该存储在相邻的位置,好的,仅保留罐子坐标附近的值,然后通过锁定尝试一下。然后锁定成正数试试,锁定成负数试试。最后我们确定了,Y 速度在 Y 坐标 +14 偏移的位置,同理 X 速度也在 X 坐标 +14 偏移的位置。

确定速度地址

What’s the fuck! 为什么又改回英文版了?前几张图片用的还是汉化版呢!

因为我发现中文版翻译水平不是很好,我用中文版的时候,有的地方突然找不到我想找的选项了。

使用上述对待 Y 坐标的方法来对待 Y 速度——首先替换成无用代码,这时 Y 速度就不变了,甚至也没法通过锤子来使自己改变 Y 速度了。只能眼睁睁地看着他 Y 方向作匀速直线运动。其实这时我们可以在 CE 中修改速度数值,因为游戏部分的代码已经不会再影响速度了,我们直接在进程外写入就可以影响游戏了。

锁定的时候我们发现了一点问题,如果锁定的时候速度大于某个数之后,人就可以慢慢往上飘了。(我自己试的时候大约是 3.5

这意味着什么?想一想高中物理,v = v_0 - g * Δt (负号表示重力方向向下),这个程序会不断地读取这个 Y 速度,然后减去一个速度差,最后再写回去,同时还用这个速度计算 x = x_0 + v * Δt。这样,我们锁定这个数值(每 0.1 秒写入一次),就可以保证我们一直处于一个 0.1 秒落回原处,并且再次紧接竖直上抛。

其实现在,第一个外挂的雏形已经做出来了。只要不断地向 Y 速度写入一个较大的数,我们可以像玩 Flappy Bird 一样玩 Getting Over It 了。

设置修改数值热键

这里我给 X 速度和 Y 速度设置了 WASD 的快捷键,使用 WASD 即可操作速度了。现在 Getting Over It 可以改名为 Flappy Bird 了。

好好地一个竞速类游戏 Getting Over It,竟然让你们玩成了休闲游戏 Flappy Bird

Flappy Bird

修改窗口标题#

创建一个自动汇编脚本,写入以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[ENABLE]

{$LUA}
hwnd = findWindow(null, "Getting Over It")
return "define(hwnd,#" .. hwnd .. ")"
{$ASM}

alloc(caption,20)
alloc(newmem,1000)

caption:
db 'Flappy Bird',0

newmem:
push eax
push caption
push hwnd
call user32.SetWindowTextA
pop eax
ret

createthread(newmem)

[DISABLE]

dealloc(caption)
dealloc(newmem)

启用这个脚本。

CE 是不是很牛逼?

查找重力#

我们想到了最前面提出的第 2 种外挂的想法,减小重力。能不能只减小重力,而不影响其他的东西。而且,与重力加速度有关的代码,一定就在写入 Y 速度的指令附近。重力加速度这个东西,我们什么都不知道,根本没法搜索(可以在末尾)

我们之前分析了引擎中可能的写法,读取 Y 速度,加上 g * Δt,然后再写回去。那么怎么办呢?首先先找到访问 Y 速度的代码 Find out what access this address。注意,是访问的代码,因为我们要找的“加上 g * Δt”的代码一定是在写入 Y 速度之前,如果只查找写入的代码的话,我们单步调试的时候想做到往前走还是很麻烦的,所以要用“访问的代码”,这样读取和写入的指令都会被记录,我们从读取的开始分析就可以了。

访问 Y 速度的指令

这里注意,必须在操作游戏的同时注意指令计数器,找到对我们有用的代码。最后我们分析得出,这两条指令的中间对 Y 速度进行了某种操作。

1
2
GettingOverIt.exe+2A419 - 8B 58 44              - mov ebx,[eax+44]
GettingOverIt.exe+2A9FA - 89 57 44 - mov [edi+44],edx

简单讲讲汇编语言#

反汇编指令

参考 mov ebx, [eax+44] 的详细信息。

mov 指令,后面有两个操作数,这句话的含义就是把内存中地址为 eax+44 的4字节内容读出来,然后写入 ebx 寄存器中。

在上面的情况中就是把 2CE9BC80 + 44 也就是 2CE9BCC4 的地址的值读取出来写入 ebx,这个 2CE9BCC4 正好就是我们监视的内存地址,也就是 Y 速度的内存地址。执行完这条指令之后,ebx 就是我们的 Y 速度了,由于断点断在指令执行之后,所以这里的 ebx4094625F 代表的就是 Y 速度,我们可以根据 IEEE754 标准解码一下,数值为 4.637008,这个应该是第一次触发这条指令时候的 Y 速度。

IEEE754 转换器

寄存器,可以理解为一个临时变量,每个寄存器有他们惯例的用途,不过这仅仅是惯例,你想用他们做其他事也可以(除了 esp 栈顶指针以外)。

通常我们使 eax, ebx, ecx, edx 做普通的操作,虽然这些寄存器也有一些特殊的用途,但是用途较少所以一般的操作用这些寄存器就可以。

esiedi:看他们的名字,叫做“源索引寄存器”和“目标索引寄存器” (source indexdestination index),因为在很多字符串操作指令用的,在其他的寄存器的时候不用这几个,当然如果你能掌控得好的话 esiedi 其实也可以随便使用。

espesp 是不可以乱动的,esp 指向堆栈顶部,pushpop 指令会影响 esp,由于寄存器的数量太少了,我们编写程序时需要的变量有很多,所以使用内存来辅助我们,函数的局部变量就会保存在栈中,调用函数的参数也会保存在栈中。
比如说,现在我们的寄存器都用完了,我需要腾出一个寄存器来做其他事情。那么就把寄存器的变量放到内存(栈)中(push),然后就可以对这个寄存器为所欲为了,然后用完之后,再把栈中的值提取出来,放回寄存器。

1
2
3
4
5
6
7
push eax
; 做一堆有关eax的事
; 比如:
; mov eax, [ebp+04]
; add eax, [edx]
; mov [ebp+04], eax
pop eax

上面的代码做了什么?push eax 相当于 sub esp, 04 加上 mov [esp], eax,栈的最顶上多出来一个数值,同时栈指针减少 4。(入栈,栈指针会减小;出栈,栈指针会增大。栈的那一块内存是从地址最大的地方开始往地址较小的方向使用的。)

最后要说说 ebp 了,ebp 通常用于进入一个函数时,记录当前的栈指针位置

常见的函数开头

1
2
3
4
5
6
7
8
9
10
11
12
13
14
push ebp ; 保存上一个 ebp
mov ebp, esp ; 把 esp 给 ebp
sub esp, 08 ; 分配8字节栈空间用于局部变量
; 函数主体内容...
; 在这里可以使用 ebp 来定位与函数有关的变量
; [ebp-08] 代表第 2 个局部变量
; [ebp-04] 代表第 1 个局部变量
; [ebp] 刚才 push 进来那个 ebp
; [ebp+04] 函数返回之后的要执行的指令所在位置
; [ebp+08] 代表函数的第 1 一个输入参数
; [ebp+0C] 代表函数的第 2 个输入参数
add esp, 08
pop ebp ; 复原 ebp
ret 08 ; 返回函数调用位置,并且把栈指针 +8,把调用的参数从栈中移除

Stack View

如果能驾驭得了的话 ebp 也是可以随意使用的。

最后的 eipInstruction pointer)始终指向下一条要执行的命令,这个寄存器通常我们不要去动他。

然后就是单步调试了,单步调试时必须的,但是却不一定能得到想要的结果。

先使用 Show disassembler 切换到反汇编窗口,然后按 F5 下断点,然后这条指令会变成绿色。

反汇编调试

然后回到游戏中,应该立刻就触发断点了。

触发断点

我们的Y速度是 2CE9BCC4eax+442CE9BD64。这说明了什么?想起 Tutorial Step 9: Shared code 了吗?这段代码是很多东西共用的,不过这个游戏不存在敌我区别,可能问题并不算特别大。

我们可以先取消断点,然后右键 Find out what addresses this instruction accesses 然后会看到很多地址,证明这部分代码的确是共用代码。

找出这条指令访问了哪些地址

那么怎么调试呢?

用条件断点。先 F5 设置断点然后,右键 Set/Change break condition

设置条件断点

填入正确的 EAX 表达式。注意:区分大小写,并且必须带 0x 前缀。这里的 EAX 就从前面找出访问 Y 速度的指令那个窗口中复制过来就行了,实在不行可以自己手动算一算 Y 速度的地址 -44 等于多少,其实你也可以填写 EAX + 0x44 == 2CE9BCC4 这样的表达式,这是一个 Lua 表达式。

填写条件断点条件

这时再返回游戏,这是就会只断在 Y 速度上。继续我们的单步调试。

我们先从断点处往下浏览一下,凡是与 [ebp-04] 有关的代码都很可疑。

1
2
3
4
5
6
7
8
9
10
11
12
GettingOverIt.exe+2A419 - 8B 58 44              - mov ebx,[eax+44]
GettingOverIt.exe+2A41C - 89 5D FC - mov [ebp-04],ebx
......
GettingOverIt.exe+2A4F5 - F3 0F10 6D FC - movss xmm5,[ebp-04]
GettingOverIt.exe+2A4FA - 0F57 F6 - xorps xmm6,xmm6
GettingOverIt.exe+2A4FD - 0F5A ED - cvtps2pd xmm5,xmm5
GettingOverIt.exe+2A500 - F2 0F58 EA - addsd xmm5,xmm2
......
GettingOverIt.exe+2A553 - F2 0F59 E2 - mulsd xmm4,xmm2
GettingOverIt.exe+2A557 - 0F57 D2 - xorps xmm2,xmm2
GettingOverIt.exe+2A55A - 66 0F5A D4 - cvtpd2ps xmm2,xmm4
GettingOverIt.exe+2A55E - F3 0F11 55 FC - movss [ebp-04],xmm2

这些可以直接进行浮点运算的指令、可以直接操作XMM寄存器的指令,都属于 SSE 指令集。简单地讲讲这几条指令的含义。

  • movss 表示 Move Scalar Single,移动标量单精度浮点值。
  • xorps 表示 XOR Packed Single,压缩单精度浮点值逻辑位异或。
  • cvtps2pd 表示 Convert Packed Single to Packed Double,压缩单精度浮点值转换成压缩双精度浮点值。
  • addsd 表示 Add Scalar Double,标量单精度浮点值加法。
  • mulsd 表示 Multiply Scalar Double,标量单精度浮点值乘法。
    以此类推。

每条汇编指令的名字起得都是有意义的,好好学习英语可以帮助我们更好地理解他们哟。

看到一条 SSE 指令,要把他拆成两部分,“操作”和“数据类型”,

第一部分:操作。movxoraddmul 这些指令,x86 最基础的指令集中也有。

第二部分:数据类型。sssdpspd 这一部分又要拆分成两部分来看。第二位是 s 表示是 single 单精度浮点型,一个数据占 32 位,第二位是 d 表示 double 双精度浮点型,一个数据占 64 位。第一位是 s 表示只操作 XMM 寄存器的第一个数据(ss 就是 32 位,sd 就是 64 位),第一位是 p 表示同时操作全部 128 位数据(ps 就是 432 位,pd 就是 264 位)。

xor 后面的两个操作数相同的话就是用来清零的,比如 xor eax,eax 就是令 eax0,这是最简单最快捷的寄存器清零方法。对于 XMM 寄存器同样也是清零。

cvt 指令就是浮点数精度的转换,主要看 sd 的位置,s2d 就是 single to double 单精度浮点数到双精度浮点数,从只占 32 位变成占 64 位,反之 d2s就是双精度到单精度的转换。

调试的时候可以通过右侧的小箭头来打开 FPU 窗口监视 XMM 寄存器的值。

FPU

可以尝试把 GettingOverIt.exe+2A500 - addsd xmm5,xmm2 这句看上去极其像 v = v_0 + Δv 的代码 NOP 掉。然后你会惊讶地发现,哇塞,真巧,重力消失了。的确,真的很巧,我们只单步调试了几十行就找到了关键代码,所以现在我们可以任意飞行了,只要把这句代码 NOP 掉即可。

查找重力加速度#

我还不满足于这小小的成就,v = v_0 + Δv 找到了,那么 Δv = g * Δt 在哪里?找到了重力加速度我才会罢休。

接下来要表演的是——如何分析代码。

我说明一下我的分析思路,下面这一堆指令后面注释请从最后一行往前看,直到找到读取重力加速度的指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
GettingOverIt.exe+2A3C0 - push ebp
GettingOverIt.exe+2A3C1 - mov ebp,esp
GettingOverIt.exe+2A3C3 - sub esp,000000A8 { 168 }
GettingOverIt.exe+2A3C9 - push ebx
GettingOverIt.exe+2A3CA - push esi
GettingOverIt.exe+2A3CB - mov esi,ecx
GettingOverIt.exe+2A3CD - push edi
GettingOverIt.exe+2A3CE - lea ecx,[ebp-1C]
GettingOverIt.exe+2A3D1 - call GettingOverIt.exe+111C0
GettingOverIt.exe+2A3D6 - mov ebx,[ebp+0C] ; ebx = [ebp + 0C] = 第二个调用参数
GettingOverIt.exe+2A3D9 - movss xmm0,[ebx] ; xmm0 = [ebx]
GettingOverIt.exe+2A3DD - xor edx,edx
GettingOverIt.exe+2A3DF - movss [ebp-20],xmm0
GettingOverIt.exe+2A3E4 - cmp [esi+1C],edx
GettingOverIt.exe+2A3E7 - jng GettingOverIt.exe+2A605
GettingOverIt.exe+2A3ED - movsd xmm1,[GettingOverIt.exe+E887E8] { [1.00] }
GettingOverIt.exe+2A3F5 - xor ecx,ecx
GettingOverIt.exe+2A3F7 - mov eax,[esi+08]
GettingOverIt.exe+2A3FA - mov eax,[eax+edx*4]
GettingOverIt.exe+2A3FD - cmp dword ptr [eax],02 { 2 }
GettingOverIt.exe+2A400 - mov ebx,[eax+30]
GettingOverIt.exe+2A403 - mov edi,[eax+2C]
GettingOverIt.exe+2A406 - movss xmm2,[eax+38]
GettingOverIt.exe+2A40B - movss xmm3,[eax+48]
GettingOverIt.exe+2A410 - mov [ebp-24],ebx
GettingOverIt.exe+2A413 - mov ebx,[eax+40]
GettingOverIt.exe+2A416 - mov [ebp-08],ebx
GettingOverIt.exe+2A419 - mov ebx,[eax+44] ; 已知 [eax+44] 为 "Y 速度"
GettingOverIt.exe+2A41C - mov [ebp-04],ebx ; 令局部变量 [ebp-04] 等于 "Y 速度"
GettingOverIt.exe+2A41F - mov ebx,edi
GettingOverIt.exe+2A421 - mov [eax+24],ebx
GettingOverIt.exe+2A424 - mov ebx,[eax+30]
GettingOverIt.exe+2A427 - movss [ebp-0C],xmm2
GettingOverIt.exe+2A42C - mov [eax+28],ebx
GettingOverIt.exe+2A42F - movss [eax+34],xmm2
GettingOverIt.exe+2A434 - jne GettingOverIt.exe+2A5C1
GettingOverIt.exe+2A43A - movss xmm2,[eax+78]
GettingOverIt.exe+2A43F - cvtps2pd xmm5,xmm2
GettingOverIt.exe+2A442 - movss xmm4,[eax+4C]
GettingOverIt.exe+2A447 - mov ebx,[ebp+10] ; ebx = [ebp+10] 第三个输入参数
GettingOverIt.exe+2A44A - movss xmm6,[ebx]
GettingOverIt.exe+2A44E - cvtps2pd xmm2,xmm2
GettingOverIt.exe+2A451 - cvtps2pd xmm4,xmm4
GettingOverIt.exe+2A454 - mulsd xmm4,xmm5
GettingOverIt.exe+2A458 - movss xmm5,[eax+50]
GettingOverIt.exe+2A45D - cvtps2pd xmm5,xmm5
GettingOverIt.exe+2A460 - mulsd xmm5,xmm2
GettingOverIt.exe+2A464 - movss xmm2,[eax+0000008C] ; xmm2 = [eax+8C] 与重力加速度有关
; [eax+8C] 的值为 1,由于这个地址基于 eax,所以他是罐子的结构体中的一个属性,这可能是重力加速度的缩放因子
GettingOverIt.exe+2A46C - cvtps2pd xmm7,xmm2
GettingOverIt.exe+2A46F - cvtps2pd xmm2,xmm2 ; xmm2 = (double)xmm2
GettingOverIt.exe+2A472 - cvtpd2ps xmm4,xmm4
GettingOverIt.exe+2A476 - cvtps2pd xmm6,xmm6
GettingOverIt.exe+2A479 - mulsd xmm6,xmm7
GettingOverIt.exe+2A47D - movss xmm7,[ebx+04] ; xmm7 = [ebx+04]
; [ebx+04] 的值为 -30.0,看来这个 [ebx+04] 就是我们要找的重力加速度
GettingOverIt.exe+2A482 - cvtss2sd xmm4,xmm4
GettingOverIt.exe+2A486 - cvtpd2ps xmm5,xmm5
GettingOverIt.exe+2A48A - cvtss2sd xmm5,xmm5
GettingOverIt.exe+2A48E - cvtpd2ps xmm6,xmm6
GettingOverIt.exe+2A492 - cvtss2sd xmm6,xmm6
GettingOverIt.exe+2A496 - addsd xmm6,xmm4
GettingOverIt.exe+2A49A - xorps xmm4,xmm4
GettingOverIt.exe+2A49D - cvtpd2ps xmm4,xmm6
GettingOverIt.exe+2A4A1 - cvtps2pd xmm4,xmm4
GettingOverIt.exe+2A4A4 - cvtps2pd xmm7,xmm7 ; xmm7 = (double)xmm7
GettingOverIt.exe+2A4A7 - mulsd xmm7,xmm2 ; xmm7 = xmm7 * xmm2
GettingOverIt.exe+2A4AB - xorps xmm2,xmm2
GettingOverIt.exe+2A4AE - cvtpd2ps xmm2,xmm7 ; xmm2 = (float)xmm7
GettingOverIt.exe+2A4B2 - cvtps2pd xmm2,xmm2 ; xmm2 = (double)xmm2
GettingOverIt.exe+2A4B5 - addsd xmm5,xmm2 ; xmm5 = xmm5 + xmm2
GettingOverIt.exe+2A4B9 - xorps xmm2,xmm2
GettingOverIt.exe+2A4BC - cvtpd2ps xmm2,xmm5 ; xmm2 = (float)xmm5
GettingOverIt.exe+2A4C0 - cvtps2pd xmm2,xmm2 ; xmm2 = (double)xmm2
GettingOverIt.exe+2A4C3 - xorps xmm5,xmm5
GettingOverIt.exe+2A4C6 - cvtss2sd xmm5,xmm0
GettingOverIt.exe+2A4CA - mulsd xmm4,xmm5
GettingOverIt.exe+2A4CE - cvtpd2ps xmm4,xmm4
GettingOverIt.exe+2A4D2 - xorps xmm5,xmm5
GettingOverIt.exe+2A4D5 - cvtss2sd xmm5,xmm0 ; xmm5 = (double)xmm0
GettingOverIt.exe+2A4D9 - mulsd xmm2,xmm5 ; xmm2 = xmm2 * xmm5
; 我猜测这里的 xmm2 和 xmm5 一个是重力加速度一个是时间差。
; 下断点查看一下 XMM 寄存器中的值的: xmm2 = -30.00,xmm5 = 0.01,
; 看上去 xmm2 重力加速度,xmm5 是时间差。
; 这个 xmm5 应该是个近似值,如果想知道准确值,就需要自己手动转换了。
GettingOverIt.exe+2A4DD - movss xmm5,[ebp-08]
GettingOverIt.exe+2A4E2 - cvtpd2ps xmm2,xmm2 ; xmm2 = (float)xmm2
GettingOverIt.exe+2A4E6 - cvtss2sd xmm4,xmm4
GettingOverIt.exe+2A4EA - cvtss2sd xmm2,xmm2 ; xmm2 = (double)xmm2
GettingOverIt.exe+2A4EE - cvtps2pd xmm5,xmm5
GettingOverIt.exe+2A4F1 - addsd xmm4,xmm5
GettingOverIt.exe+2A4F5 - movss xmm5,[ebp-04] ; xmm5 = "Y 速度"
GettingOverIt.exe+2A4FA - xorps xmm6,xmm6
GettingOverIt.exe+2A4FD - cvtps2pd xmm5,xmm5 ; xmm5 = (double)xmm5
GettingOverIt.exe+2A500 - addsd xmm5,xmm2 ; xmm5 = xmm5 + xmm2 <== 从这一行开始往上看
GettingOverIt.exe+2A504 - movss xmm2,[eax+00000084]
GettingOverIt.exe+2A50C - cvtps2pd xmm2,xmm2
GettingOverIt.exe+2A50F - cvtss2sd xmm6,xmm0
GettingOverIt.exe+2A513 - mulsd xmm2,xmm6
GettingOverIt.exe+2A517 - addsd xmm2,xmm1
GettingOverIt.exe+2A51B - cvtpd2ps xmm4,xmm4
GettingOverIt.exe+2A51F - movapd xmm6,xmm1
GettingOverIt.exe+2A523 - divsd xmm6,xmm2
GettingOverIt.exe+2A527 - xorps xmm2,xmm2
GettingOverIt.exe+2A52A - cvtpd2ps xmm2,xmm6
GettingOverIt.exe+2A52E - cvtss2sd xmm4,xmm4
GettingOverIt.exe+2A532 - xorps xmm6,xmm6
GettingOverIt.exe+2A535 - cvtps2pd xmm6,xmm2
GettingOverIt.exe+2A538 - mulsd xmm4,xmm6
GettingOverIt.exe+2A53C - cvtpd2ps xmm4,xmm4
GettingOverIt.exe+2A540 - movss [ebp-08],xmm4
GettingOverIt.exe+2A545 - xorps xmm4,xmm4
GettingOverIt.exe+2A548 - cvtps2pd xmm2,xmm2
GettingOverIt.exe+2A54B - cvtpd2ps xmm5,xmm5 ; xmm5 = (float)xmm5
GettingOverIt.exe+2A54F - cvtss2sd xmm4,xmm5 ; xmm4 = (double)xmm5
GettingOverIt.exe+2A553 - mulsd xmm4,xmm2 ; xmm4 = xmm4 * xmm2
GettingOverIt.exe+2A557 - xorps xmm2,xmm2
GettingOverIt.exe+2A55A - cvtpd2ps xmm2,xmm4 ; xmm2 = (float)xmm4
GettingOverIt.exe+2A55E - movss [ebp-04],xmm2 ; 令 "Y 速度" 的临时变量等于 xmm2
......
GettingOverIt.exe+2A5E5 - mov edi,[ebp-04]
GettingOverIt.exe+2A5E8 - mov [ecx+eax+04],edi
......
GettingOverIt.exe+2A9F6 - mov edx,[ebx+eax+04]
GettingOverIt.exe+2A9FA - mov [edi+44],edx ; 重新写回 "Y 速度"

XMM 寄存器

IEEE754 XMM 寄存器

经过上述一系列的分析,我们发现了,重力加速度 [ebx+04] 和重力缩放因子 [eax+8C] 这两个数值。

通过断点把 ebx+04 的值,然后手动添加到 Cheat Table 中(当然,也可以用“找出这条指令访问的地址”),将其 -30.00 改成其他数值试试,看看改成 0 是不是失重,改成正数是不是反重力。

反重力

把重力加速度 [ebx+04] 改成正数之后,似乎所有物品都会向上飘,不只是自己的罐子(Shared Code 体现出来了),但是把重力缩放因子 [eax+8C] 这个与人相关的数值改成 -1 的话,就只有自己会向上飘,其他物品则不会,这也验证了我们的猜想。

好的,我们又完成了一个外挂的功能。

看到 [ebx+04] 是重力加速度,有没有什么想法?ebx肯定也是一个结构体的指针啊,为什么一个重力加速度要放在 +04 的位置呢?

+04 表示的是 Y 方向的加速度,而 [ebx] 则是 X 方向的加速度,根据我们的猜测应该是 0,然后这两个数值组成,重力加速度的向量 (0, -30),我们把 [ebx] 添加到 Cheat Table 中,看一看数值,确实是 0,然后再改一改 [ebx] 就可以发现,我们的猜测没有错,这里正是 X 方向加速度。

找基址#

这一步所说的“找基址”并不只是找基址,只是为了达到目标——在每次程序重启时都能直接定位到目标地址。

以下提供几种方法,实际破解的时候需要将每种方法都试一试,最后一种是备用方法,也是比较高级的方法,实现起来比较麻烦。

  1. 手动查找基址
  2. Pointer scan
  3. 代码注入

前两种方法请参考《CE 教程:基础篇》。

对于 Getting Over It 这种使用引擎并且物理计算较多的程序而言,手动查找地址可能并不是非常容易,可以尝试扫描自动扫描指针。不过自动扫描指针也有一些缺点,就是太占硬盘了。我尝试扫描指针的时候发现,刚扫描了 1 分钟就生成了 20G 的指针列表文件,而且用默认的 4 级指针还真的不一定能找到基址。

所以这里我们要讲代码注入法。

这里我就以上面刚刚找到的 Y 方向的加速度为例。

首先还是“查找什么访问了该地址”。这里我们找到了刚才分析过的那一条指令。

1
GettingOverIt.exe+2A47D - movss xmm7,[ebx+04]

然后就是代码注入了,选择 Auto Assemble 或者使用快捷键 Ctrl + A,然后使用 Full Injection 模板

代码注入

自动生成了如下代码。大括号内部的文字都是注释,前面是文件的相关描述,最后是代码注入点附近的指令。如果觉得没用的话可以删掉,只保留中间的主要内容即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
{ Game   : GettingOverIt.exe
Version:
Date : 2018-02-01
Author : Ganlv

This script does blah blah blah
}

define(address,"GettingOverIt.exe"+2A47D)
define(bytes,F3 0F 10 7B 04)

[ENABLE]

assert(address,bytes)
alloc(newmem,$1000)

label(code)
label(return)

newmem:

code:
movss xmm7,[ebx+04]
jmp return

address:
jmp newmem
return:

[DISABLE]

address:
db bytes
// movss xmm7,[ebx+04]

dealloc(newmem)

{
// ORIGINAL CODE - INJECTION POINT: "GettingOverIt.exe"+2A47D

"GettingOverIt.exe"+2A454: F2 0F 59 E5 - mulsd xmm4,xmm5
"GettingOverIt.exe"+2A458: F3 0F 10 68 50 - movss xmm5,[eax+50]
"GettingOverIt.exe"+2A45D: 0F 5A ED - cvtps2pd xmm5,xmm5
"GettingOverIt.exe"+2A460: F2 0F 59 EA - mulsd xmm5,xmm2
"GettingOverIt.exe"+2A464: F3 0F 10 90 8C 00 00 00 - movss xmm2,[eax+0000008C]
"GettingOverIt.exe"+2A46C: 0F 5A FA - cvtps2pd xmm7,xmm2
"GettingOverIt.exe"+2A46F: 0F 5A D2 - cvtps2pd xmm2,xmm2
"GettingOverIt.exe"+2A472: 66 0F 5A E4 - cvtpd2ps xmm4,xmm4
"GettingOverIt.exe"+2A476: 0F 5A F6 - cvtps2pd xmm6,xmm6
"GettingOverIt.exe"+2A479: F2 0F 59 F7 - mulsd xmm6,xmm7
// ---------- INJECTING HERE ----------
"GettingOverIt.exe"+2A47D: F3 0F 10 7B 04 - movss xmm7,[ebx+04]
// ---------- DONE INJECTING ----------
"GettingOverIt.exe"+2A482: F3 0F 5A E4 - cvtss2sd xmm4,xmm4
"GettingOverIt.exe"+2A486: 66 0F 5A ED - cvtpd2ps xmm5,xmm5
"GettingOverIt.exe"+2A48A: F3 0F 5A ED - cvtss2sd xmm5,xmm5
"GettingOverIt.exe"+2A48E: 66 0F 5A F6 - cvtpd2ps xmm6,xmm6
"GettingOverIt.exe"+2A492: F3 0F 5A F6 - cvtss2sd xmm6,xmm6
"GettingOverIt.exe"+2A496: F2 0F 58 F4 - addsd xmm6,xmm4
"GettingOverIt.exe"+2A49A: 0F 57 E4 - xorps xmm4,xmm4
"GettingOverIt.exe"+2A49D: 66 0F 5A E6 - cvtpd2ps xmm4,xmm6
"GettingOverIt.exe"+2A4A1: 0F 5A E4 - cvtps2pd xmm4,xmm4
"GettingOverIt.exe"+2A4A4: 0F 5A FF - cvtps2pd xmm7,xmm7
}

我这里先把写好的代码贴出来,然后再解释为什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
define(address,"GettingOverIt.exe"+2A47D)
define(bytes,F3 0F 10 7B 04)

[ENABLE]

assert(address,bytes)
alloc(newmem,$1000)
alloc(acceleration_base,4)
registersymbol(acceleration_base)

label(code)
label(return)

newmem:
mov [acceleration_base],ebx

code:
movss xmm7,[ebx+04]
jmp return

address:
jmp newmem
return:

[DISABLE]

address:
db bytes
// movss xmm7,[ebx+04]

dealloc(newmem)
dealloc(acceleration_base)

对比一下自动生成的代码和最后的完整代码,我们发现只多了这几行。

1
2
3
4
5
alloc(acceleration_base,4)
registersymbol(acceleration_base)
mov [acceleration_base],ebx

dealloc(acceleration_base)

alloc 是分配内存,分配一个 4 字节大的内存区域,然后把分配得到的地址保存到 acceleration_base 中。

registersymbol 命令是我们主要讲解的东西,代码注入法找地址全靠这个命令,这个一会再讲。

最后 mov 指令是写入到 newmem 的,把 ebx 的值保存到了 acceleration_base 也就是刚刚分配的那 4 字节的空间中了。

dealloc 是释放已分配的内存

把上述代码保存到当前 Cheat Table 中

保存到当前 Cheat Table 中

然后可以在最后面看到一个 Auto Assemble script,可以给他起一个名字,比如“Hook Y 加速度获取地址”,我们可以点击前面的方框或者按空格键启用它。

自动汇编脚本

一旦启用了这个脚本,我们的 registersymbol 的作用就来了。

我们刚才定义的 acceleration_base 里面的值应该是等于 ebx 的吧,或者说 [acceleration_base] 等于 ebx,并且 Y 加速度的内存地址是 ebx+04,所以我们只要在 Cheat Table 中添加一个 [acceleration_base]+4 的地址即可。没错,手动新建一个地址,地址就填 [acceleration_base]+4 就行。当然也可以使用指针的方法,基址填 acceleration_base,一级偏移填 4,就可以了。

使用 registersymbol 构造地址偏移

现在你可能懂了 registersymbol 是做什么的,用了这个命令之后,我们申请的变量地址就可以被 Cheat Table 引用了,我们就可以根据这个地址来找到目标地址。

你可能发现了,为什么显示地址为 00000004 而不是真实的地址呢?

首先,我们的代码注入必须先启用,点击“Hook Y 加速度获取地址”前面的方框启用,这时代码已经注入到了游戏中。然后,由于切出游戏时,游戏会自动暂停,这段代码并没有被执行,所以我们的 acceleration_base 还一直没有被赋值,暂时还是 0。所以呢,我们想让其显示正常的值的方法就是返回游戏中,动一下(只要能让这个代码执行一次就行,什么方法都可以)。

现在应该已经成功了。

共用代码找基址#

课堂练习:使用代码注入法找到“罐子 Y 速度”的地址。

其实我是故意的,之前的分析提到,罐子 Y 速度的访问和写入都是 Shared code 共用代码,我们如果还是简单地使用上面的方法,可能会得到几个不同的结果,我们必须在赋值之前对结构体进行检查(参考 Tutorial Step 9: Shared code,如何检查人名或者队伍)。

我依然选择上面分析得到的那句代码作为注入点

1
GettingOverIt.exe+2A419 - mov ebx,[eax+44]

我们需要的只是这里的 eax,申请一个地址,并 registersymbol,把 eax 存进去即可。不过这次我们要判断是否是我们想要的元素的 eax

在反汇编器中,右键该条指令,Find out what addresses this instruction accesses 找出这条指令访问的地址。

被这条指令修改的地址

我们找到一堆指令,第一条与最后两条条显然与我们的速度不符,倒数第二条,看前面的地址就是我们的“罐子 Y 速度”地址。我们要做的就是找不同。

找什么的不同呢?找寄存器的不同、找堆栈的不同、找 eax 附近结构体内容的不同。

右键目标地址,可以显示寄存器状态 Ctrl + R,在寄存器窗口可以看栈的情况。

显示寄存器状态

显示那几个内存地址寄存器状态,然后再对比一下,可以发现 edx 是从 05 一次递增的,edx == 5 的时候恰好是我们的想要的地址。

然后就可以这么写注入脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
define(address,"GettingOverIt.exe"+2A419)
define(bytes,8B 58 44 89 5D FC)

[ENABLE]

assert(address,bytes)
alloc(newmem,$1000)
alloc(body_base,4)
registersymbol(body_base)

label(code)
label(return)

newmem:
pushf // 把标志位寄存器入栈,一会要恢复
cmp edx,05 // 比较 edx 和 5
jne @f // 如果不相等则跳转到下一个最近的标签
// @f 表示下一个标签,@b 表示上一个标签
mov [body_base],eax // 把 eax 存到变量中
@@: // 这就是下一标签
popf // 恢复标志位

code:
mov ebx,[eax+44]
mov [ebp-04],ebx
jmp return

address:
jmp newmem
nop
return:

[DISABLE]

address:
db bytes
// mov ebx,[eax+44]
// mov [ebp-04],ebx

dealloc(newmem)
dealloc(body_base)

上边的代码并不是对的,因为如果人物掉进水里,重新生成的话,edx 的顺序会改变,下次就不是 05 了,我们还是得对比结构体啊。

使用 Open dissect data with selected addresses 在指定地址处打开分块数据。

在指定地址处打开分块数据

然后就选择 <New window>,然后填上一个数据结构的名称,我就叫做“物体单元”了。

分块数据

这个结构是 CE 自动分析的,有些不一定对,还需要手动调整,我们需要做的其实就是找不同。这里的第 6 列是“罐子 Y 速度”的 eax,我们就找第 6 列中明显的区别吧。

然后我就发现 eax+68 处只有我们需要的这个地址是 6 其他是 0 或者 4,所以我们就比较这个地址吧。

关键代码如下,把上面的那个 cmp 指令修改一下就可以了。

1
cmp [eax+68],06

之后手动添加内存地址的过程大家应该都已经会了。

附录#

相关链接#

Cheat Engine 基础教程 CE Tutorial

注意事项#

CE 版本问题#

我用的是目前的最新版 Cheat Engine 6.7(2017 年 6 月 7 日发布的。这个日子对于考生们很特别嘛)

关于语言问题#

我使用的是英文版,表示某按钮或某菜单的时候我会用英文,说明其用途的时候我会用中文。

汉化版的 CE 与原版的按钮位置或菜单顺序一般都是不会变的,Tutorial 中的基址我不保证不会变。

我推荐使用英文版,因为有些翻译并不能准确表达英文原文的意思。

基础知识#

简介#

Cheat Engine:作弊引擎,简称为 CE

  • 通常用于单机游戏的内存数据修改,可以搜索游戏中的内存数据,并进行修改、锁定等操作
  • 内置调试器,可以进行反汇编调试、断点跟踪、代码注入等诸多高级功能
  • 支持 lua 语言,可以实现自己定义的逻辑功能,而不仅仅是简单的锁定数据。也可以在代码注入的同时注入 lua 插件,使游戏进程与 CE 进程进行交互。 CE 的大部分功能都可以通过 lua 来操作
  • 它还支持 D3D Hook 功能,可以在游戏中显示十字准星,也可以绘制功能菜单,同时也可以对 D3D 的调用栈进行跟踪调试
  • 自带变速功能,通过 Hook 游戏相关函数改变游戏速度
  • 自带了一个 Trainer 的功能,可以将自己在 CE 中实现的功能单独生成一个 exe 文件,并可以使用 lua 创建比默认样式更加复杂的窗体或功能
  • 除了 CE 界面上提供的功能,可以 lua 引擎使用更多隐藏功能,具体就要看帮助文档了

安装 CE#

(安装过程略)

Cheat Engine 6.7 在 Windows 10 下的默认安装位置为:C:\Program Files (x86)\Cheat Engine 6.7,可以使用其他安装目录,不过记着把程序放在哪了就行(记不住其实也无所谓…),之后可能要到这个目录去找一些东西。

使用中文翻译文件#

官方翻译文件的下载链接在文章末尾。

首先解压到类似 C:\Program Files (x86)\Cheat Engine 6.7\languages\zh_CN 的文件夹。

解压中文翻译文件

然后到 CE 的设置中,语言选择 zh_CN

选择简体中文语言

重启 CE 即可

中文界面

打开 CE#

最新版的 CE 有两个主程序,一个 32 位的,一个 64 位的,基本没什么区别,不存在其他一些调试器 32 位无法调试 64 位程序的问题,通常直接通过 开始菜单 > Cheat Engine 6.7 > Cheat Engine 6.7 打开就可以了,不必区分 32 位或者 64 位。

开始菜单

打开 CE 会触发 UAC(User Account Control,用户账户控制),就是用管理员权限打开咯,单纯的搜索内存是不用这个权限的,只有调试的时候某些特殊的功能才可能用到管理员权限,肯定点“是”啊(如果点否的话,你可以顺便关闭这个帖子了…)

界面简介#

主界面

上面是菜单栏,暂时用不到。

然后有个一直在闪的按钮是进程选择器,旁边两个是打开和保存 CE 的存档文件的按钮,记得随时保存哦,改内存数据很可能导致游戏崩溃的哦,如果调试的话连 CE 也会一起崩溃。

然后是当前选择的进程的名称。

下面是一个进度条,显示搜索进度用的。

右边有个设置按钮。

然后左边是搜索到的内存地址,右边是搜索的类型、内容、条件、内存区域等等,下面有个↘右下箭头,是把选中的地址添加到下方的区域里。

下方称之为 Cheat Table(作弊表),就是我们得到的内存地址、数据,以及自己添加的功能等等。

表格上方三个按钮:显示内存窗口、清空作弊列表、手动添加地址。下方两个按钮:高级选项和附加信息。

内存窗口用于调试,手动添加地址通常是手动添加带指针的地址,高级选项中储存了一些指令,还可以暂停当前进程,附加信息可以附带一些使用方法、作者信息等等,高级选项中的指令与附加信息会一同保存在存档中。

教程#

打开 CE 自带的教学软件 Tutorial#

选择菜单栏中的 Help > Cheat Engine Tutorial ,是否是64位的无所谓,也可以从 开始菜单 > Cheat Engine 6.7 > Cheat Engine Tutorial 打开CE自带的教学软件(以后简称 Tutorial )。

打开教学程序

打开之后可以看到一堆文字说明,会英文的同学慢慢看,不会英文的同学可以找CE中文版,或者找翻译器翻译一下吧,我也会简单叙述一下的。

Step 1#

文中的内容是 CE 的简介,和我前面的叙述内容差不多。

你需要做的就是在 CE 中点击那个闪闪发亮的进程选择按钮,选择 ????????-Tutorial-x86_64.exe 或者 ????????-Tutorial-i386.exex86_64 或者 i386 取决于你打开的是 32 位的教学程序还是 64 位的教学程序,前面的 8 位可能不一样,因为这个是进程 ID(PID),每次打开程序,系统会重新分配一个新的 PID。

Step_01_01

通常,游戏只会有一个进程,某些游戏可能有两个进程,选哪个就要凭感觉了,你可以看看任务管理器,通常选择占用内存大的那一个,实在不行就两个都试一试(反正又玩不坏)。

Tutorial 右下角有一个输入密码的地方,之后每一关都会告诉你一个密码,在这里输入,就可以直接就跳到对应关卡了。

选择完之后可以看到主界面上面显示了当前选择的进程名。

Step_01_02

现在点击 Tutorial 中的 Next 就可以了。

Step 2: Exact Value scanning (PW=090453)#

第二关:精确值搜索(密码是 090453)。

这关重现了经典的打怪现场,你有 100 滴血,点击 Hit me 你会被怪打一下,然后你的血量就少了。(再然后,打着打着就像白学家一样被打死了←_←,不过 Tutorial 会给你一条新的生命的。实际游戏中通常不会有这种好事,开始一次新的搜索吧)

你需要做的是:把它改成 1000

很简单,在CE中输入 100,点击 First scan

Step_02_01

然后让怪打自己一下(点击 Hit me),输入新的数值(比如说 96),然后点击 Next scan

现在基本就剩1个地址了,不行就再打一下,再搜一次。

Step_02_02

然后双击地址列表中的值(或者选中然后点击右下箭头),添加到下方 Cheat Table 中。

Step_02_03

然后双击 Value 部分,改成 1000

Step_02_04

可以看到原来不可以点击的 Next 现在已经可以点击了,点击 Next 进入下一关。

现在想想,这个是什么原理呢?#

通常,在游戏中面板显示的数值应该和内存里的数值一样。第一次,显示 100 ,搜索 100 。然后打了自己一下,地址列表中有些数值还是 100 ,有些则变成了 96 ,那么哪个地址才是真正的存储地址呢?如果这个地址是我们想要的,无论打自己几下,他就会一直与我们的血量有关。这就是个不断筛选的过程,把始终跟目标有关的数据筛选出来就可以了。

Step 3: Unknown initial value (PW=419482)#

第三关:未知初始值。

有些游戏的血量不会直接以数值形式显示,比如说某些小怪,之后显示一个血条,但是打他们的时候,怪的身上会显示承受伤害值,就是减少的血量。

你需要做的是:把它改成 5000

首先你需要开始一次新的搜索,点击 New scan

Step_03_01

然后在 Scan Type 中选择 Unknown initial value(未知初始值),点击 First scan

Step_03_02

因为是未知初始值,所以第一次搜索之后列表中什么也不会显示,因为显示什么也没有用,毕竟未知初始值没什么意义。同时左上角的 Found 显示找到的数值非常多,未知初始值就是把内存中所有数值都保存下来,所以非常多,所以最好不要使用未知初始值这个东西。

然后打自己一下,在CE中选择 Decreased value by ...(数值减少了…),将减少的数值输进去,比如 Tutorial 中显示 -9 则输入 9,点击 Next scan

Step_03_03

这时列表可能不止一个数值,我们重复上述操作,再打一次,把减少的血量输入到CE中,再搜索一次。

搜索了几次还是剩这么几个地址,我果断就把其他几个忽略了,把两百左右那个添加到 Cheat Table中,改成 5000,有时候还是需要一些主观因素的。

Step_03_04

好了,可以 Next 了。

Step 3 课外拓展#

4294965556 这个数是什么意思?

计算机中的整数分为有符号和无符号两种,内存就是一堆字节构成的数据,至于如何解释这堆字节就要看是哪段代码来读取他。

比如 4294965556,在内存中是 FF FF F9 34 这4个字节组成的,如果按无符号来解释就是 4294965556 如果按有符号来解释就是 -1740 的意思。

Step_03_05_01

Step_03_05_02

可以通过右键该地址 Browse this memory region 来查看这段内存区域。(在我们常用的这种PC机中,采用 Little Endian 的格式存储多字节数据,即把 FFFFF934 存储成 34 F9 FF FF 的形式)

Step_03_05_02

Step 4: Floating points (PW=890124)#

第四关:浮点类型。

现代的游戏中已经很少使用整数了,这一关就是搜索含小数的数值,Health(生命值)是 float 类型(单精度浮点型), Ammo(弹药)是 double 类型(双精度浮点型)。

你需要做的是:把生命和弹药都改成 5000

除了改一下 Value Type 其他和第二关完全一样。

Value Type 改成 Float 搜索并修改生命值,改成 Double 搜索并修改弹药。

Step_04_01

Step_04_02

然后点击 Next

Step 4 说明#

通常游戏中为了计算速度会选择 Float 类型,至于游戏开发者或游戏框架会选择什么类型谁也不清楚,靠自己瞎猜吧,一个一个试,实在不行就把 Value Type 改成 All 吧。

Step 5: Code finder (PW=888899)#

第五关:代码查找器

通常,储存数据的地址不是固定不变的,每一次重启游戏通常都会变动,甚至在你玩游戏的过程中地址都有可能改变。为此,我们有两个方法,这一关讲解使用代码查找的方法。

每一次点击 Change value 数值都会改变。

你需要做的是:让其不再改变。

首先,用第二关的方法,找到这个数值的地址,添加到 Cheat Table 中。

然后,右键点击这个地址,选择 Find out what writes to this address

Step_05_01

这时会弹出一个提示框,“这个操作会将 Cheat Engine 的调试器附加到指定进程中,是否继续?”

Step_05_02

选择“是”即可。(你可以试试选“否”会怎样。随便尝试嘛,反正电脑又不是 Samsung Galaxy Note 7,又不会因为这点小事而爆炸。不过试了之后你会发现什么都没发生…因为选“否”就是不继续进行任何操作了。)

这时就会弹出一个新的窗口“下列操作指令写入了xxxxxxxx”

Step_05_03

现在,点一下 Change value 就会发现,CE 发现了一条指令改写了当前地址。

Step_05_04

选中这条指令,点击 Replace 按钮,想起个名字就给他起个名字,不想起就直接 OK 就行了。

Step_05_05

当然,右键这条指令,选择 Replace with code that does nothing (NOP) 也可以。

Step_05_06

这时再点 Change value,就会发现 Next 可以点击了。

如果你能以足够快的方式锁定这个数值,也是可以的过关的。

Step 5 说明#

这一关,我们到底做了些什么?

找出改写当前地址的操作#

首先,找出改写当前地址的操作,这个是调试器的一个功能,所以必须先把调试附加到指定进程(这个附加操作可能被检测到,网络游戏需要过非法)

找出改写当前地址的操作,其实是设了一个内存断点,每次有指令改写这个内存,都会使目标进程中断在改写指令之后(为什么是之后,这个是CPU所决定的,没办法),然后 CE 会自动记录这条地址,并储存寄存器信息,然后继续运行程序。

可以尝试,右键这个地址选择 Browse this memory region 查看这块内存区域。

Step_05_07_01

Step_05_07_02

可以看到当前地址用深绿色表示,证明有断点存在

这个操作的过程还是很耗资源的,虽然现在的PC已经完全不在话下了,但是还是在用完即使点 Stop 按钮,取消这个断点,结束这一系列操作。

点击 Stop 之后,绿色表示的断点就会消失。

将代码替换为什么也不做#

程序运行时的指令与数据都是储存在内存中的,将指令替换为什么都不做(通常称为:NOP 掉这条指令。NOP 就是 No operation 的意思,CPU遇到这条指令就是什么都不做,直接执行下一条指令。NOP对应的机器码为 0x90,16进制的90),就是将指定的内存区域全部改成 90,可以先使用 Show diassembler 查看指定区域的反汇编,然后再点 Replace 看看那条指令的变化。

Step_05_08_01

Step_05_08_02

替换成什么也不做之后,这个地址就不会再改变了,其他指令使用这个地址时就一直是同一个值了。

代码列表#

点击 Replace 会提示一个输入名称,这是因为这条代码会保存在地址列表中,点击主界面的 Advanced Options 打开地址列表。

红色表示当前地址已经被替换成无用代码了。

Replace 会自动将其替换成无用代码,如果想复原可以在 Advanced Options 中右键这条指令,选择 Restore with original code 恢复原始代码。

Step_05_09_01

Step 6: Pointers: (PW=098712)#

第六关:指针

上一关使用了代码查找器,这一关则使用指针的方法确定地址。

点击 Change value 数值都会改变。点击 Change pointer 有点类似重新开始一局游戏,这意味着,数值的地址会改变。

你需要做的是:将数值锁定在5000,即使点击 Change pointer 改变了数值所在地址之后。

先用第二关的方法把数值的地址找到,添加到 Cheat Table 中。

然后用第五关的方法 Find out what writes to this address,不过这回不使用 Replace 了,而是点击 More infomation

Step_06_01

使用 Scan Type: Exact ValueValue Type: 4 Bytes、勾选 Hex 搜索刚才复制的地址

Step_06_02

Step_06_03

绿色的地址表示基址,每次游戏启动时基址都是不会改变的,我们找到了基址,就大功告成了。

把搜索到的基址添加到 Cheat Table 中,然后双击 Address 区域,修改地址,把基址复制下来。

Step_06_04

手动添加地址,勾选指针,然后最下面粘贴基址,上面的一级偏移填 0

Step_06_05

然后修改数据,改成 5000,然后点击前面的方框锁定数据。

点击 Change pointer,然后就可以点 Next 了。

Step 6 说明#

那个一级偏移为什么是 0#

方法一:

我们通过第二关的方法找到的地址为 037B2C80(你的地址应该与我的不一样),然后在 More Information 中复制的地址为 037B2C80,用第一个地址(目标地址)减去第二个(基址的值)就是 0(这两个完全一样嘛…所以就是 0

方法二:

mov [edx],eax,方括号中没有任何加减法操作,就是 0

如果出现 mov [edx+10],eax ,就填 10 就行了,这里的 10 是 16 进制的 10 换成 10 进制就是 16

如果出现 [eax*2+edx+00000310] 这种复杂的运算,这时就要看 More Information 下面的寄存器了,如果 eax=4cedx=00801234,那么,应该搜索较大的一个数值,然后将较小的一个数值运算得到一级偏移的结果(另外,有乘法的一定是作为偏移存在的,基址的值一定不存在乘法),即搜索 00801234 找到基址,然后一级偏移为 4c * 2 + 310 = 3a8,这个为 16 进制运算,请使用 Windows 自带的计算器

x86 系列的指令集就是复杂,可以把好几条运算集成成一条指令。

什么是指针#

这个可以从C语言讲起(不过指针不是起源于C语言的哦),C语言最牛逼的东西就属指针啦,指针本身是一个 4 字节(32 位程序)或 8 字节(64 位程序)的整数,如果将其解释为一个整数,那么就是指向那块内存的地址。(注意,内存里的东西只是字节组成的数据,具体是什么含义要看代码是怎么解释,代码将其用作指针那就是指针,代码将其用作整数型变量那就是普通的整数型变量)

看下面这段C语言代码,代码中演示了如何对同一个地址以不同的方式解释。

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include <stdlib.h>
int main()
{
int *a = 0; // 定义指针
a = (int *)malloc(4); // 分配内存
*a = 1; // 令其等于1
printf("%d\n", *a); // 输出指针a指向的值
printf("0x%08x\n", (unsigned int)a); // 以整数型的方式读取a
free(a); // 释放内存
return 0;
}

x86 指令集支持指针这种东西,就是汇编语言中的方括号,mov [edx],eax 的意思就是把 eax 的值存到 edx 指向的地址中去,如果仅仅为 mov edx,eax 那么就仅仅是令 edx 等于 eax 了。

基址#

基址在程序每次启动时都不会变,因为他们的地址是直接写在代码中的,如果他们的地址变了,这个程序还怎么运行了?

Step 7: Code Injection: (PW=013370)#

第七关:代码注入

点一下 Hit me 减少 1 滴血。

你需要做的是:点一下 Hit me 改成加 2 滴血。

也不知道这句话我说了多少遍了,先用第二关的方法把血量的地址找到,然后添加到 Cheat Table 中。

再用第五关的方法找到写入这个地址的代码。

这回我们不 Replace 也不用点 More Information,我们点 Show disassembler 打开反汇编窗口。

选中这条语句,使用 Tools > Auto Assemble

Step_07_01

Auto Assemble(自动汇编,简称AA)

Step_07_02

选择 Template > Code injection

Step_07_03

这个地址就是刚才在 Disassembler 中选择的那个地址,直接点 OK 即可。

模板提供了以下代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
alloc(newmem,2048)
label(returnhere)
label(originalcode)
label(exit)

newmem: //this is allocated memory, you have read,write,execute access
//place your code here

originalcode:
sub dword ptr [ebx+00000478],01

exit:
jmp returnhere

"Tutorial-i386.exe"+255BD:
jmp newmem
nop
nop
returnhere:

sub dword ptr [ebx+00000478],01 改成 add dword ptr [ebx+00000478],02 就可以了。

原本这条代码是把 [ebx+00000478] 这个内存地址的数据减 1,把 sub 改成 add 就是加 1 了,再把 01 改成 02 就是加 2 了。

完整的代码就是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
alloc(newmem,2048)
label(returnhere)
label(originalcode)
label(exit)

newmem: //this is allocated memory, you have read,write,execute access
//place your code here

originalcode:
add dword ptr [ebx+00000478],02

exit:
jmp returnhere

"Tutorial-i386.exe"+255BD:
jmp newmem
nop
nop
returnhere:

改完之后,点 Execute 就大功告成了。

Step_07_04

Step_07_05

Step_07_06

点击 Hit me 然后就可以点击 Next 了。

这里有个更简单的方法,直接在反汇编窗口中把 sub dword ptr [ebx+00000478],01 中的 01 改成 -02

Step_07_07

Step 7 说明#

newmem:originalcode:exit:"Tutorial-i386.exe"+255BD:returnhere: 这几个个东西虽然都有冒号,但是的用途是不一样的。

newmem"Tutorial-i386.exe"+255BD 是具体的内存地址,他表示接下来所有指令将被写入这个内存地址。

originalcodeexitreturnhere 是标签,不占据字节,不占据指令,他表示一个暂时不知道的地址,仅仅用于跳转。

所以从 newmem:"Tutorial-i386.exe"+255BD: 之间的指令都会写到新分配的地址中,"Tutorial-i386.exe"+255BD: 到结尾的指令都会覆盖原始指令(实现代码注入的效果)

而代码最前面的这四句,就是用来定义这些内存地址或标签的

1
2
3
4
alloc(newmem,2048) // 申请 2048 字节的内存,newmem 就是这个内存地址
label(returnhere) // 定义用于跳转的标签 returnhere
label(originalcode) // 定义用于跳转的标签 originalcode
label(exit) // 定义用于跳转的标签 exit

Step 8: Multilevel pointers: (PW=525927)#

第八关:多级指针

和第六关差不多,不过这次不是一级指针,而是4级指针,就是指向目标数值 的指针 的指针 的指针 的指针

你需要做的是:点击 Change pointer之后的 3 秒内 将新的数值锁定在 5000

你懂的,先用第二关的方法把血量的地址找到,然后添加到 Cheat Table 中。再用第六关的方法搜索指针,不过这次你发现,搜索到的地址不是绿色的基址了,这就需要我们再继续搜索上一级指针。

Step_08_01

将这个地址添加到下方,然后使用 Find out what accesses this address,注意是 accesses 而不是 writes

Step_08_02

然后点击 Change value 会收集到两条指令,cmp 指令跟指针没什么关系,对我们有用的是第二条指令 mov esi,[esi]

Step_08_03

不过问题来了,我们发现,这里提示的地址和上一次提示的地址是一样的,这是为什么呢?

CE 默认使用硬件断点的方式,断点只能停在指令执行之后,而这条指令正好是把 esi 原来指向的地址中的值再赋值给 esi,所以执行之后 esi 的值已经是被覆盖掉的值了,而我们想知道的恰恰是执行这条指令之前的 esi 值,那么怎么办呢。

方法一:秀操作

我们可以手动在当前指令的前一条指令下断点…(陈独秀同志,请你坐下)

方法二:动一动脑子

我们的 Find out what accesses this address 是干什么的?查找访问这个地址的指令,然后我们又发现了 mov esi,[esi] 这条指令访问了这个地址,那么 [esi] 原来是啥?原来的 esi 不就是这个我们监视的地址嘛。

所以直接搜索这个地址即可。

Step_08_04

将这个地址添加到下方,然后使用 Find out what accesses this address,再来一遍。

Step_08_05

和上一步一样,不在赘述。

Step_08_06

不过这次我们搜索到了两个指针,用哪个呢?通常的话选第一个就可以了,不过这个也不一定,大不了再把第二个也试一下嘛。

真不巧,还真是第二个…

Step_08_07

现在我们来整理一下

我们可以借用汇编语言的指针表示方法来表示一下我们需要的内存地址

[[[["Tutorial-i386.exe"+1FD660]+0C]+14]+00]+18

把基址(一级指针) "Tutorial-i386.exe"+1FD660 的值取出来,加上一级偏移 0C,当做地址,这是二级指针的地址,再把二级指针的值取出来,加上 14,这是三级指针的地址,依次类推。

然后就来手动添加地址

Step_08_08

把刚添加的条目改成5000,然后锁定

Step_08_09

点击 Change pointer 然后就可以点击 Next 了。

注意#

  • 可以通过代码注入的方式通过此关。

  • 上述过程中,除了对目标数值的地址使用 Find writes,其余都使用 Find accesses。其实,对目标数值的地址使用 Find accesses 也可以。

Pointer scan#

如果你觉得这样就结束了的话,那你太低估 CE 的强大了。CE 内置了搜索指针的工具。

右键这个地址,选择 Pointer scan for this address

Step_08_10

弹出的搜索设置提示框,通常使用默认配置即可。由于 Pointer 列表过于庞大,所以需要有一个保存的地方,这个自己随意保存。

Step_08_11

点击确定开始搜索,可以发现有非常多的路径可以指向我们要找的地址,这时我们要做的就是 Change pointer,至于为什么请继续往下看。

我们重新找到目标地址,把这个地址复制下来,然后选择 Pointer scan 窗口中的菜单选项 Pointer scanner > Rescan memory - removes pointers not pointing to the right address

Step_08_12

然后把新的地址填上就好了,点击 OK 就好了

Step_08_13

Step_08_14

只剩一个了,双击添加到地址列表,按原来的方法锁定为 5000 即可。

Step 9: Shared code: (PW=31337157)#

第9关:共享代码

有时候使敌人掉血的代码和使自己掉血的代码是同一个代码,单纯修改这个代码会使要么同时对自己和敌人有利,要么同时有害,而不是对自己有利,对敌人有害。

这一关重现了这样一种情况:己方初始血量很少,且被攻击一次掉血很多,敌方初始血量很多,且每次攻击只掉1滴血。

你需要做的是:不使用锁定,让己方不死,而敌方阵亡。

注意:生命值是 Float 类型。

注意:解决方案不唯一。

嗯,你懂得。先用第二关的方法把第一个人的血量的地址找到,然后添加到 Cheat Table 中。然后使用第七关的方法找出写入该地址的代码。

同理你可以把4个人的血量地址全找到,再查找写入该地址的代码,发现全都是同一条指令。

我们需要在代码注入时区分己方和敌方。

怎么办呢?来吧,找不同。

分别对四个血量进行 Browse this memory region 操作(可以使用快捷键 Ctrl + B

Step_09_01

对比一下吧

Step_09_02

最明显的要数这个名字了,我们可以对比这个名字,如果是 DE 开头,则是友军,不能让其掉血。如果是其他字母开头的话,则正常掉血。

当然,如果仔细看看,会发现数据中有队伍编号,对这个进行判断再简单不过了,如果是 1 则不掉血,否则掉血。

现在我们要进行代码注入了,Tools > Auto Assemble,然后 Template > Code injection

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
alloc(newmem,2048)
label(returnhere)
label(originalcode)
label(exit)

newmem: //this is allocated memory, you have read,write,execute access
//place your code here

originalcode:
mov [ebx+04],eax
fldz

exit:
jmp returnhere

"Tutorial-i386.exe"+265B7:
jmp newmem
returnhere:

原始代码是 mov [ebx+04],eax,意思就是,血量处于 ebx+04 的位置。我们再看 Dave 的血量地址 04D9B22C 和队伍编号地址 04D9B238,两者做个差值(16进制减法,可以用计算器算),应该是0C,如果血量是 ebx+04,那么队伍编号就应该是 ebx+04+0C 就是 ebx+10 了,我们需要在运行原来代码之前判断一下 [ebx+10] 是否等于 1

最终完整代码如下(注释在代码中):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
alloc(newmem,2048)
label(returnhere)
label(originalcode)
label(no_decrese_health) // 在这里定义一个标签
label(exit)

newmem: //this is allocated memory, you have read,write,execute access
//place your code here
// 在这里添加代码
cmp [ebx+10], 1 // 判断 [ebx+10] 是否为 1
je no_decrese_health // 如果相等的话,则跳转到 no_decrese_health 标签

originalcode:
mov [ebx+04],eax
no_decrese_health: // 在这里使用这个标签
fldz

exit:
jmp returnhere

"Tutorial-i386.exe"+265B7:
jmp newmem
returnhere:

当然也可以通过判断名字的方法来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
alloc(newmem,2048)
label(returnhere)
label(originalcode)
label(no_decrese_health) // 在这里定义一个标签
label(exit)

newmem: //this is allocated memory, you have read,write,execute access
//place your code here
// 在这里添加代码
cmp byte ptr [ebx+15], 44 // 判断 [ebx+15] 是否为 D
je no_decrese_health // 如果相等的话,则跳转到 no_decrese_health 标签
cmp byte ptr [ebx+15], 45 // 判断 [ebx+15] 是否为 E
je no_decrese_health // 如果相等的话,则跳转到 no_decrese_health 标签

originalcode:
mov [ebx+04],eax
no_decrese_health: // 在这里使用这个标签
fldz

exit:
jmp returnhere

"Tutorial-i386.exe"+265B7:
jmp newmem
returnhere:

后来我想了想,还可以比较名字的第二位是否是小写,ASCII 怎么判断大小写?大写是 41 - 5A,小写是 61 - 7A,所以和 60 比较大小即可,所以中间判断的那部分可以这样写:

1
2
cmp byte ptr [ebx+16], 60 // 判断 [ebx+16] 和 'a' - 1
jg no_decrese_health // 如果大于的话,则跳转到 no_decrese_health 标签

点击 Execute 注入代码。然后点击 Restart game and autoplay ,可以看到己方存活,敌方两员大将死翘翘了,我们的 Next 可以点击了。

Step 9 说明#

为什么我们能在血量附近找到人物的其他一些属性?#

通常人物属性是以结构体的方式存储的,在C语言中,结构体的所有信息就会储存在一块连续的内存中。

cmp 指令#

cmp 就是比较左右两个数,不过要注意的是,比较队伍编号我用的是 cmp [ebx+10], 1,他编译之后是 cmp dword ptr [ebx+10], 01,而比较名字首字母的时候用的是 cmp byte ptr [ebx+15], 44cmp dword ptr 比较指针指向地址处的 4 个字节而 cmp byte ptr 只比较 1 个字节。

完结撒花#

Step_10

附录#

相关链接#

CE 教程:《荒野行动》从内存数据修改到代码注入

人物物品变黄色#

2018 年 1 月 8 日之后,网易似乎对荒野行动的内存数据进行了简单的检测,原来的通过 基址 + 偏移 的方法改数据会被秒封。

物品红色 gamma 值地址:[[["hyxd.exe"+1B9A678]+AC]+E0]+E0,float 类型,直接把该数值改成 1000 会导致账号被封,具体原因暂时不清楚。

反调试#

首先这个游戏有简单的反调试,Cheat Engine 菜单 > Edit > Settings > Debugger Options,改一下设置 Debugger method: Use windows debuggerDebugger interface config: Try to prevent detection of debugger,简单粗暴的反反调试就可以了,至于 Try to prevent detection of debugger 具体做了什么暂时不太清楚,有待进一步研究。

Try to prevent detection of debugger

找到修改内存的指令#

然后就可以对这个地址进行Find out what accesses the address > Find what accesses the address pointed at by this pointer 可以看到两条指令

Find what accesses the address pointed at by this pointer

经过测试,第一条是真正有用的代码,第二条大约每 1 秒触发 1 次,可能就是用来检测数据异常的。

既然直接改内存数据会被检测到,那么不用内存数据修改的方法怎么办呢?

代码注入#

选中第一条指令,点击 Show disassembler

Show disassembler

1
2
3
hyxd.exe+64F3E2 - F3 0F10 86 EC000000   - movss xmm0,[esi+000000E0]
hyxd.exe+64F3EA - F3 0F59 86 EC000000   - mulss xmm0,[esi+000000EC]
hyxd.exe+64F3F2 - F3 0F11 47 40         - movss [edi+40],xmm0

这三条指令,第一条是读取红色亮度,第二条是乘以白色亮度,第三条是存到 [edi+40]

代码注入原理很简单,就是二话不说,直接给 [edi+40] 赋值 float 的 1000

这块内存区域足够我们注入了,不需要申请新内存,直接在这块内存上修改即可。

原始代码:8 + 5 bytes = 13 bytes

1
2
hyxd.exe+64F3EA - F3 0F59 86 EC000000   - mulss xmm0,[esi+000000EC]
hyxd.exe+64F3F2 - F3 0F11 47 40 - movss [edi+40],xmm0

修改后代码:7 + 6 * 1 bytes = 13 bytes

1
2
3
4
5
6
7
mov [edi+40], (float)1000.0
nop
nop
nop
nop
nop
nop

这个就是最简单的代码注入了。

在 CE 中实现#

Auto assemble

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[ENABLE]
"hyxd.exe"+64F3EA:
mov [edi+40], (float)1000.0
nop
nop
nop
nop
nop
nop

[DISABLE]
"hyxd.exe"+64F3EA:
mulss xmm0,[esi+000000EC]
movss [edi+40],xmm0

保存到代码列表即可。

然而 CE 的自动汇编似乎每条指令不是同时写入的,连续的开关这个代码注入有时直接导致程序崩溃,所以我改为使用 db 直接拷贝字节数组,这样可以一次性写入,防止程序崩溃。

1
2
3
4
5
6
7
[ENABLE]
"hyxd.exe"+64F3EA:
db C7 47 40 00 00 7A 44 90 90 90 90 90 90

[DISABLE]
"hyxd.exe"+64F3EA:
db F3 0F 59 86 EC 00 00 00 F3 0F 11 47 40

Assign to current cheat table

Set hotkeys

这样基本可以完成了,2018 年 1 月 18 日测试不会被封号。

人物物品变黄色

摄像机抬高#

关于这个游戏的摄像机位置,大家可能都知道了。

人物站立搜 1.65,蹲下搜 1.30,趴下搜 0.76,只剩下一个地址,这是跟随摄像机的高度。

不要直接修改这个数值,队友观战的情况下可以看到和你相同的画面(他们的视野也是提高的,或者说服务器会收到你修改的异常数据)。

对这个地址使用上述同样的方法,找出写入的代码,然后在原始内存上代码注入即可,比如设置成 30,就可以拥有一个头上的无人侦察机了。

然而,简单的内存注入会使摄像机顶在天花板上,极其影响使用体验,所以,我们深入研究一下这段代码。

内存修改到代码注入#

  1. 人物站立搜 1.65,蹲下搜 1.30,趴下搜 0.76,只剩下一个地址。

  2. Find out what accesses the address,只有一条指令,然后 Show disassembler.

Find what accesses the address pointed at by this pointer

看到两条有用的指令

1
2
hyxd.exe+23954D - F3 0F10 86 C4000000   - movss xmm0,[esi+000000C4]
hyxd.exe+239555 - F3 0F11 45 C8 - movss [ebp-38],xmm0

[esi+000000C4] 赋值给 [ebp-38]

于是我们就使用 Find out what addresses this instruction accesses 找出这条指令访问的地址。

Find out what addresses this instruction accesses

可以看到,找到的地址的值一直在变啊,我们下断点看看 ESP(Stack Pointer,栈指针)的值,ESP 和 EBP 比较接近,所以 [ebp-38] 指向的是一个栈内存的数据,很麻烦。

了解一些反汇编的原理就会知道,堆内存是分配完只要不销毁、无论怎么在函数间传递或调用都会一直存在的,也不会被其他数据占据,而栈是只在函数调用期间存在的,一旦这个函数调用结束,别的函数就会复用这块栈,这块数据会频繁的改变,所以栈内存根本没法使用普通方式修改,必须依赖代码注入。

1
2
3
4
5
6
7
mov [ebp-38],(float)30.0
nop
nop
nop
nop
nop
nop

解决顶在天花板的问题#

这样就可以了,非常简单,不过顶在天花板上的问题还没解决呢。

虽然栈是短暂的,在线程外访问会得到不确定的结果。但是,如果我们下了断点,让程序在这里沿着游戏的线程执行,就不会出现这个问题了,但是 Find out what addresses this instruction accesses 这种简单的方法就不行了。

Set breakpoint

首先 F5 下断点,然后看寄存器,并右键 EBP 选择 Show in hexview,并且往上找 3 行 8 字节(就是 [ebp-38]),右键 Data breakpoint > Break on access,然后 F9 继续执行就可以了,程序会断在对 [ebp-38] 访问的指令上,我们可以确定,这条指令一定是有用的,而不会被其他函数复用了这块内存(因为这个数据刚写入,第一次访问一定是有用的访问)。

Set data breakpoint. Break on access

1
2
3
4
5
6
7
8
hyxd.exe+239B3D - F3 0F10 8E 84000000   - movss xmm1,[esi+00000084]
hyxd.exe+239B45 - F3 0F10 86 88000000 - movss xmm0,[esi+00000088]
hyxd.exe+239B4D - F3 0F58 96 80000000 - addss xmm2,[esi+00000080]
hyxd.exe+239B55 - F3 0F58 4D C8 - addss xmm1,[ebp-38]
hyxd.exe+239B5A - F3 0F58 45 C4 - addss xmm0,[ebp-3C]
hyxd.exe+239B5F - 0F14 D1 - unpcklps xmm2,xmm1
hyxd.exe+239B62 - 66 0FD6 17 - movq [edi],xmm2
hyxd.exe+239B66 - F3 0F11 45 A4 - movss [ebp-5C],xmm0

我们可以分析一下这段代码的意图,在 hyxd.exe+239B45 处下断点,查看 XMM1 寄存器,很显然 [esi+00000084] 存放的是当前人物的 Y 坐标(高度方向坐标),然后使用 addss 指令把 [ebp-38] 的相机高度加上,然后 unpcklpsxmm1xmm2 寄存器各自的前 4 字节合成到 xmm2 的前 8 字节,写入 [edi],我们可以看到 edi 在每局游戏中是固定的,这个就非常舒服,我们直接添加到地址列表中就可以研究了。

FPU

尝试#

我们可以尝试第一个实验,直接把 movq [edi],xmm2 改为 nop,右键该条指令,Replace with code that does nothing,我们发现镜头不会跟随我们了,然后我们可以对 edi 的地址用 CE 直接进行内存修改,发现镜头真的可以随数值移动,证明,edi 这个地址是有用的。

改完记得在右键 Restore with original code 还原代码

然后,就是 Find out what writes the address,可以看到,在相机不碰到天花板的时候只有一条指令写入,在碰到天花板的时候有两条写入,我们直接把第二条nop 掉。大功告成,我们可以在屋子里提高视野了。

Replace camera collision detect

于是我们又发现问题了,开镜、开车和观战的时候,步骤 2 中注入的代码不好使,搜索对 EDI 这个地址的写入,我们发现开镜、开车和观战的时候分别有不同的代码写入 EDI 这个地址,我们必须继续探究 EDI 这个地址到底之后被用来做什么了。

相机碰撞代码注入#

还是熟悉的套路,右键刚才那个相机高度的地址,Find out what accesses the address,查找访问(访问包括写入和读取),可以看到通常情况下有 4 条指令,当进行某些复杂的相机位置计算的时候回多出来很多(比如顶到天花板,比如开镜)

查找访问相机高度的指令

注意,前面的计数器是相等的,很显然这些操作是一系列有顺序的操作,其中第二条是刚才写入 [edi] 的那条指令。

另外,这里显示的指令的顺序是非常重要的,CE按照触发顺序列出了所有的访问指令,我们要改的应该是哪一个呢?

想一想,肯定是最后一个嘛。我们的想法就是,前面无论你怎么算,让游戏随便算,我们到最后一步,把结果一改就 OK 啦。(你前面尽管算,结果对了算我输)

开始修改#

Show disassembler

最后一条访问相机高度的指令

1
2
3
hyxd.exe+21F7FB - F3 0F10 47 04         - movss xmm0,[edi+04]
hyxd.exe+21F800 - F3 0F5C C5 - subss xmm0,xmm5
hyxd.exe+21F804 - F3 0F11 45 90 - movss [ebp-70],xmm0

[edi] 是 X 坐标,[edi+04] 是 Y 坐标(相机高度)

我们要做什么你们懂吧,在 movss [ebp-70],xmm0 这句话之前,在 xmm0 上加 30.0 就可以了。

对这句指令进行 Auto Assemble,使用 Template > Full Injection 模板

Full Injection

CE 提供了以下 Full Injection 模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
define(address,"hyxd.exe"+21F804) // 定义我们要修改的地址
define(bytes,F3 0F 11 45 90) // 这是改地址原来的指令机器码

[ENABLE]

assert(address,bytes) // 只有在我们要修改的地址为原来的指令的时候才继续
alloc(newmem,$1000) // 分配1KB内存

label(code) // 定义标签
label(return) // 定义标签

newmem: //这是我们刚分配的内存地址,在这里添加修改代码// ...
// ...
// ...
code: // 这是原来指令的拷贝(因为jmp newmem指令覆盖了原来的指令,所以需要把原来的指令搬到我们新分配的地址中来)
movss [ebp-70],xmm0
jmp return

address: // 这是原始的地址,这个标签之后的内容会覆盖原始指令(就是跳转到我们分配的那一段内存里)
jmp newmem
return:

[DISABLE]

address:
db bytes
// movss [ebp-70],xmm0

dealloc(newmem)

代码中有解释,不过我还要再说明一下,newmem: code: address: return: 这四个东西的用途是不一样的。newmemaddress 是具体的内存地址,他表示接下来所有指令将被写入这个内存地址。codereturn 是标签,不占据字节,不占据指令,他表示一个暂时不知道的地址,仅仅用于跳转,所以从 newmem:address: 之间的指令都会写到新分配的地址中,address:[DISABLE] 之间的指令都会覆盖原始指令(实现代码注入的效果)

我们需要做的就是在 newmem: 后面添加几条指令,你们可以自行发挥(由于 newmem:address: 之间的指令都会写到新分配的地址中,改 code: 之后的指令也是没有任何问题的)。

我的写法如下:

1
2
3
push (float)30.0
addss xmm0, [esp]
add esp, 4

由于 ss 系列指令不能接受具体的数值(应该叫立即数吧,我对 ASM 只是粗略的了解),所以我先直接往栈中压入一个数值,由于栈可以通过地址使用 [esp] 访问(ESP 永远指向栈顶),然后再把栈指针增加 4 ,恢复栈平衡。

这里基本就完事了。

我甚至发现一个有趣的东西,就是可以在天上开镜,甚至还可以打到人。看来这个游戏的命中检测是客户端做的。

照相机提高效果

如果在车上开提高视角,一旦屏幕中没有显示出自己的车子,屏幕就会非常卡,暂时不知道原因是什么。(所以在车里提高视角之后只能竖直向下看)

课后拓展#

看到 movss [ebp-70],xmm0 这条指令,对 [ebp-70] 有没有什么兴趣。

ebpesp 差不多,通常都是指向栈内存的,分析这个地址也需要单步或内存断点。

放大视野(自带8倍镜)#

不开镜搜 39.4,枪自带的镜或红点搜 31.52,二倍镜搜 39.4/2,四倍镜搜39.4/4,以此类推。

  1. 先通过内存搜索把地址定下来,应该会有 4 个地址。

  2. 你们可以一个一个试,找出访问这个地址的指令。

最后我定位到这段代码了

修改视野的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
hyxd.exe+175FBD - F3 0F10 05 94DD4802   - movss xmm0,[hyxd.exe+168DD94] { [179.90] }
hyxd.exe+175FC5 - F3 0F10 0D 90DD4802 - movss xmm1,[hyxd.exe+168DD90] { [0.10] }
hyxd.exe+175FCD - F3 0F5D 86 FC010000 - minss xmm0,[esi+000001FC]
hyxd.exe+175FD5 - F3 0F5F C8 - maxss xmm1,xmm0
hyxd.exe+175FD9 - 84 C0 - test al,al
hyxd.exe+175FDB - 75 32 - jne hyxd.exe+17600F
hyxd.exe+175FDD - 0F2E 8F 98000000 - ucomiss xmm1,[edi+00000098]
hyxd.exe+175FE4 - 9F - lahf
hyxd.exe+175FE5 - F6 C4 44 - test ah,44 { 68 }
hyxd.exe+175FE8 - 7B 25 - jnp hyxd.exe+17600F
hyxd.exe+175FEA - 8B 4D 08 - mov ecx,[ebp+08]
hyxd.exe+175FED - F3 0F11 8F 98000000 - movss [edi+00000098],xmm1
hyxd.exe+175FF5 - F3 0F59 0D 8CDD4802 - mulss xmm1,[hyxd.exe+168DD8C] { [0.02] }
hyxd.exe+175FFD - 51 - push ecx
hyxd.exe+175FFE - 8B 01 - mov eax,[ecx]
hyxd.exe+176000 - F3 0F11 0C 24 - movss [esp],xmm1
hyxd.exe+176005 - FF 50 34 - call dword ptr [eax+34]
hyxd.exe+176008 - C7 45 FC FFFFFFFF - mov [ebp-04],FFFFFFFF { -1 }
hyxd.exe+17600F - F3 0F10 86 00020000 - movss xmm0,[esi+00000200]

读取 [esi+000001FC],然后与给定范围对比,最大179.9度,最小0.10度。

(然后后面那一堆代码可以不用理解,具体想研究的话需要去查ucomiss和lahf指令的具体含义,反正我研究了半天,也没什么大用)

如果 [edi+00000098] 不等于 xmm1 则令 [edi+00000098] 等于 xmm1,然后把 xmm1 乘以 0.02 存入 [esp],调用 [eax+34] 这个函数 。

这个 0.02 是四舍五入以后的,具体看一下那个内存地址是 0.017,猜一猜嘛,高中数学,就是 π/180 呗。(就算知道了又怎样呢…..)

思路#

我们需要做的很简单,把 maxss xmm1,xmm0 那一堆直接改成令 xmm1 = 39.4/8 就行了。(下面的代码没有填充nop,自己调整一下就好了)

1
2
3
push (float)5.0
movss xmm1,[esp]
add esp,4

这样其实就可以了。不过你会发现这样的放大视野并不会加载远处的东西,那怎么办呢?我还没研究出来

(还在探索中,不过真不太想玩这个游戏了)

高级代码注入#

抬高相机玩的还不够爽,毕竟只是改变一个 Y 轴。那么 X 轴和 Z 轴呢?

当然也可以改,不过问题来了,我怎么知道 X 轴、Z 轴的数值要怎么改变呢,我们需要更人性化的使用方法。

以下代码实现的功能,简单来说就是一个隐形无人机:IJKL:控制方向,P/分号:上升/下降,左/右方括号:减速/加速

如果是可以开镜的枪,开镜使用就是一个具备攻击能力的隐形无人机。

以下内容需要对 Windows 的 API 有一定了解,对汇编有一定了解。如果你对汇编不太了解可以用 C 语言写,然后使用 CE 进行注入(不过这个我还没试过)。

无人机代码#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
// 首先定义了很多常量
// 这个代码地址是原来的计算已经结束的地方,我们的目的是在得到原始计算结果之后再进行修改
define(address,"hyxd.exe"+21F812)
define(bytes,F3 0F 11 45 CC)
define(VK_I,49)
define(VK_J,4A)
define(VK_K,4B)
define(VK_L,4C)
define(VK_P,50)
define(VK_OEM_1,BA) // 分号键
define(VK_OEM_4,DB) // 左方括号
define(VK_OEM_6,DD) // 右方括号

[ENABLE]

// 目标地址必须是指定指令才进行注入
assert(address,bytes)

// 分配代码内存
alloc(newmem,$1000)
// 分配变量内存
alloc(delta_x,4)
alloc(delta_y,4)
alloc(delta_z,4)
alloc(v_x,4)
alloc(v_y,4)
alloc(v_z,4)
alloc(v,4)
alloc(v_ratio,4)

// 定义标签
label(return)
label(uav)
label(not_press_VK_I)
label(not_press_VK_J)
label(not_press_VK_K)
label(not_press_VK_L)
label(not_press_VK_P)
label(not_press_VK_OEM_1)
label(not_press_VK_OEM_4)
label(not_press_VK_OEM_6)

// 变量初始化
delta_x:
dd (float)0
delta_y:
dd (float)0
delta_z:
dd (float)0
v_x:
dd (float)0
v_y:
dd (float)1
v_z:
dd (float)0
v:
dd (float)0.5
v_ratio:
dd (float)1.05

// 注入的代码
newmem:

// 原来地址上的代码
movss [ebp-34],xmm0

// 把速度存下来,供其他部分使用
// movss xmm3, [edi+0c]
movss [v_x], xmm3
// movss xmm5, [edi+10]
movss [v_y], xmm5
// movss xmm4, [edi+14]
movss [v_z], xmm4
// 检测键盘操作
call uav
// 在相机位置上加上偏移量
movss xmm0,[ebp-6C]
addss xmm0,[delta_x]
movss [ebp-6C],xmm0
movss xmm0,[ebp-70]
addss xmm0,[delta_y]
movss [ebp-70],xmm0
movss xmm0,[ebp-34]
addss xmm0,[delta_z]
movss [ebp-34],xmm0
// 我调试了半天才发现,call user32.GetAsyncKeyState会清空xmm寄存器
// 恢复xmm寄存器
movss xmm3, [edi+0c]
movss xmm5, [edi+10]
movss xmm4, [edi+14]
jmp return

// 这个是我们通过键盘操控的代码
uav:
// 记录寄存器和标志位
push eax
push ecx
push edx
pushf
// GetAsyncKeyState接受一个参数,就是虚拟按键的编码
push VK_I
call user32.GetAsyncKeyState
// 返回值保存到EAX,AX的最高位为1则按下,这里简单的判断EAX是否为0
test eax,eax
je not_press_VK_I
// delta_x = delta_x + v * v_x
movss xmm0,[delta_x]
movss xmm1,[v_x]
mulss xmm1,[v]
addss xmm0,xmm1
movss [delta_x],xmm0
// 这里可以根据自己的喜好,选择注释或取消注释。
// movss xmm0,[delta_y]
// movss xmm1,[v_y]
// mulss xmm1,[v]
// addss xmm0,xmm1
// movss [delta_y],xmm0
movss xmm0,[delta_z]
movss xmm1,[v_z]
mulss xmm1,[v]
addss xmm0,xmm1
movss [delta_z],xmm0
not_press_VK_I:
push VK_K
call user32.GetAsyncKeyState
test eax,eax
je not_press_VK_K
movss xmm0,[delta_x]
movss xmm1,[v_x]
mulss xmm1,[v]
subss xmm0,xmm1
movss [delta_x],xmm0
// movss xmm0,[delta_y]
// movss xmm1,[v_y]
// mulss xmm1,[v]
// subss xmm0,xmm1
// movss [delta_y],xmm0 movss xmm0,[delta_z]
movss xmm1,[v_z]
mulss xmm1,[v]
subss xmm0,xmm1
movss [delta_z],xmm0
not_press_VK_K:
push VK_J
call user32.GetAsyncKeyState
test eax,eax
je not_press_VK_J
// 左右移动需要动动脑子,delta_x = delta_x + v * v_z,delta_z = delta_z - v * v_x
movss xmm0,[delta_x]
movss xmm1,[v_z]
mulss xmm1,[v]
addss xmm0,xmm1
movss [delta_x],xmm0
movss xmm0,[delta_z]
movss xmm1,[v_x]
mulss xmm1,[v]
subss xmm0,xmm1
movss [delta_z],xmm0
not_press_VK_J:
push VK_L
call user32.GetAsyncKeyState
test eax,eax
je not_press_VK_L
movss xmm0,[delta_x]
movss xmm1,[v_z]
mulss xmm1,[v]
subss xmm0,xmm1
movss [delta_x],xmm0
movss xmm0,[delta_z]
movss xmm1,[v_x]
mulss xmm1,[v]
addss xmm0,xmm1
movss [delta_z],xmm0
not_press_VK_L:
push VK_P
call user32.GetAsyncKeyState
test eax,eax
je not_press_VK_P
movss xmm0,[delta_y]
addss xmm0,[v]
movss [delta_y],xmm0
not_press_VK_P:
push VK_OEM_1
call user32.GetAsyncKeyState
test eax,eax
je not_press_VK_OEM_1
movss xmm0,[delta_y]
subss xmm0,[v]
movss [delta_y],xmm0
not_press_VK_OEM_1:
push VK_OEM_4
call user32.GetAsyncKeyState
test eax,eax
je not_press_VK_OEM_4
movss xmm0,[v]
divss xmm0,[v_ratio]
movss [v],xmm0
not_press_VK_OEM_4:
push VK_OEM_6
call user32.GetAsyncKeyState
test eax,eax
je not_press_VK_OEM_6
movss xmm0,[v]
mulss xmm0,[v_ratio]
movss [v],xmm0
not_press_VK_OEM_6:
// 恢复栈平衡
popf
pop edx
pop ecx
pop eax
ret

address:
jmp newmem
return:

[DISABLE]

address:
db bytes
// movss [ebp-34],xmm0

dealloc(newmem)
dealloc(delta_x)
dealloc(delta_y)
dealloc(delta_z)
dealloc(v_x)
dealloc(v_y)
dealloc(v_z)
dealloc(v)

这个代码是完全在绘图线程上做的,CE 其实还可以支持 createthread 再建立一个自己的线程,在自己的线程完成移动视角的操作,这样就可以减轻原始线程的负担。

GetAsyncKeyState 函数#

参考 https://msdn.microsoft.com/en-us/library/windows/desktop/ms646293

判断当这个函数被调用时,某一个按键是否处于按下状态。如果处于按下状态,则返回值的最高位为1(返回值为-32767),通常简单的判断非零即可。

1
SHORT WINAPI GetAsyncKeyState(_In_ int vKey);

他接受一个参数,就是虚拟按键码,Visual C++ 开发可以直接使用 VK_* 的常量来表示,这里需要我们自己定义。

WIN32 API#

直接使用汇编语言调用 API 其实不是特别难,如果这个 DLL 已经加载了,那就更简单了,逆序 push 进去参数,call 即可,返回值保存在 EAX 寄存器

参考 https://www.52pojie.cn/thread-434732-1-1.html

算法#

位移速度时间计算,高中数学+物理,这个自行解决吧。

左右移动可能需要一些比如向量的乘法或者行列式之类的东西,不过这里简单的说一下,如下图,就两种情况,试一试就知道了。

位移计算方法

题外话#

现在的挂不就是比谁更牛逼嘛,飞天、遁地、穿墙又能怎样?

当你们还在用人体作战,我们已经拥有无人机了。

无人机效果

无人机效果

无人机效果

这个功能严重影响游戏平衡,请酌情使用!

可谓杀人于无形。

运筹帷幄之中,决胜千里之外。

(上面这句话的引用纯属娱乐,语文考生请勿如此使用)

外挂是百分之一的灵感加上百分之九十九的汗水。

课后练习#

设计一个可以动态改变瞄准镜倍数的外挂。

其他#

  1. 有人在回复询问,39.4 是怎么找到的?

    学习 CE 最开始肯定是破解 CE 自带的 Tutorial.exe 了,CE 的第三步就是未知初始值查询。

    首先我们不开镜选择 Scan Type: Unknown initial value。至于 Value Type,对这个游戏研究一下就会发现他全都用的是 Float 类型,如果实在不知道可以选择 All

    然后就是开镜,Scan Type: Changed value,搜索,Scan Type: Unchanged value,搜索,搜索,搜索。关镜,Scan Type: Changed value,搜索,Scan Type: Unchanged value,搜索,搜索,搜索。如此反复。中间可以夹杂着一下比如 Bigger than ... 1e-9Smaller than ... 1e9 之类的,把没用的地址去掉。

    最后剩下的就是这个数值了。

    同理:1.651.300.76 可以通过同样的方法获取到。

  2. 人物颜色那个地址是哪来的?

    恕我直言,我不知道怎么获取。

    原贴地址:https://www.52pojie.cn/thread-674430-1-1.html

  3. 基本上算是结帖了,有什么问题可以我也可以回复,这个游戏真的没什么意思了,到处都是神仙,上天入地满地乱爬的,希望大家也尽量少开挂,本着学习交流的心态去使用辅助工具。

  4. 恕我直言,根据以上分析,这个游戏的命中检测肯定实在客户端做的,而且只在打枪的一方做,服务器和被打中的一方没有其他检测。恕我直言这个游戏吃枣药丸。真希望网易尽快解决这个问题。之前也在论坛里看到有人发不用瞄准,随便打,枪枪打中人的动态图,我希望这个功能的外挂我希望还是最好不要放出来。

  5. 我已经卸载游戏了,教程不会继续更新了。

  6. CE 有自带的 Trainer,可以将 CE 存档文件直接生成 exe 可执行文件

  7. 我不会易语言,而且我也不想单独发布外挂,这个教程仅仅是个教程,具体怎么用就看你们自己的了,更何况附件的 CE 存档文件又不是不能用。

    如果想使用其他编程语言编写外挂 可能需要 OpenProcess VirtualAllocEx WriteProcessMemory 等 Windows API 函数,如果要过检测,还需要 Hook 住相关的 API,易语言可能有相关的模块,这个我不太懂。

  8. 我在这方面只是研究了不到一个月,我是靠这个内容申请的吾爱破解账号(会员申请贴)。之前只是对计算机其他东西有所了解,对反汇编、代码注入这方面仅仅只有不到一个月的水平。不要向我要联系方式,也不要提是否收徒这种问题,这篇文章是我在这方面研究的全部内容,就算收徒也不会带来其他的知识了。有相关问题可以回帖,在这个帖子凉之前我应该都会回答。私信我我会视情况回复的,如果是常见问题尽量还是在下面回帖提问。

  9. 2018 年 1 月 26 日有人回帖称,已被秒封,请尽可能小心地进行破解。

附件#

附件是我自己的 Cheat Engine 存档文件(密码52pojie.cn)。

下载 hyxd.zip