2023年3月

数据表示

一般来说字符串数据分为四种:

  • 分类数据
  • 可以在语义上映射维类别的自由字符串
  • 结构化字符串数据
  • 文本数据

词袋表示

这种表示舍弃了输入文本中的大部分结构,如段落、章节、句子和格式,只计算每个单词在每个文本中的出现频次

计算词袋有以下步骤:

  • 分词(tokenization):将每个文档划分为出现在其中的单词,按空格和标点划分。
  • 构建词表(vocabulary building):收集词表,包含出现在任意文档的所有词。
  • 编码(encoding):对于每个文档,计算每个单词在文档中的出现频次。(稀疏矩阵存储)

bag_of_words

CountVectorizer

  • 简单使用
from sklearn.feature_extraction.text import CountVectorizer
vect = CountVectorizer()
vect.fit(bard_words) # train
len(vect.vocabulary_)
vect.vocabulary_
bag_of_words = vect.transform(bard_words) # 词袋表示使用稀疏矩阵存储
bag_of_words.toarray() # 转换成array可视化
  • 改进单词提取

CountVectorizer使用正则表达式进行分词 "\b\w\w+\b"

指定min_df可以减少特征量,仅使用至少在min_df个文档出现的单词

vect = CountVectorizer(min_df=5).fit(text_train)
X_train = vect.transform(text_train)
feature_names = vect.get_feature_names() # get feature name
  • 删除停用词
指定stop_words字段
vect = CountVectorizer(min_df=5,stop_words="english").fit(text_train)
X_train = vect.transform(text_train)

TfidfVectorizer

tf-idf方法

tf-idf即词频-逆向文档频率,这种方法对于在某个特定文档经常出现的术语给予很高的权重,对于在语料库中的不同文档经常出现的术语给予较低的权重,因此高权重的术语更有可能概括整个文档的内容.

$$ tfidf(w,d) = tf \log (\frac {N+1}{N_w+1})+1 $$

sklearn

  • 结合logisticsRegression进行情感预测
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.pipeline import make_pipeline
pipe = make_pipeline(TfidfVectorizer(min_df=5, norm=None),
                     LogisticRegression())
param_grid = {'logisticregression__C': [0.001, 0.01, 0.1, 1, 10]}

grid = GridSearchCV(pipe, param_grid, cv=5)
grid.fit(text_train, y_train)
print("Best cross-validation score: {:.2f}".format(grid.best_score_))
  • 查看tfidf产生的权重
vectorizer = grid.best_estimator_.named_steps["tfidfvectorizer"]
# transform the training dataset:
X_train = vectorizer.transform(text_train)
# find maximum value for each of the features over dataset:
max_value = X_train.max(axis=0).toarray().ravel()
sorted_by_tfidf = max_value.argsort()
# get feature names
feature_names = np.array(vectorizer.get_feature_names())

print("Features with lowest tfidf:\n{}".format(
      feature_names[sorted_by_tfidf[:20]]))

print("Features with highest tfidf: \n{}".format(
      feature_names[sorted_by_tfidf[-20:]]))
  • 查看回归模型的参数
mglearn.tools.visualize_coefficients(
    grid.best_estimator_.named_steps["logisticregression"].coef_,
    feature_names, n_top_features=40)

下载

N元分词

n元分词可以保存句子的结构信息.n个词例可以组成一个n-gram.

  • 一元分词
cv = CountVectorizer(ngram_range=(1, 1)).fit(bards_words)
print("Vocabulary size: {}".format(len(cv.vocabulary_)))
print("Vocabulary:\n{}".format(cv.get_feature_names()))

image-20230328011909671

  • 二元分词
cv = CountVectorizer(ngram_range=(2, 2)).fit(bards_words)
print("Vocabulary size: {}".format(len(cv.vocabulary_)))
print("Vocabulary:\n{}".format(cv.get_feature_names()))

image-20230328011921460

  • 三元分词
cv = CountVectorizer(ngram_range=(1, 3)).fit(bards_words)
print("Vocabulary size: {}".format(len(cv.vocabulary_)))
print("Vocabulary:\n{}".format(cv.get_feature_names()))

image-20230328011954677

  • 热力图表示
# extract scores from grid_search
scores = grid.cv_results_['mean_test_score'].reshape(-1, 3).T
# visualize heat map
heatmap = mglearn.tools.heatmap(
    scores, xlabel="C", ylabel="ngram_range", cmap="viridis", fmt="%.3f",
    xticklabels=param_grid['logisticregression__C'],
    yticklabels=param_grid['tfidfvectorizer__ngram_range'])
plt.colorbar(heatmap)

image-20230328012045341

高级分词/词干提取/词形还原

词干提取/词形还原都属于normalization.使用Porter进行词干提取,使用spacy包实现词形还原

import spacy
import nltk

# load spacy's English-language models
en_nlp = spacy.load('en')
# instantiate nltk's Porter stemmer
stemmer = nltk.stem.PorterStemmer()

# define function to compare lemmatization in spacy with stemming in nltk
def compare_normalization(doc):
    # tokenize document in spacy
    doc_spacy = en_nlp(doc)
    # print lemmas found by spacy
    print("Lemmatization:")
    print([token.lemma_ for token in doc_spacy])
    # print tokens found by Porter stemmer
    print("Stemming:")
    print([stemmer.stem(token.norm_.lower()) for token in doc_spacy])

image-20230328013605939

主题建模和文档聚类

