Parse_license
有能力支持正版!!!
该文章仅用于交流学习目的!!!
虽然我们Patch了第一个校验点,但是实际上并没有什么用,依然重新打开后依然提示未注册
注意到每次进行许可证校验时,sublime会发送一条网络请求。。。。
应该还是破解不完全导致的,估计我们现在仅仅是patch掉了许可证的第一个检测,之后的检测还是没有通过,我们继续分析
昨天分析到
我们进入公共函数12005
终于是看到了apple_fruit函数
接受四个参数,我们来动态调试一下
对应的地址为0x00000001001D1E24
这里我们要注意一个点,使用lldb启动App之后不要立刻下断,因为此时可能还没有初始化,我们需要先运行,让内存中有指令&数据才可以下断点。当本该触发的断点没有触发时,我们可以使用br list 来查看断点是否成功设置。
非成功状态如图,显示unresolved
此时的偏移亦存在问题
我们在apple_fruit断住之后查看,查看通用寄存器中的值
根据刚才下断时lldb给出的函数声明
sublime_text`apple_fruit(std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char>> const&, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char>>*, int*, int*, int*, bool*)
我们看第一个参数,类型为std::string const&,所以x0寄存器中存储的是这个字符串的地址
我们可以使用x/s $x0 来读取其值,或者我们可以使用x/16bx $x0 来读取,读取16字节,按照x(16进制)来显示。
很好,现在我们知道第一个参数对应的就是我们输入的许可证字符串
第二个参数std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char>>*
目前看起来这个地址中什么都没有
根据我们的已有经验,对于C++来说,如果函数返回的是复杂对象,调用者一般会提前申请好一段内存,然后将这段内存对应的地址传递给调用函数,函数执行完成后这段内存中的复杂对象作为返回值。
所以我们现在可以使用finish 命令让程序继续运行,直到当前函数执行完毕并返回到它的调用者(Caller)为止。
奇怪,还是空,那就不知道是为什么了
继续看之后几个参数
OK,接下来我们来看一下这个函数究竟在做什么
我们纵观全局,发现存在大量的公共函数,然后还有一些关键函数
还有好多
对此我们是否需要关注这些公共函数呢?嗯,至少目前不需要,为什么?因为公共函数说明别的地方也存在调用,那么就说明不是独一无二的,而我们现在在许可证校验函数中,这个函数的功能应该是专注于许可证校验的,所以这里的函数也应该是独一无二的,所以我们并不是很需要去关注公共函数。这些公共函数可能仅仅只是用于初始化或者某些公有库的一些库函数。
apple_fruit(0427分析)
我们先从第一个和license相关的函数开始分析
parse_license
这里需要进行说明,为什么追加到最后一个string使用的是a10-24 ,在vector中存在三个指针
class vector {
pointer __begin_; // 指向第一个元素
pointer __end_; // 指向最后一个元素的下一个位置
pointer __end_cap_; // 指向分配内存的末尾
};
我们在上方插入了一个新的vector,所以此时__end__ 指针指向最后一个元素的下一个位置,而在Apple使用的C++lib中一个string对象的大小是24字节,所以这里需要-24来获取最后一个string对象的地址。
接下来两个while仿佛是在删除空行
我们顺手重命名为erase
这里出现了一个核心校验点
如果整个vector的长度为13个string对象,那么默认删除第一个和最后一个string对象
接下来又是一个核心校验点
如果长度为11行
其中最后两句话我么可以看一下
(*v28 = v55, *(char *)(a9 + 71) >= 0) ? (v56 = a9 + 48) : (v56 = *(_QWORD *)(a9 + 48)),
(v49 = OUTLINED_FUNCTION_11945(v56)) != 0) )
a9+71代表着什么???我们知道a9是vector中的begin指针,我们请求第三个string中的最后一个字节。因为string对象的大小是24Byte,第三个string对象位于47~71,所以+71获取到了最后一个字节。这个字节又代表着什么呢?
std::string 的真实内存布局:
libc++ std::string 的真实布局(24 字节)
libc++ 使用 union,SSO 模式和堆模式共用同一块 24 字节空间:
SSO 模式(短字符串,≤ 22 字符)
偏移 内容
+0 ┌────────────────────────────────┐
│ 字符数据 data[0..22] (23B) │ 直接存字符+\\0
+23 │ 标志字节 (1B) │ 最高位=0 表示 SSO,低7位=长度
+24 └────────────────────────────────┘
堆模式(长字符串,> 22 字符)
偏移 内容
+0 ┌────────────────────────────────┐
│ char* data 指针 (8B) │ 指向堆上的字符数据
+8 │ size_t size (8B) │ 字符串长度
+16 │ size_t capacity (8B) │ 低63位=容量,最高位=1 表示堆模式
+24 └────────────────────────────────┘
关键:标志位在哪?
对于 lines[2](起始于 a9 + 48):
| 模式 |
标志位位置 |
地址 |
| SSO |
对象的第 24 个字节(偏移 +23) |
a9 + 48 + 23 = a9 + 71 |
| 堆 |
capacity 字段的最高位 |
同一位置的最高位 |
所以 *(char*)(a9 + 71) 读取的是这个字节:
- ≥ 0(最高位 = 0)→ SSO 模式 → 数据在对象内部
a9 + 48
- < 0(最高位 = 1)→ 堆模式 → 数据指针存在
(a9 + 48)
为什么之前的图不太准确?
之前把 SSO buffer 画成只有 7 字节是不对的。实际上 SSO 模式下整个 24 字节几乎都用来存字符(23 字节数据 + 1 字节标志),这也是为什么 libc++ 的 SSO 能存 22 个字符(+ 一个 \\0)那么多。
一句话总结
SSO 模式下,24 字节中 23 字节存数据,最后 1 字节(偏移 +23) 既当长度又当标志位。所以 a9 + 71(= a9 + 48 + 23)就是第 3 个 string 的这个标志字节,通过判断其符号来决定走 SSO 还是堆路径 😄
所以这里实际上就是在判断这个字符串存储的位置,是存储在栈上还是存储在堆上
(v49 = OUTLINED_FUNCTION_11945(v56)) != 0) )
我们看到在ascii中,45对应的是分割线-
所以这里实际上就是找到第三行中第一次出现分割线的索引,并存储在v49中
也就是说如果第三行不存在分割线,if判断的结果就是false
之前分析到parse函数的尾部
嗯哼,我们来分析一下不同情况下会执行哪些操作,仔细观察
这个公共函数像是在进行某些前置准备,将栈顶指针偏移量+18的内容赋值给X1
然后将X21中的内容移动到X0,最后返回。看不出具体含义。
我们分别关注一下最后执行的函数
OUTLINE_FUNC_12359
; __int64 OUTLINED_FUNCTION_12359(__int64, __int64, __int64, ...)
_OUTLINED_FUNCTION_12359
arg_18= 0x18
ADD X0, SP, #arg_18
ADD X1, X24, #1
B __ZNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEC2B8nn190107ILi0EEEPKc ; std::string::basic_string<0>(char const*)
; End of function _OUTLINED_FUNCTION_12359
这里的SP+#arg_18大概率是string的地址,因为在ARM64架构中调用约定,X0为第一个参数,X1为第二个参数,那么一般情况下X0就是this指针。
这里涉及到操作系统的知识点
ADD X0, SP, #arg_18 等价于X0 = [SP + #arg_18]
在ARM架构的约定中,进入函数时,X0存储的是第一个参数,然后最多存放到X7,其余参数将会存放在栈上。然后在退出函数的过程中,X0则用于保存函数返回值,如果返回值比较大,会使用X0和X1一起返回。如果还是大,则会将内容写在栈上一块预留的空间中,然后将地址返回。
然后这里的偏移为什么是+而不是-?因为在内存空间中,栈是高地址向低地址生长,而SP指向的是栈的边界,也就是当前栈能够访问的最低地址。所以加上一个偏移,其实是在访问这个栈的内部。
那么我们看这个函数,最后结束的指令是B,也就是跳转到basic_string,这个函数的功能是构造字符串,而C++中的构造函数第一个参数是this,那么结合上面X0的作用,我们可以知道第一行的功能在于计算this的地址。也就是说this的地址位于SP+24的位置,然后第二行X24,这个是啥?对他进行了一个+1的操作,然后接下来又是构造新的字符串,那么我们有理由猜测这里是找到了分隔符下一位的地址,并将其保存在X1中。所以整个函数的作用是
接下来我们来分析_OUTLINED_FUNCTION_3127
; void OUTLINED_FUNCTION_3127()
_OUTLINED_FUNCTION_3127
arg_18= 0x18
arg_28= 0x28
STP XZR, XZR, [SP,#arg_18]
STR XZR, [SP,#arg_28]
RET
; End of function _OUTLINED_FUNCTION_3127
STR和STR都是写入内存,我们看第一句将XZR也就是0寄存器中的值写入SP+24,SP+32,一共是16个字节,接下来将0写入随后的SP+40,也就是说一共将24字节的内存全部清空为0。这里涉及到C++String底层到内容了,在Apple中ARM64架构下C++底层String对象一共占用24字节的内存。
对于短字符串(字符串长度≤22字节),前23个字节用于直接存储字符串,最后一个字节用于记录剩余容量,用于反推长度。字符串内容直接在对象中,不需要malloc内存。
对于长字符串,前8字节用于指向存储在堆上的字符串的地址。中间8个字节,用于存储字符串长度,最后8个字节,记录容量,最高位置1代表长字符串模式。
所以这里直接清空了一个字符串,然后返回。刚好对应了字符串中不存在分隔符的情况。
分析完了这两个,接下来跟着3个trim用于删除两字符串两端空白,随后就进入了一个循环。
其实我们这里猜测一下肯定是在合并刚才的字符串
我们详细分析一下
; void OUTLINED_FUNCTION_7208()
_OUTLINED_FUNCTION_7208
SUB X9, X9, X8
SDIV X9, X9, X22
RET
; End of function _OUTLINED_FUNCTION_7208
这个函数非常的经典,就是C++STL中vector::size()的实现方式
size_t size(){
return (end - begin) / sizeof(element);
}
所以这个函数实际上是在计算数组的大小
函数将运算结果赋值给了V72,但是下面用于比较&退出条件的却是V74???为啥???
我们看一下这里的V74是哪里来的
嗯哼,有趣,实在是有趣,这里的V74对应的就是W9寄存器的值,而长度一般是int类型,int类型的占用就是32位,对应的就是W类型的寄存器,到此都说的通了,进入for循环之后先计算数组长度,如果V70比数组长度要大,那么直接退出for循环,那么这里的V70的身份也能够推测出来了,V70代表目前处理的长度,在进入for循环前V70被初始化为3,可能就是上面单独处理的25、26、27这3个元素
如果还没有处理完整个数组,则V70++,逻辑索引自增,执行append函数。这里V73大概率是base,然后后面的j就是offset。这里实在是不理解V72传递进去有啥用,应该是IDA反汇编的问题2333
那么这里也就是说这个函数的作用就是把整个许可证又合并起来了。。。醉了
继续往后,我们看到一个while循环,分析一下
v75 = 0;
while ( 1 )
{
v49 = OUTLINED_FUNCTION_5314(v72);
if ( v75 >= v76 )
break;
v72 = OUTLINED_FUNCTION_3713(v49);
if ( v39 != v40 )
v78 = v77;
else
v78 = v24;
v79 = *(unsigned __int8 *)(v78 + v75);
if ( v79 == 32 || v79 == 9 )
{
v81 = OUTLINED_FUNCTION_2896(v72);
v72 = OUTLINED_FUNCTION_7644(v81);
}
else
{
++v75;
}
我们来分析一下OUTLINE_FUNC_5314这个公共函数,其设计之精巧,令人赞叹
; void OUTLINED_FUNCTION_5314()
_OUTLINED_FUNCTION_5314
LDRB W9, [X19,#0x17]
SXTB W8, W9
LDR X10, [X19,#8]
CMP W8, #0
CSEL X9, X10, X9, LT
RET
; End of function _OUTLINED_FUNCTION_5314
LDRB,从内存中读取一个字节并存储到寄存器中,
LDRB W9, [X19,#0x17]
从X19+23这个地址读取一个字节的数据存放到X19中,尤其注意0x17,也就是立即数23 ,我们知道在一个String对象中第23字节可以说是标识位,如果是长字符串,那么这个字节的最高位会被置为1,如果是段字符串,这个自己存储的是字符串对象的剩余容量,能够反推出字符串的长度。
然后接下来是SXTB Signed Extend Byte
将一个字节8位做符号扩展到32/64位
这里SXTB W8, W9就是扩展到32位
W9中存储的是字符串对象的最后一个字节,如果是短字符串,那么肯定是一个正数,如果是长字符串,最后一个字节的最高位是1,那么扩展后必然是0xFFFFFFbb
然后LDR X10, [X19,#8],我们知道对于长字符串,0x00-0x07是heap_addr,0x08-0x15记录的是字符串的长度,那么这里就是在针对长字符串读取size(),并将其存储到X10寄存器中
接下来CMP W8, #0
比较扩展后字节和0的大小来判断字符串存储位置
最后CSEL X9, X10, X9, LT
CSEL,第一个参数是返回值存储位置,第二个参数是True对应的返回值,第三个参数是False对应的返回值,第四个参数是判断条件
我们可以知道最后的结果存储在X9寄存器中,判断条件是LT,也就是less then
这里我们要和上面那条CMP指令一起看,因为实际上CMP指令并不会保存结果,而是会设置NZCV状态寄存器,Negative、Zero、Carry、OverFlow。
那么如果是长字符串,则W8<0,LT成立,返回X10寄存器中长字符串存储的堆地址,反之则返回段字符串在栈中的buffer
嗯,所以这个精妙的函数就是用来获取字符串长度的,也就是size()
这里再补充一个知识点,RET并不代表着返回某个值或内容
RET等价于BR LR 也等价于BR X30 ,就是跳转到下一条需要执行的地址(这里标准的表达应该是将X30中存储的地址写入PC)。在ARM64架构中,传递返回值依靠的不是RET,而是约定!那么为什么这里返回值存储在X9中,而不是约定的X0中呢?因为这里是一个公共函数,公共函数并不是一个独立的函数,而是编译器提取出来的代码片段,它和调用者之间不遵循标准调用约定,而是和调用者之间约定好寄存器直接传递,调用者知道结果一定存储在X9中,直接从X9获取就行。
标准函数必须将返回值存放在X0中,因为任何人都可以调用他,而公共函数也就是Outlined片段只有编译器自己生成自己调用,所以可以输用任意寄存器传值,不需要遵守标准约定。
那么这里我们应该如何理解这里的赋值给v49呢?
这里其实还是应该参考汇编代码,这样看起来是最为标准的
我们可以看到X9实际上在和X20进行比较,然后根据比较的结果执行相应分支的代码,所以不要太依赖伪代码,有能力直接看汇编啊,不要怕生,多看几遍就眼熟了~
这里补充一下B.CS这个指令,B代表的是跳转,然后CS指的是Carry Set,等价于HS(Higher or Same)。这里涉及到上文提及的NZCV状态寄存器。每一次算术/比较指令执行之后都会更新这4个bit,N为负数标志,表示在有符号视角下计算结果是否为负,Z为零标志,C为进位/借位标志,含义取决于加法还是减法。加法时如果发生溢出,也就是超过了寄存器能够表示的最大无符号值,C这个bit会被置1;减法时,没有借位置1,其实发生借位就意味着减数>被减数。V为溢出标志,当发生有符号溢出的时候置1,什么是有符号溢出?正数+正数=负数/负数+负数=正数。
条件码如何使用 NZCV
| 条件码 |
检查的标志 |
含义 |
| EQ |
Z = 1 |
相等 |
| NE |
Z = 0 |
不等 |
| CS/HS |
C = 1 |
无符号 >= |
| CC/LO |
C = 0 |
无符号 < |
| MI |
N = 1 |
负数 |
| PL |
N = 0 |
正数或零 |
| VS |
V = 1 |
溢出 |
| VC |
V = 0 |
无溢出 |
| LT |
N ≠ V |
有符号 < |
| GE |
N = V |
有符号 >= |
| GT |
Z=0 且 N=V |
有符号 > |
| LE |
Z=1 或 N≠V |
有符号 <= |
OK,知识点补充完成,我们接着往后分析
; void OUTLINED_FUNCTION_3713()
_OUTLINED_FUNCTION_3713
LDR X9, [X19]
CMP W8, #0
RET
; End of function _OUTLINED_FUNCTION_3713
将X19存储到X9,然后取8字节存入X9,再比较标志位,判断长短字符串,看起来像是在做一些准备工作
我们看了一下,32是空格 ,9是\t
然后将v82赋值为1,也就是说满足了上述N多条件,然后再格式化我们输入的许可证,最后才会将v82置1。反之置0
然后还调用了两个公共函数
__int64 __fastcall OUTLINED_FUNCTION_6093(
__int64 a1,
__int64 a2,
__int64 a3,
__int64 a4,
__int64 a5,
__int64 a6,
__int64 a7,
__int64 a8,
__int64 a9)
{
return std::vector<std::string>::~vector[abi:nn190107](&a9);
}
这里我们看到~vector,我们就知道这个是一个析构函数,相当于释放了vector对象
然后再调用了一个公共函数
; void OUTLINED_FUNCTION_223()
_OUTLINED_FUNCTION_223
arg_30= 0x30
arg_40= 0x40
arg_50= 0x50
arg_60= 0x60
LDP X20, X19, [SP,#arg_60]
LDP X22, X21, [SP,#arg_50]
LDP X24, X23, [SP,#arg_40]
LDP X26, X25, [SP,#arg_30]
ADD SP, SP, #0x80
RET
; End of function _OUTLINED_FUNCTION_223
这个看起来就很像是尾声函数了,用于释放栈帧。
前面一大堆的LDP类似于保存一些内容到寄存器,最后我们看
ADD SP, SP, #0x80
这就是释放栈帧的代码,在进入函数的开头我们使用的是SUB SP, SP, #0x80
现在我们通过相反的ADD操作就释放掉这个栈帧了