记一次SAI2的逆向

查看 89|回复 9
作者:ApollosLegends   
程序介绍
画画程序
演示版保护机制
程序启动后弹窗提示“The program is started in the restricted mode because a valid license certificate is not installed.”


Pasted image 20250313191743.png (11.73 KB, 下载次数: 0)
下载附件
2025-3-22 18:31 上传

单击确定后提示窗口消失转而进入主窗口,但是主窗口很多菜单都是灰色的不可用状态


Pasted image 20250313192016.png (135.07 KB, 下载次数: 0)
下载附件
2025-3-22 18:30 上传

这里大致猜测程序是调用WindowsAPI的EnableMenuItem()函数设置菜单不可用,等下可以考虑给这个函数下断点,或者直接禁用这些函数实现爆破。
我之前在网上了解过SAI是使用证书进行激活的,尚且不知道激活过程是否联网。
但是软件启动的时候并未有输入激活码的选项,说以证书可能是以文件形式存储或者是存在注册表里。
SAI2的最新发行版是便携版,不需要安装。
观察文件目录发现除了启动程序并未存在其他动态链接库(dll文件),因此可以肯定程序的验证流程就在启动文件里。
逆向过程
使用IDA打开启动程序,IDA成功识别到了软件的WinMain函数,一个是程序没有加壳,还省了我再去找main函数入口了。
可以观察到函数有很多简单的返回分支和一个很复杂的分支。


Pasted image 20250313193325.png (125.32 KB, 下载次数: 0)
下载附件
2025-3-22 18:35 上传

经过观察,这些分支程序都是打印错误消息的


Pasted image 20250313193825.png (84.55 KB, 下载次数: 0)
下载附件
2025-3-22 18:36 上传