使用隐含迪利克雷分布(LDA)进行主题建模.

vect = CountVectorizer(max_features=10000, max_df=.15)
X = vect.fit_transform(text_train)

from sklearn.decomposition import LatentDirichletAllocation
lda = LatentDirichletAllocation(n_topics=10, learning_method="batch",
                                max_iter=25, random_state=0)
# We build the model and transform the data in one step
# Computing transform takes some time,
# and we can save time by doing both at once
document_topics = lda.fit_transform(X)
  • LDA的components_属性
print("lda.components_.shape: {}".format(lda.components_.shape))
# lda.components_.shape: (10, 10000) 即每个单词对于每个主题的重要性
  • LDA重要性可视化
# for each topic (a row in the components_), sort the features (ascending).
# Invert rows with [:, ::-1] to make sorting descending
sorting = np.argsort(lda.components_, axis=1)[:, ::-1]
# get the feature names from the vectorizer:
feature_names = np.array(vect.get_feature_names())
# Print out the 10 topics:
mglearn.tools.print_topics(topics=range(10), feature_names=feature_names,
                           sorting=sorting, topics_per_chunk=5, n_words=10)

image-20230328014114480

  • 查看主题的整体权重
fig, ax = plt.subplots(1, 2, figsize=(10, 10))
topic_names = ["{:>2} ".format(i) + " ".join(words)
               for i, words in enumerate(feature_names[sorting[:, :2]])]
# two column bar chart:
for col in [0, 1]:
    start = col * 50
    end = (col + 1) * 50
    ax[col].barh(np.arange(50), np.sum(document_topics100, axis=0)[start:end])
    ax[col].set_yticks(np.arange(50))
    ax[col].set_yticklabels(topic_names[start:end], ha="left", va="top")
    ax[col].invert_yaxis()
    ax[col].set_xlim(0, 2000)
    yax = ax[col].get_yaxis()
    yax.set_tick_params(pad=130)
plt.tight_layout()

下载 (1)

exe文件:逆向工程实验一.zip

※步骤一:运行程序,查看特征字符串"Please Input:"通过IDA Pro找到该字符串所在位置,即定位main函数

image-20230322211440304

※步骤二: 在main函数中尝试反汇编,IDA报错,错误信息显示栈sp不平衡.打开IDA Optionals中的Stack pointer,分析程序中哪些位置栈不平衡.发现两处位置不平衡,将两处的push 0注释为nop

一般来说不平衡发生在调用函数前后,调用函数前push参数,那么在函数返回时应该观察SP是否调整去,比如 被调用函数返回时使用带参数返回指令ret N 或 调用者在调用函数后通过add esp,N

image-20230322211424962

image-20230322211536545

image-20230322212114842

image-20230322212144610

image-20230322212242184

※步骤三:至此main函数可以反汇编,得到以下结果.观察各个函数,不难猜测:

  • sub_48CD46printf函数,因为参数中带有输出字符串"Please Input"
  • sub_48C0EEscanf函数,因为参数中带有模式字符串%s,第二个参数是一个地址,可能指向缓冲区,且带有一个常数30,一般是指缓冲区长度.那么unk_5F3068就是用户输入字符串的地址
  • sub_48A9A6strlen函数,参数是用户输入的字符串地址.因为v5由该函数赋值,且v5在之后的判断中与数字进行比较,那么有可能是对输入字符串的长度进行判断.
  • sub_48E5BFstrcopy函数,因为参数有用户输入的字符串首地址/常数30/另外一个地址off_5F3088.且之后都使用off_5F3088进行判断
  • 当v5(输入字符串长度)在10~30范围内,会进入第一个if语句.随后调用了strcopy将输入字符串赋值给了off_5F3088.接着判断off_5F3088+7位置的字符是65('A'),如果不是将会执行sub_48A6DBsub_48C274.这两个函数在下面的else语句中也出现了,应该是程序终止语句.
  • 至此已经发现用户第8个字符输入(从0开始)应该是'A',随后分析sub_48D3A4函数.

屏幕截图 2023-03-22 212952

※步骤三:分析sub_48D3A4,发现它调用了sub_49DBD0.在这个函数中,首先将输入字符串偏移+7处的字符改为35('#'),随后对每个字符都异或0x2E.得到处理后的字符串a2.但是在这个函数内没有发现字符串比较逻辑,说明验证逻辑在其他地方,这个时候可以通过输入字符串的地址off_5F3088入手分析.

image-20230322213214580

※步骤四:右键变量off_5F3088查看哪些位置引用了它(jump to xref),发现sub_49DC80有重大嫌疑.进入函数并反汇编发现'111'和'107'这两个魔数,查ascii表发现正好是ok,说明这里应该是正确逻辑.向上观察发现该函数首先对字符串a2每个字符都异或上0x2D,随后拿a2跟off_5F3088比对,如果相等就会打印ok.(因为字符串比对函数中如strcmp,如果字符串相等会返回0,与这里的sub_48DB42前的!一致)那么接下来找到a2的位置即可

image-20230323093038072

image-20230323093059902

※步骤五:查看函数的调用者,来获取a2的信息.经过两次跳转后发现a2就是变量a1234IsAnExa567,查看值为"1234 is an exa5678"

image-20230323093336129

image-20230323093349066

※步骤六:根据上述流程,编写python脚本获取正确的flag,程序如下.得到答案2107#jpAbm#f{b654;.通过执行程序,可以验证flag的正确性

image-20230323095836757

image-20230323095342035

作品说明

对AssaultCube 1.3.0.2进行攻击,实现透视、自瞄类的作弊功能。

实验环境

调试工具:x64dbg(使用32位版本x32dbg)、Cheat Engineer

开发工具:Visual Studio 2019

准备工作

1.定位玩家基地址和相关结构

1)首先通过CE附加到AssaultCube(以下统称AC),通过多次扫描得到玩家的位置/视角信息。

注1:可以在多次扫描中穿插扫描变化/不变的值,进一步缩小选择范围。或者使用AC自带的控制台功能(~),使用dbgpos 1得到玩家当前的位置信息和视角信息。如下图右下角所示。

注2:为了方便调试之后的内容(透视/自瞄),可以使用控制台 idlebots 1禁用机器人移动和射击。

image-20230109154314268

2)找到YAW信息后,查找是哪条指令向其写入数据,得到0x4BF922。使用x32dbg进行反汇编。

image-20230109155319088

3)0x4BF922处的指令是movss,数据来源于ecx+34指向的地址,向上翻找汇编代码,可以看到ecx在0x4BF905被赋值(数据来源于数据段的指针0x57E0A8),打下断点,查看此时ecx的值为0x00963080。查看这块内存,可以看到这片内存中包含了玩家的名字unarmed,可以推测这片内存就是存放了玩家的结构信息。

image-20230109161612558

4)我们知道游戏必须在内存中存储有关每个玩家的数据。此数据通常位于内存的连续部分中。在 C 或 C++ 中,这将表示为结构或类。例如,游戏可以定义玩家结构,如下所示:

struct Player {
    float x;
    float y;
    float z;
    float yaw;
    float pitch;
    char model_texture_path[128];
    char name[128];
    bool alive;
}

5)在 x64dbg 中查看时,我们可以将内存换成浮点数表示,可以直接看到玩家的坐标信息就在0x963084开头的3个32位浮点数中。

image-20230109162304484

6)同时也可以找到偏仰角和偏转角的位置。

image-20230109162536802

7)这个时候我们就找到了玩家自己的位置信息和视角信息相对于玩家基址的偏移量了。根据偏移信息重写player类结构。同时由3)知道指向玩家基地址的指针为0x57E0A8

struct Player {
    char unknown[4];
    float x;
    float y;
    float z;
    char unknown[36];
    float yaw;
    float pitch;
}

2.定位敌方玩家列表

1)在游戏中定位敌方玩家,可以创建一个包含 8 个机器人的游戏。一般来说游戏会将敌人存储在内存某个列表中。那怎么获得列表的基地址呢?首先我们注意到当鼠标悬停在玩家上时,屏幕左下角将显示该玩家的名称。那么游戏必须有一段函数用于遍历游戏中的玩家并寻找其名称。通过这个信息获得敌方玩家所在!

image-20230109164321447

2)使用CE查找字符串,查看真正改变玩家名字的内存。并在这个内存上打上访问断点,查看访问次数最多的指令0x481B02.

image-20230109172807022

3)该语句在循环中,调试发现ecx先定位到每个玩家的基址,再通过0x481AF7的add ecx,205移到该玩家的名字字符串处。进行向上查找,很容易发现0x481AE6的语句,ebx+esi*4很像循环遍历元素,基本确定此时ebx的值就是玩家(机器人)链表基址。而基址存放在数据段0x58AC04的指针中(且第一个玩家在基址+4处),同时edi中存放了当前游戏总人数0x10,所以玩家人数应该是通过数据段0x58AC0C这个指针得到。且add ecx,205表示名字相对于基址的偏移是0x205.

image-20230109173933890

3.获取玩家名字/存活状态/阵营

1)上节我们获得了玩家的名字相对于基地址的偏移0x205,那怎么获得名字的最大长度呢?比较简单的方法就是取名试试,在设置中我们发现最多给名字取15个字符,加上字符串尾部的'\0',名字字段分配16个字节即可。

image-20230110113944212

2)玩家多次自杀,查看内存变化,可以发现有一个字节在玩家死后从0->1,合理猜测这就是标志玩家是否死亡的字段,距离角色基地址的偏移为+318

image-20230109190841199

3)寻找玩家阵营字段,通过修改settings->game settings->change to the enemy team,观察角色内存变化,可以找到一个值在0/1来回变化,可以猜测它就表示阵营字段,其对于角色基地址的偏移为+30c

image-20230110112318776

准备工作总结

经过以上对玩家本身和其他玩家的逆向分析,我们得到了以下数据:

默认程序装载地址为0x400000,即ac_client.exe=0x400000
  • 存放玩家自身基地址的指针0x57E0A8(ac_client.exe+0x17E0A8)
  • 存放本局游戏玩家总数的指针0x58AC0C(ac_client.exe+0x18AC0C)
  • 存放玩家列表基地址的指针0x基址存放在数据段0x58AC04的指针中(ac_client.exe+0x18AC04,且第一个玩家在基址+4处)
  • 玩家的坐标(x,y,z)当前视角(yaw,pitch)存活状态(died)名字(named)阵营信息(team)相对于基地址的偏移关系如下,通过struct结构来表示。unknown是我们不需要的填充字段。
struct Player {
    char unknown1[4];
    float x;
    float y;
    float z;
    char unknown2[0x24];
    float yaw;
    float pitch;
    char unknown3[0x1C9];
    char name[16];
    char unknown4[0xF7];
    int team;
    char unknown5[0x8];
    int dead;
};