全部掠过,再来看主分支,这里调用了两个函数。进入观察后每发现个函数都很复杂,直接看是看不出来的。所以我打算去字符串窗口中找找,看看能不能找到弹窗字符串,然后根据字符串反向查找调用。但是理想很丰满,现实很骨感:(根本查不到字符串。
所以现在有两种可能摆在面前:
1.程序的字符串是保存在文件里的,程序在运行的时候才能访问。(查过了,没有本地字符串文件)
2.程序对字符串加密了,所以看不到。
当然这是我刚开始调试的想法,其实字符串一直就摆在程序里,只是用来UTF-16LE编码。所以只要给IDA设置好了就能正确看到字符串了。直接右键字符窗口任意位置选择Setup,然后给所有字符串类型全打勾就可以了。


Pasted image 20250313200725.png (50.8 KB, 下载次数: 0)
下载附件
2025-3-22 18:37 上传



Pasted image 20250313200741.png (40.31 KB, 下载次数: 0)
下载附件
2025-3-22 18:37 上传

正确设置了以后就能查到字符串了。但是这个程序使用了通过一段整形+转换函数来动态获取字符串地址,这样就没办法直接用交叉引用来逆向寻找调用函数。
动态字符串获取函数:


Pasted image 20250322182157.png (37.38 KB, 下载次数: 0)
下载附件
2025-3-22 18:38 上传

所以这个方法不能用了。
这里就先略过我当时的无用尝试过程了。
在那之后我改变思路想先找到是哪个函数调用让程序产生第一张图里的弹窗,所以果断开启调试。
程序报错了?提示内存读取读取越界??难道有反调试吗?


Pasted image 20250313194607.png (23.35 KB, 下载次数: 0)
下载附件
2025-3-22 18:41 上传

先把问题放一下,看看到底怎么回事,单击ok让程序停在报错的指令上。


Pasted image 20250313194759.png (349.65 KB, 下载次数: 0)
下载附件
2025-3-22 18:41 上传

看到是.text:000000014019F09D mov     rsi, [rdi+rbx*8]这一句访问越界了,不是严重错误,看样子好像并不会让程序直接退出。头铁直接回去下断点然后把异常给程序处理执行试试。


Pasted image 20250313195335.png (36.66 KB, 下载次数: 0)
下载附件
2025-3-22 18:42 上传

好像并没有发生什么,程序备用报演示版提示弹窗,也没有退出,然后还断在我设的断点上。
经过我后面的调试发现这个貌似是个bug,老版本的SAI2也有这个问题。


Pasted image 20250313195715.png (130.47 KB, 下载次数: 0)
下载附件
2025-3-22 18:42 上传

然后我们步过执行程序,突然调试器在上图中标红的函数处停下了,弹窗出来了。
这里我试了单步追踪,但是函数太多了,不好操作。不如换一个思路。
演示版提示弹窗是==第一个==被显示的窗口,所以只要给ShowWindow()函数下断,然后找到第一个调用该API的地方就行了。


Pasted image 20250313204326.png (113.13 KB, 下载次数: 0)
下载附件
2025-3-22 18:46 上传

这里就是程序第一次调用显示窗口API的地方。然后一直运行到返回,我们找到了显示弹窗的关键函数。我在这里对函数也做了重命名。见下图。


Pasted image 20250313204345.png (104.34 KB, 下载次数: 0)
下载附件
2025-3-22 18:46 上传



Pasted image 20250313204537.png (37.59 KB, 下载次数: 0)
下载附件
2025-3-22 18:46 上传

在函数体内下断点,重启程序,我们看到堆栈内有弹窗中对应的字符串。这里可以推测这个函数不只是用来显示演示版弹窗的。程序所有的报错消息应该都会交由这个函数显示。而且我们也能根据堆栈内字符串知道字符串指针的获取不在该函数的代码范围内。
这里依旧运行到返回。 ^1b953a
我们发现在该函数上方存在两个的关键判断语句(地址0x14000B380)。箭头是我们的弹窗函数,波浪线画的是判断是指令上的第一个函数,而且这两个函数相同。
同时根据函数的第二个参数可以看出,它是个路径字符串,而且程序是有两个验证过程的。
流程如下:
1.检查C盘文档下是否有合格证书。是->成功 否->转到2.
2.检查程序文件夹下是否有合格证书。是->成功 否->转到3.
3.显示演示版弹窗并启动限制版程序。


Pasted image 20250313205157.png (121.54 KB, 下载次数: 0)
下载附件
2025-3-22 18:47 上传

细心的你一定也发现了判断是根据bHasLicense这个变量判断是否是正版软件的。
看一下这个变量的交叉引用。果然!只有一个写入,其他全是读取,还是判断它是不是非零。而且唯一的写入也是在验证函数里。那么思路一下就明朗了起来。只需要修改无论如何都会写入true到这个变量就可以了。


Pasted image 20250313211228.png (91.19 KB, 下载次数: 0)
下载附件
2025-3-22 18:48 上传

这里我在函数进入后直接对bHasLicense写入1。然后无条件跳转到下面的return 0语句。然后将改动直接写入到原文件。
修改后代码如下:


Pasted image 20250313224231.png (68.19 KB, 下载次数: 0)
下载附件
2025-3-22 18:50 上传

演示版程序不能保存,如下。


Pasted image 20250313213209.png (158.2 KB, 下载次数: 0)
下载附件
2025-3-22 18:50 上传

修改后程序如下,可以正常保存。


Pasted image 20250313223956.png (142.64 KB, 下载次数: 0)
下载附件
2025-3-22 18:50 上传

我本以为这样就可以开开心心地爆破,但是在程序保存和读取文件时报错了,起初我还以为是SAI2的bug,还去浏览器上查了半天沈。兜兜转转调试了很多次后发现并不是bug引起的错误,而是程序还有暗桩。
报错如下:


Pasted image 20250322105700.png (119.26 KB, 下载次数: 0)
下载附件
2025-3-22 18:51 上传

经过我对CreateFileW()API下条件断点调试得出程序只读取了一次证书文件
过程如下
给CreateFileW()API设置条件断点,条件为msg("Filename: %s\n", get_strlit_contents(RCX, 180, "UTF-16LE")), 0
这样IDA会在调试的时候自动输出CreateFileW()第一个参数,也就是读取的文件目录到控制台,180是字符串长度。我们知道字符串长度肯定会不一样,但是IDA这个函数对UTF-16编码的识别不是很智能只能先设置个180显示。后面的0是布尔参数,1的时候断点会中断,0则不会,两种情况都会执行msg()函数。


屏幕截图 2025-03-22 185303.png (240.81 KB, 下载次数: 0)
下载附件
2025-3-22 18:53 上传

我们就可以很清楚的看到程序只读取了一次证书文件。
既然证书只被读取了一次,那么问题出在哪里呢?目前有以下几种可能的解释:
  • 程序内部有多次验证,比如证书的内容或根据证书计算出的注册码被保存到了内存中,每次保存或者读取的时候都会调用验证函数验证
  • 证书被保存在了设置文件中,每次运行的时候在这里读取。

    至于修改了验证函数还是会验证失败:
  • 好几个不同实现方式的验证函数
  • 验证关键函数在我之前改的函数的子调用中,其他函数仍可调用该未修改过的函数进行验证。

    对于证书被保存在其他地方的情况我试过删除证书源文件,程序提示证书文件未安装。说明证书可能安装在其他文件里的,但是这不能下定论。接下来我把证书内容==设置为从a到z的小写字母==,运行程序然后去设置文件里找,发现并==没有相同内容==,所以这种可能性排除。
    那么就只剩第一种情况了。
    对于第二个问题,我们只能通过调试和分析寻找答案。
    这里我选择跟进验证函数,分析如下。
    紫色箭头为正常流程,如果不存在证书文件则会在第一个跳转处直接走红色路径。
    所以接下来的调试最好准备一个.slc文件放到程序根目录下,文件内容随便。
    汇编流程分析:


    af5bt-vz9jf.png (2.85 MB, 下载次数: 0)
    下载附件
    2025-3-22 18:55 上传

    反编译:
    __int64 __fastcall VerifyLicenseFunc(Sai_Storage *tlsStorageStruct, const wchar_t *lpFilePath)
    {
      unsigned int v4; // ebx
      unsigned int aSlcSuffixPath; // eax
      unsigned int i; // eax
      unsigned int result; // eax
      void *bTemp; // rax
      __int64 a1[2]; // [rsp+30h] [rbp-D0h] BYREF
      wchar_t lpFullPath[264]; // [rsp+40h] [rbp-C0h] BYREF
      _BYTE FindFileData[44]; // [rsp+250h] [rbp+150h] BYREF
      wchar_t FullPath[274]; // [rsp+27Ch] [rbp+17Ch] BYREF
      wchar_t String1[256]; // [rsp+4A0h] [rbp+3A0h] BYREF
      wchar_t Filename[264]; // [rsp+6A0h] [rbp+5A0h] BYREF
      unsigned int contentSize; // [rsp+8D0h] [rbp+7D0h] BYREF
      void *content; // [rsp+8D8h] [rbp+7D8h] BYREF
      v4 = -1;
      a1[0] = 0LL;
      aSlcSuffixPath = sub_1401C9250(
                         (__int64)a1,
                         ::aSlcSuffixPath,          // "%s\\*.slc"
                         lpFilePath);               // aSlcSuffixPath内容为"%s\*.slc"可能为证书文件名
      if ( aSlcSuffixPath )
      {
        if ( aSlcSuffixPath == 2 )
          return 0LL;
        sub_1401A5DC0(aSlcSuffixPath);
      }
      else
      {
        for ( i = sub_1401C8330(a1[0], FindFileData); !i; i = sub_1401C8330(a1[0], FindFileData) )
        {
          if ( (FindFileData[0] & 0x10) == 0 )
          {
            wsplitpath(FullPath, 0LL, 0LL, Filename, String1);
            if ( !wcsicmp(String1, L".slc") )//确认后缀
            {
              wmakepath(lpFullPath, 0LL, lpFilePath, Filename, String1);
              result = readFIleToContent(&content, &contentSize, lpFullPath);// 读取成功返回0
              if ( result )
              {
                sub_14000AF10(tlsStorageStruct, 0x2003Du, 0x20504u, lpFullPath, result);
              }
              else
              {
                if ( sub_14000B010(tlsStorageStruct, content, lpFullPath) == 1 )//唯一可能处理content的函数
                {
                //函数返回真的时候程序直接将content赋值给bHasLicense,说明该函数时关键验证函数
                  bTemp = content;
                  content = 0LL;
                  bHasLicense = bTemp;
    LABEL_18:
                  v4 = 0;
                  goto LABEL_15;
                }
                free(content);
              }
            }
          }
        }
        if ( i == 18 )
          goto LABEL_18;
        sub_1401A5DC0(i);
    LABEL_15:
        sub_1401C82C0((HANDLE **)a1);
      }
      return v4;
    }
    这里我选择去查看content变量内容的来源
    根据上面判断readFIleToContent()是可能的读取文件的函数,而下面的sub_14000B010()可能是用来验证或者对content进行证书计算来取得一个注册码
    那么进入readFIleToContent()先看看有哪些关键函数调用,我们首先看到了malloc()然后是标准的对内存申请结果的判断。


    Pasted image 20250322115528.png (33.36 KB, 下载次数: 0)
    下载附件
    2025-3-22 18:56 上传

    我们转到反汇编,一眼就能看出内存申请后的地址,内存申请的大小和一个奇怪的变量(buffer)传入了SomeReadFileUpper()函数。那么他就是可能的文件读取函数。
    继续跟进


    Pasted image 20250322115900.png (86.83 KB, 下载次数: 0)
    下载附件
    2025-3-22 18:56 上传

    SomeReadFileUpper()函数分析:
    发现该函数内部有两个调用。


    Pasted image 20250322120940.png (67.24 KB, 下载次数: 0)
    下载附件
    2025-3-22 18:57 上传

    我们先看第一个调用,其内部非常复杂,但是三次调用的上图的第二个函数(ReadFileByHandle())。
    如下如图粉色标记


    Pasted image 20250322121245.png (57.02 KB, 下载次数: 0)
    下载附件
    2025-3-22 18:57 上传

    既然如此,我们先分析第二个函数调用,函数内部调用非常简单,直接上反编译。
    函数内部直接调用ReadFile()API对文件进行读取,而文件句柄来自buffer,又根据下面对buffer地址的赋值。这里合理推测buffer指向的是一个存着文件信息的结构体,这一层函数调用根据这个结构体传递消息。
    第二个函数的调用我也顺带分析了,里面是一个GetLastError()的错误处理函数。
    那么这个函数的功能就明了了,==读取文件内容至dest,将读取字节数存到第四个参数中。==


    Pasted image 20250322121510.png (56.42 KB, 下载次数: 0)
    下载附件
    2025-3-22 18:57 上传

    那么再来看前面sub_1401C6A00()的函数调用,函数里面有两个循环,根据上层(buffer结构中的变量)消息的不同进行两种不同的读取方式,但是最终字符串保存的位置是一样的,都是存储在dest中。那么可以确定SomeReadFileUpper()函数就是根据第一个参数地址+5处存储的文件句柄进行读取操作。
    那么接下来就是看看newBuffer从哪来的
    进入readFileAndPutHandleInBuffer()函数,根据上面对buffer的判断这里的分析过程非常简单,我直接放结果了。


    屏幕截图 2025-03-22 123202.png (122.13 KB, 下载次数: 0)
    下载附件
    2025-3-22 18:58 上传

    经过调试知道buffer+16处的值在这里始终为0,以此为根据我们可以很简单地判断出函数正确执行时的返回值是0。


    Pasted image 20250322124435.png (20.73 KB, 下载次数: 0)
    下载附件
    2025-3-22 18:58 上传

    下面的函数是检查文件句柄是否关闭成功的,与验证关系不大,略过。
    然后再去查看sub_14000B010()第一个参数的来源
    先回到上层查看LicenseCheck()是如何调用VerifyLicenseFunc()的。这里IDA的识别是非常乱的,这是我调整后的结果。反汇编如下


    Pasted image 20250322125138.png (104.71 KB, 下载次数: 0)
    下载附件
    2025-3-22 18:59 上传

    根据后面代码的判断,传入sub_14000B010()的第一个参数,也就是传入VerifyLicenseFunc()函数的第一个参数是一个结构体指针,并在InitSaiStorageStruct()函数处初始化。我直接在IDA中添加了该结构体的声明,其内部结构如下。


    Pasted image 20250322125507.png (38.79 KB, 下载次数: 0)
    下载附件
    2025-3-22 18:59 上传

    StoragePtr存储的是一个指向存储空间的指针,Value01通常用来存储存第一个变量指向的储空间的字节大小。
    InitSaiTlsStruct()函数内容如下


    Pasted image 20250322174007.png (31.01 KB, 下载次数: 0)
    下载附件
    2025-3-22 18:59 上传

    根据LicenseCheck()反编译的前两句知道函数将threadStoragePtr中的指针指向线程存储空间中的一个指针。
    再回来看threadStoragePtr具体是干什么的。
    在上面两次 VerifyLicenseFunc()的验证都失败的时候调用了OnAlertWindowFunc()函数(==我这里将函数重命名了,之前是OnFailedFunc()==)根据上文可知这是弹窗函数,这时候threadStoragePtr存储着上面验证函数失败时的提示字符串,并调用弹窗显示。
    那么接下来一切都明朗了起来,sub_14000B010()函数就是验证的关键,当它返回1时我们认定程序为正版。那么我们看看有哪些程序调用了这个函数。


    Pasted image 20250322142452.png (25.1 KB, 下载次数: 0)
    下载附件
    2025-3-22 19:00 上传

    很遗憾,只有一处调用,只有可能是这个函数调用了关键验证函数。
    我们进入函数寻找返回值为1的情况。


    Pasted image 20250322143014.png (77.78 KB, 下载次数: 0)
    下载附件
    2025-3-22 19:00 上传

    图中黄色圆圈处为程序返回1是的分支。根据流程图我们一眼就能看出只有经过四次正确跳转后才能返回1,其他分支都是不行的。
    然后我们再看看程序其他分支都干了什么事。


    Pasted image 20250322180056.png (123.91 KB, 下载次数: 0)
    下载附件
    2025-3-22 19:01 上传

    那么反编译后结果就一目了然了。
    这边我也一并把其他分支看了,其他分支都是通过getcodeAddress()来动态获取字符串和sub_1401BEFE0()来将字符串复制到threadStoragePtr中指针指向的空间,再回到这里使用弹窗显示字符串。


    Pasted image 20250322143607.png (60.32 KB, 下载次数: 0)
    下载附件
    2025-3-22 19:03 上传

    这里的反汇编是我调整过的。IDA并没有把calculatedValue识别成数组。我通过调试大概知道了变量结构。声明如下:


    Pasted image 20250322144221.png (49.48 KB, 下载次数: 0)
    下载附件
    2025-3-22 19:03 上传

    VerifyFunc()调用后面紧跟着一个大!判!断!和一个小判断,对应于流程中的四个判断。那么这四个条件就必须满足。很显然判断用的这四个值就是上面这个函数算出来的。
    看看这个函数交叉引用


    Pasted image 20250322144413.png (35.44 KB, 下载次数: 0)
    下载附件
    2025-3-22 19:03 上传

    很可疑,下断点调试。
    让程序在这里停下
    然后通过IDA修改先后绕过四个判断后继续运行程序。
    回到bHasLicense赋值的地方,惊讶的发现赋值给它的是一个指针,内容是我证书的内容
    那么先前对于它是布尔变量的判断就是错的。直接把它重命名成lpSAILicense。
    继续运行程序,并没有出现演示版提示弹窗。
    我们在菜单选择打开文件然后确定,然后程序和我预料的一样在VerifyFunc()处断下来了。


    Pasted image 20250322145303.png (370.96 KB, 下载次数: 0)
    下载附件
    2025-3-22 19:04 上传

    根据栈顶知道调用来自0x14010E143的上一条语句


    Pasted image 20250322145418.png (50.66 KB, 下载次数: 0)
    下载附件
    2025-3-22 19:04 上传

    很明显,第二个问题的正解是上面提到的第二个情况。
    该处调用VerifiyFunc()的参数如下


    Pasted image 20250322145536.png (30.63 KB, 下载次数: 0)
    下载附件
    2025-3-22 19:05 上传

    好了,接下来就是用哪种方法爆破了,
    第一个是把每一个调用VerifiyFunc()语句后的判断修改成无条件跳转;
    第二个就是直接改VerifiyFunc()函数。
    这里我选择第二个方法爆破。
    通过观察上面四个调用发现所有验证判断的条件都是一样的,与下图一致
    即MustBeOne的三个变量必须为1,HardwareCodeFromLicense必须与全局变量HardwareCodeFromLicense一致。
    C++源码:
    void __fastcall VerifiyFunc(unsigned __int16 **a1, const void *content, Sai_License_Info *licenseInfo)
    {
            licenseInfo->MustBeOne01 = 1;
            licenseInfo->MustBeOne02 = 1;
            licenseInfo->MustBeOne03 = 1;
            licenseInfo->HardwareCodeFromLicense = HardwareCodeFromLicense;
    }
    修改后的汇编码
    mov [rsp+0x8], rbx
    mov [rsp+0x10], rsi
    push rdi
    sub rsp,0x860
    mov dword ptr [r8], 0x1
    mov dword ptr [r8+0x14], 0x1
    mov dword ptr [r8+0x1c], 0x1
    mov eax, dword ptr cs:[HardwareCodeFromLicense]
    mov dword ptr [r8+0x4], eax
    lea r11, [rsp+0x868+0x8]
    mov rbx, [r11+0x10]
    mov rsi, [r11+0x18]
    mov rsp, r11
    pop rdi
    retn
    修改前:


    Pasted image 20250322173543.png (73.21 KB, 下载次数: 0)
    下载附件
    2025-3-22 19:05 上传



    Pasted image 20250322173616.png (56.97 KB, 下载次数: 0)
    下载附件
    2025-3-22 19:06 上传

    修改后程序如下:


    Pasted image 20250322162946.png (73.08 KB, 下载次数: 0)
    下载附件
    2025-3-22 19:06 上传



    Pasted image 20250322160001.png (89.74 KB, 下载次数: 0)
    下载附件
    2025-3-22 19:06 上传

    爆破成功,可以随便打开、保存文件。

    函数, 下载次数

  • ApollosLegends
    OP
      

    可能是我设置的问题,高亮没显示,而且还有几张图被吞了。下面是我markdown直接转的PDF文件:
    [color=]https://wwni.lanzouq.com/iX7Ym2rhp8lg
    lies2014   

    谢谢经验分享!
    Laobai1993   

    学习一下
    fre   

    感谢分享
    K23   

    大佬厉害,之前我试着破sai,没成功
    IcePlume   

    好详细啊,学习到了
    Vasasy   

    很有用,多谢楼主分享,不过很多单纯板绘的可能操作不来吧
    q7756654   

    很有用,多谢楼主分享,学习学习了
    VictorQuqi   

    我们sai2也是好上了, 都能被阿波罗传奇逆向了
    您需要登录后才可以回帖 登录 | 立即注册

    返回顶部