DLL/注射器模板

注射器模板

​ 实现透视/自瞄两个功能都通过DLL注入来实现,这里采用的是远程线程注入方法,具体实现方法已经在作业二中详细说明,以下给出注射器关键代码。

代码模板参考 微软官方文档
#include <windows.h>
#include <tlhelp32.h>
#include<stdio.h>
// The full path to the DLL to be injected.
const char* dll_path = "C:\\YourDLLPath\\test.dll";

int main(int argc, char** argv) {
    HANDLE snapshot = 0;
    PROCESSENTRY32 pe32 = { 0 };

    DWORD exitCode = 0;

    pe32.dwSize = sizeof(PROCESSENTRY32);

    snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    Process32First(snapshot, &pe32);

    do {
        // 确认进程名
        if (wcscmp(pe32.szExeFile, L"ac_client.exe") == 0) {
            printf("%d", pe32.th32ProcessID);//打印pid
            //获得进程句柄
            HANDLE process = OpenProcess(PROCESS_ALL_ACCESS, true, pe32.th32ProcessID);
            // 创建内存存放dllPath
            void* lpBaseAddress = VirtualAllocEx(process, NULL, strlen(dll_path) + 1, MEM_COMMIT, PAGE_READWRITE);

            // 写入dllPath
            WriteProcessMemory(process, lpBaseAddress, dll_path, strlen(dll_path) + 1, NULL);

            // 远程线程注入
            HMODULE kernel32base = GetModuleHandle(L"kernel32.dll");
            HANDLE thread = CreateRemoteThread(process, NULL, 0, (LPTHREAD_START_ROUTINE)GetProcAddress(kernel32base, "LoadLibraryA"), lpBaseAddress, 0, NULL);
            WaitForSingleObject(thread, INFINITE);
            GetExitCodeThread(thread, &exitCode);
            VirtualFreeEx(process, lpBaseAddress, 0, MEM_RELEASE);
            CloseHandle(thread);
            CloseHandle(process);
            break;
        }
    } while (Process32Next(snapshot, &pe32));
    return 0;
}

DLL模板

​ 无论是自瞄或者透视,都需要实时获取所有玩家的信息,那么就需要一个永真循环包裹住外挂代码。其次因为是永真循环,需要创建子线程完成外挂功能,否则会导致游戏卡死。模板如下。

#include <Windows.h>

void injected_thread() {
    while (true) {
        //your cheat code
        Sleep(1);
    }
}

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
    if (fdwReason == DLL_PROCESS_ATTACH) {
        CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)injected_thread, NULL, 0, NULL);
    }

    return true;
}

自瞄实现

实现自瞄主要是需要修改玩家的视角信息,即偏航角(yaw)和仰角(pitch)

偏航角

​ 获取偏航角,首先需要得到这两个玩家的相对距离,直接将对应的x,y坐标相减即可。再通过atan2f函数得到上图θ角的弧度制,稍加转换就可以得到其角度值。

需要注意的是,对于偏航角yaw。AssaultCube的初值就是90°,因此计算得到的yaw还需要+90才是正确的值
float abspos_x = enemy->x - player->x;
float abspos_y = enemy->y - player->y;
float azimuth_xy = atan2f(abspos_y, abspos_x);
float yaw = (float)(azimuth_xy * (180.0 / M_PI));
yaw = yaw + 90;

俯仰角

仰角同样也可以通过atan2f函数得到仰角的弧度制,稍加转换就可以得到其角度值。

float abspos_z = enemy->z - player->z;
float temp_distance = euclidean_distance(abspos_x, abspos_y);//计算斜边
float azimuth_z = atan2f(abspos_z, temp_distance);
pitch = (float)(azimuth_z * (180.0 / M_PI));

确定自瞄目标

​ 以上都是对于单个敌人计算的。在多人对战里面,需要需要遍历所有敌人,确定自瞄的目标。如何确定目标呢,我们可以先暂时认为距离最近的敌人就是我们下一个需要自瞄的目标。遍历代码如下。

注1:这个判断逻辑其实是有瑕疵的,比如距离我们最近的敌人在一堵墙后面,我们就没办法杀死对方。更好的方法是,寻找距离屏幕的十字准星最近的敌人,这涉及到视角矩阵方面的知识,刚好透视也需要用到。到时候再对自瞄敌人选择算法进行改良。整个代码框架是基本一致的。改良见自瞄选择优化

注2:这里遍历的i从1开始,原因在准备工作2中提到过。玩家列表的基地址+4的内容才是第一个玩家的基址

DWORD* player_offset = (DWORD*)(0x57E0A8);
player = (Player*)(*player_offset);    //获得玩家基地址
int* current_players = (int*)(0x58AC0C);    //获得当前玩家人数
for (int i = 1; i < *current_players; i++) {
    DWORD* enemy_list = (DWORD*)(0x50F4F8);    //存放玩家列表的指针
    DWORD* enemy_offset = (DWORD*)(*enemy_list + (i*4));
    Player* enemy = (Player*)(*enemy_offset);
    if(enemy->team == player->team) continue;     //同阵营是队友
    float temp_distance = euclidean_distance(abspos_x, abspos_y);
    if (closest_player == -1.0f || temp_distance < closest_player) {
        closest_player = temp_distance;
        ...
        closest_yaw = yaw + 90;
        ...
        closest_pitch = (float)(azimuth_z * (180.0 / M_PI));
    }
}
//迭代完成
player->yaw = closest_yaw;
player->pitch = closest_pitch;
Sleep(1);

自瞄效果

​ 将以上代码填入DLL模板中,并使用注射器注入到游戏中(具体代码见上文),可以看到此时视角已经不能由鼠标控制,直接指向了最近的一个敌人的头部,杀死敌人后视角会转向另一个存活的最近的敌人,符合预期。杀死敌人前后视角的转换见以下两图。我们已经实现了自瞄功能!

image-20230110001416872

image-20230110001747180

注:在之后完成透视功能的实现后,将对自瞄选择算法进行优化。并且将实现自瞄和透视两个功能的整合,并通过快捷键实现功能的开启/关闭

自瞄优化

​ 之前自瞄选择算法的思想是选出距离最近的敌人,我们将改良为选择距离十字准星最近的敌人。视角矩阵转换算法在此处中已经提到,我们可以利用WorldToScreen算法中screenW是否为正数来判断敌人是否在视场范围内,如果不在视角范围内就不考虑这个敌人(如果当前视场没有敌人,用户可以移动鼠标直到视场出现其他敌人,恢复自瞄功能,这一点考虑是为了优化用户体验)。

​ 在视场中的敌人,将继续计算得到他们在屏幕上的坐标,通过这个坐标和准星坐标(默认在屏幕中心)计算敌人和准星之间的距离。最后同样是遍历所有敌人,找到在视场中并且离准星最近的敌人作为自瞄目标。

​ 判断代码如下。外面再套一层循环遍历敌人即可。

bool Seen(ViewMatrix* view,Player *enemy,int width, int height,float &dis) {
    //得到相机xyw
    float screenX = (view->m11 * enemy->x) + (view->m21 * enemy->y) + (view->m31 * enemy->z) + view->m41;
    float screenY = (view->m12 * enemy->x) + (view->m22 * enemy->y) + (view->m32 * enemy->z) + view->m42;
    float screenW = (view->m14 * enemy->x) + (view->m24 * enemy->y) + (view->m34 * enemy->z) + view->m44;
    if (screenW <= 0.001f) {    //敌人不在视场范围内
        return false;
    }
    //准心在屏幕的位置
    float camX = width / 2.0f;
    float camY = height / 2.0f;

    //计算敌人坐标和准星坐标的差值
    float deltax = (camX * screenX / screenW);
    float deltay = (camY * screenY / screenW);
    dis = euclidean_distance(deltax, deltay);    //计算敌人和准星的距离
    return true;
}

​ 优化后自瞄效果如下。开启自瞄功能后,我们将优先射击距离准星最近的敌人,而不是直线距离最近的敌人。这很好的控制了视角的稳定,既保护了眼镜,也减少了开挂被发现的风险(人物模型基本不会抽搐旋转)。

image-20230111133715107

image-20230111133822393

透视实现

透视原理

​ 透视原理需要用到投影方面的知识,简单来说就是游戏是3D的,需要通过2D屏幕来反馈信息,这就涉及到了3D->2D的算法,一般来说FPS游戏使用视角矩阵来描述玩家视角的信息,通过对这个矩阵进行计算可以得到3D世界中的某个点在屏幕上的横纵坐标。

获取视角矩阵

通过CE先查找初始值未知的值,再逐遍扫描变化的值。在这期间可以移动、打开狙击枪扫描镜使视角变化,缩小查找范围,最后找到了一块变动的4*4矩阵区域,最后一行数值很大,其他数值不超过2,开镜、跳跃时的数值特征与视角矩阵一致。可以猜测这就是视角矩阵,基址为0x57DFD0.

根据视角矩阵的特征可以更快定位,对于横矩阵,最后一行的数值(Float表示)会非常大,而前几行一般在-2~2之间。纵矩阵则是最后一列的数值会非常大。除此之外矩阵中第三行第一列(或者第一行第三列)一般是定值0.0。根据这几个特征就可以更快找到视角矩阵的内存位置。

image-20230110150746670

WorldToScreen转换

​ 我们得到的视角矩阵是4*4的横矩阵,所以采用横矩阵的算法(伪代码)如下。其中Pos是敌人的坐标信息,width和height分别是屏幕的宽和高。算法返回值x和y是敌人在屏幕上的二维坐标,screenW则是判断敌人是否在视角范围内(如果screenW>0,则Pos在视角范围内)

屏幕宽度width在0x591ED8处

屏幕高度height在0x591EDC处

struct ViewMatrix{
    //视角矩阵     X,   Y,   Z,   W
    float m11, m12, m13, m14; //00, 01, 02, 03
    float m21, m22, m23, m24; //04, 05, 06, 07
    float m31, m32, m33, m34; //08, 09, 10, 11
    float m41, m42, m43, m44; //12, 13, 14, 15
}
Vector3 WorldToScreen(Vector3 Pos, int width, int height){
            //得到相机xyw
            float screenX = (m11 * Pos.x) + (m21 * Pos.y) + (m31 * Pos.z) + m41;
            float screenY = (m12 * Pos.x) + (m22 * Pos.y) + (m32 * Pos.z) + m42;
            float screenW = (m14 * Pos.x) + (m24 * Pos.y) + (m34 * Pos.z) + m44;

            //准心在屏幕的位置
            float camX = width / 2f;
            float camY = height / 2f;

            //转换成敌人在屏幕的坐标
            float x = camX + (camX * screenX / screenW);
            float y = camY - (camY * screenY / screenW);
             return x,y,screenW;
}

绘制方框

​ 我们找到了视角矩阵,在之前的准备工作中也知道了敌人的位置坐标,经过横矩阵转换算法可以得到敌人在屏幕中的二维坐标,现在需要将敌人的位置绘制出来。这里采用基于透明窗口的方框绘制,使用的图形库是GDI。

​ 首先是透明窗口的构建,关键代码如下。流程是先获取游戏的窗口句柄window,根据窗口句柄获得窗口大小和位置(GetClientRect and ClientToScreen)。根据这些参数通过CreateWindowEx创建新窗口,新窗口大小位置和游戏窗口一致,且需要配置成透明(config中设置)。

    Window = FindWindowA("SDL_app", "AssaultCube");
    GetClientRect(Window, &rc);
    WNDCLASSEX wincl;
    //.....some confing
    RegisterClassEx(&wincl);
    POINT point;
    point.x = rc.left;
    point.y = rc.top;
    ClientToScreen(Window, &point);
    NewWindow = CreateWindowEx(
        (WS_EX_TOPMOST|WS_EX_TRANSPARENT|WS_EX_LAYERED),
        (L"lookaway"),
        (L"lookaway"),
        (WS_POPUP|WS_CLIPCHILDREN),
        point.x,
        point.y,
        width,
        height,
        0,0,wincl.hInstance,0
    );

​ 窗口创建后,就利用GDI的API进行图形绘制。首先通过GetDC检索一指定窗口的客户区域或整个屏幕的显示 设备上下文环境的句柄,便于之后的画图。

    hDC = GetDC(NewWindow);
    COLORREF bkcolor = GetBkColor(hDC);
    SetLayeredWindowAttributes(NewWindow, bkcolor, 0, LWA_COLORKEY);
    ShowWindow(NewWindow, SW_SHOWNORMAL);
    UpdateWindow(NewWindow);

​ 接下来设置画图信号,每当接收到VM_PAINT信号后将执行Draw函数。VM_PAINT信号由UpdateWindow(NewWindow)发出

LRESULT CALLBACK WindowProcedure(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    switch (message)
    {
    case WM_DESTROY:
        PostQuitMessage(0);
        break;
    case WM_LBUTTONDOWN:
        break;
    case WM_PAINT:
        Draw();    //绘制方框
        break;
    default:
        return DefWindowProc(hwnd, message, wParam, lParam);
    }
    return 0;
}

​ Draw函数实现如下,为了防止图像闪烁问题,这里采用双缓冲机制绘制。首先绘制方框,再绘制每个人的名字,其中敌人的边框和字体颜色为红色,友军的边框字体颜色为绿色

为了方框可以自适应敌人大小,这里采用两个坐标绘制。除了敌人的头部坐标外,还有脚部坐标(敌人头部坐标z值减4.5)。
void Draw() {
    HBITMAP hbitmap = CreateCompatibleBitmap(hDC, width, height);
    HGDIOBJ oldbitmap = SelectObject(memDC, hbitmap);
    BitBlt(memDC, 0, 0, width, height, 0, 0, 0, WHITENESS);
    for (int i = 1; i < *current_players; i++) {
        int offset = (val_yfoot[i] - val_yhead[i]) / 6;
        //绘制边框
        SelectObject(memDC, GetStockObject(DC_PEN));
        is_enemy[i] ?SetDCPenColor(memDC, rgbRed) : SetDCPenColor(memDC, rgbGreen);
        Rectangle(memDC, val_xhead[i]-2*offset, val_yhead[i]-offset, val_xfoot[i]+2*offset, val_yfoot[i]);
        //绘制名字
        is_enemy[i] ? SetTextColor(memDC, rgbRed) : SetTextColor(memDC,rgbGreen);
        SelectObject(memDC, font);
        TextOutA(memDC, val_xhead[i], val_yhead[i],name[i], strlen(name[i]));
    }
    BitBlt(hDC, 0, 0, width, height, memDC, 0, 0, SRCCOPY);
    DeleteObject(hbitmap);
}

透视效果

​ 注入DLL后,开启一把多人对战,可以看到每个人都被方框绘制出来了,同时也显示了名字(也可以选择显示血量等敌人信息,展示为了画面简洁只显示姓名)。敌我双方也用红色/绿色标识。透视功能实现成功!

image-20230111134450037

代码重构和热键实现

代码重构

​ 之前的自瞄和透视功能写在了两个dll中,而两者代码也有部分重叠。可以考虑将代码重构,合并一些共同变量如人物基址、偏移等。两个头文件如下:

#pragma once
#define offset_Player_base_ptr 0x57E0A8
#define offset_Player_num 0x58AC0C
#define offset_Player_list 0x58AC04
#define offset_viewMatrix 0x57DFD0
#define offset_GameWidth 0x591ED8
#define offset_GameHeight 0x591EDC
#pragma once
#include <Windows.h>
#include "offset.h"

struct Player {    //逆向得到的玩家类
    char unknown1[4];
    float x;
    float y;
    float z;
    char unknown2[0x24];
    float yaw;
    float pitch;
    char unknown3[0x1C9];
    char name[16];
    char unknown4[0xF7];
    int team;
    char unknown5[0x8];
    int dead;
};

struct ViewMatrix {    
    //视角矩阵     X,   Y,   Z,   W
    float m11, m12, m13, m14; //00, 01, 02, 03
    float m21, m22, m23, m24; //04, 05, 06, 07
    float m31, m32, m33, m34; //08, 09, 10, 11
    float m41, m42, m43, m44; //12, 13, 14, 15
};

//开挂函数
void InitPlayer();    //初始化玩家类
float euclidean_distance(float, float);    //勾股定理
bool Seen(Player* target);    //该敌人是否可见
void ESP();    //透视
void AutoAim();    //自瞄
void UpdateAim();    //更新准星

//绘图
void Draw();    //绘图
LRESULT CALLBACK WindowProcedure(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam);    
void InitWindow();    //初始化窗口
void DeleteWindow();    //删除窗口

热键绑定

​ 接下来实现自瞄/透视功能的开关。这需要用到热键的绑定。比如按下F1实现自瞄的开启/关闭,按下F2实现透视的开启/关闭。这里需要用到GetAsyncKeyState() & 1获取键盘信息。

​ 以透视功能为例,每次按下F1将修改状态modeESP,如果从关到开需要初始化透明窗口,如果从开到关需要关闭透明窗口。同时如果modeESP开着,需要执行ESP()函数进行坐标的计算和绘制

while (1) {
    //Menu();
    if (GetAsyncKeyState(VK_F1) & 1) {    //按下F1
        modeESP = !modeESP;
        if (modeESP == true) {
            InitWindow();
        }
        else{
            DeleteWindow();
        }
    }
    if (modeESP)    ESP();
    if (GetAsyncKeyState(VK_F2) & 1) {    //按下F2
        modeAutoAim = !modeAutoAim;
    }
    if (modeAutoAim)    AutoAim();
    Sleep(1);
}

作品最终展示

​ 本作品实现了对Assualt Cube 1.3.0.2这个FPS游戏的逆向分析,并通过DLL远程线程注入的方式下实现了自瞄/透视两个外挂功能。敲击F1开启/关闭透视功能,敲击F2开启/关闭自瞄功能

​ 透视功能中敌我双方用红(绿)色方框标识,并标记名字。方框大小也随人物远近动态变化

image-20230111224417041

​ 自瞄功能开启后,如果视角范围内存在敌人准星将自动移到敌人头部

image-20230111224517148

​ 原本DLL还实现了菜单功能,但是有些许BUG就不展示了....

​ 视频展示见Assault Cube 1.3.0.2 逆向分析 自瞄/透视实现 2023/1/12.

参考博客

About - Game Hacking Academy

摘要

​ Faster RCNN在Fast RCNN的基础上增加了RPN网络来代替比较耗时的Selective SearchRPN网络和检测网络共享同一张卷积特征图,他同时预测每个位置的前景(背景)概率和每一类相对于锚框的偏移,得到的预测框将送入Fast RCNN的检测头进行进一步的分类和BBOX回归。

​ 对于非常深的VGG-16模型,Faster RCNN在GPU上的帧速率为5fps(包括所有步骤),同时在PASCAL VOC 2007、2012和MS COCO数据集上实现了最先进的对象检测精度,每张图像只有300个建议框。在ILSVRC和COCO 2015竞赛中,Faster R-CNN和RPN是多个赛道第一名获奖作品的基础。

算法流程

image-20230308091659327

image-20230308091742426

RPN网络结构

正向传播

image-20230308091838188

对于特征图上的每个3x3的滑动窗口(实际实现就是用conv3x3 p1 s1),首先计算窗口中心点在原图上的位置,并计算出k个anchor box

​ 对于anchor的选择,共有三种比例,三种尺度,即每个位置都有9个anchor

image-20230308092338534

感受野

​ 对于特征图上的感受野,骨干网络为ZFNet时,感受野为171;骨干网络为VGG16时,感受野为228(具体计算如下)

image-20230308092633528

有个问题是,无论是VGG/ZF骨干网络,一个位置的感受野最多也就是228x228,那为什么anchor box的尺寸可以设置到256x256甚至512x512? 在论文中解释是说,通过物体的局部来预测物体是有可能的,实际上这么设置表现也确实有所提升

image-20230308092958530

anchor->proposal

image-20230308093042856

正负样本选择

​ 训练时,每个mini-batch,即每张图片随机选择256个anchor,这些anchor中正负样本比例大约是1:1。如果正样本数目不足128,那就用负样本补足到256.

image-20230308093402534

​ 正负样本定义如下:

正样本:①和某个GT Box的IoU大于0.7

​ ②是和某个GT Box的IoU最大的anchor(就是对第一条规则的补充,避免出现某个GT Box没有分配anchor)

负样本:和所有的GT Box的IoU都小于0.3

RPN 损失

​ RPN网络的损失分为两部分,分类损失和边界框损失。分类主要是衡量预测框中是否含有物体(类似于YOLO系列中的objectness),边界框损失就是简单的回归损失。

image-20230308093901988

image-20230308093935964

​ 分类损失有两种实现:

​ 如果每个anchor只有一个预测值,那么就使用BCE二元交叉熵损失

image-20230308094354826

​ 如果每个anchor有两个预测值,那么就使用Softmax Cross Entropy

image-20230308094431386

​ 边界框回归损失和Fast RCNN一样,都是用SmoothL1 Loss.标注值t*的计算参考RCNN中的实现。

image-20230308094537614

Detect Loss

​ 检测网络的损失和Faster-RCNN网络的损失一摸一样。这里不再赘述。

image-20230307213352907

网络训练

image-20230308094828414

现在可以直接采用RPN loss+Fast R-CNN Loss联合训练网络

image-20230308095010636

自己的一点理解

​ 从损失函数来看,Faster R-CNN和YOLO挺像的(应该是YOLO借鉴的ROI思想)。

​ 在YOLO系列中,对于每个grid cell会预测k个anchor(since v2),对于每个anchor会预测一个置信度(objectness),边界框偏移和类别预测。这里的类别预测同v1一样,是条件概率,即在置信度基础上的概率。

​ 而在Faster R-CNN中,置信度和类别预测被拆开成了两个网络,RPN网络负责预测置信度和边界框偏移;Fast R-CNN网络负责预测类别和边界框偏移

​ RPN网络提供Proposal,其实就是在置信度的基础上筛选出一些质量高的锚框(当然还需要根据边界偏移调整获得最后的region proposal)。

​ Fast R-CNN接到Proposal后,根据它在特征图上的映射获得特征矩阵,再通过RoI pooling层获得获得固定大小的矩阵,送入全连接层预测类别和bbox回归。那么这里的类别同样可以理解成条件概率,因为得到的候选框是经过RPN网络筛选过的,即在置信度满足一定条件时预测出的概率。

​ 所以YOLO系列其实就是将RPN网络和Fast R-CNN网络进行了合并。至于锚框生成,区别于RPN网络在特征图上通过滑动窗口生成,YOLO更加简单粗暴,直接将原图分割并在每个单元上生成锚框

相较于RCNN的改进

  • Fast RCNN仍然使用selective search选取2000个建议框,但是这里不是将这么多建议框都输入卷积网络中,而是将原始图片输入卷积网络中得到特征图,再使用建议框对特征图提取特征框。这样做的好处是,原来建议框重合部分非常多,卷积重复计算严重,而这里每个位置都只计算了一次卷积,大大减少了计算量
  • 由于建议框大小不一,得到的特征框需要转化为相同大小,这一步是通过ROI池化层(region of interest)来实现的
  • Fast RCNN里没有SVM分类器和回归器了,分类和预测框的位置大小都是通过卷积神经网络输出
  • 为了提高计算速度,网络最后使用SVD代替全连接层

算法流程

  • 输入一张图片,使用Selective Search获取建议框(region proposal)
  • 将原始图片输入卷积神经网络之中,获取特征图
  • 对每个建议框,从特征图中找到对应位置(按照比例映射),截取出特征框(深度保持不变)
  • 将每个特征框划分为 HxW个网格(论文中是 7×7 ),在每个网格内进行最大池化(即每个网格内取最大值),这就是ROI池化。这样每个特征框就被转化为了 7×7×C 的矩阵
  • 每个矩阵展平为一个向量,分别作为之后的全连接层的输入
  • 全连接层的输出有两个,计算class得分bounding box回归。前者是sotfmax的21类分类器(假设有20个类别+背景类),输出属于每一类的概率(所有建议框的输出构成得分矩阵);后者是输出一个 20×4 的矩阵,4表示(x, y, w, h),20表示20个类,这里是对20个类分别计算了框的位置和大小
  • 对输出的得分矩阵使用非极大抑制方法选出少数框,对每一个框选择概率最大的类作为标注的类,根据网络结构的第二个输出,选择对应类下的位置和大小对图像进行标注

image-20230307213534355

网络结构

​ 网络backbone采用VGG-16,不用resnet是因为那个时候还没有resnet

​ 最开始仍然是在ImageNet数据集上训练一个1000类的分类网络,随后将模型进行以下改动

  • 最后一个最大池化层换成ROI池化层
  • 将最后一个全连接层和后面的softmax1000分类器换成两个并行层,一个是全连接层1+21分类器,另一个是全连接层2+表示每个类预测框位置的输出

​ 使用变动后的模型,在标注过的图像数据上fine-tuning,训练时要输入图像、标注(这里将人为标注的框称为ground truth)和建议框信息。这里为了提高训练速度,采取了小批量梯度下降的方式,每次使用2张图片的128张建议框(每张图片取64个建议框)更新参数

训练网络

每次更新参数的训练步骤如下

  • 2张图像直接经过前面的卷积层获得特征图
  • 根据ground truth标注所有建议框的类别。具体步骤为,对每一个类别的ground truth,与它的iou大于0.5的建议框标记为groud truth的类别(正样本),对于与ground truth的iou介于0.1到0.5之间的建议框,标注为背景类别(负样本)
  • 每张图片随机选取64个建议框(要控制背景类的建议框占75%),提取出特征框
  • 特征框继续向下计算,进入两个并行层计算损失函数
  • 反向传播更新参数(关于ROI池化的反向传播细节可以参考这篇博客

image-20230307213611296

损失函数

​ 跟YOLO系列类似(其实应该是YOLO与rcnn类似),损失函数分成两部分——分类损失和回归损失。

  • 对类别输出按照softmax正常计算损失(交叉熵损失)
  • 对框的位置的损失方面,标注为背景类的建议框(负样本)不增加损失(体现在下面公式中的 [u>1] 艾弗森括号)。对于标注为物体类别的建议框(正样本)来说,先计算ground truth的四个标注参数,再和网络的预测值来计算loss(采用smoothL1 loss)

image-20230307213352907

论文中的其他

  • 全连接层使用SVD分解来减少计算时间
  • 模型在各种数据集上的测试效果及对比
  • 在fine-tuning基础上更新哪些层的参数实验
  • SVM V.S. softmax,输入多种规格的图片,更多训练数据等等

image-20230307213740234