Previously
看到了22级陈师傅的文章,只能说,非常好文章,使我的脑子旋转。
刚好在最近的DubheCTF的wp上看到同款黑蚊子多(什,随开始新一期青年大学习——
前置技能点——什么是WoW64系统
这玩意的原名非常简单粗暴,Windows 32-bit on Windows 64-bit。顾名思义,这玩意是64位windows系统内嵌的子系统,目的就是为了让你能够在64位上跑32位程序。
它的大概机理就是通过新建一个64位进程,并加载一堆运行32位程序必要的库,以及一些64位的动态链接库,其中主要包括:
· Wow64.dll,通往Windows NT内核的核心接口,它转换32位与64位调用,包括指针和调用栈操作。
· Wow64win.dll,为32位应用程序提供适当的入口点。
· Wow64cpu.dll,负责解决进程从32位切换到64位模式。
· Ntdll.dll,重要的Windows NT内核级文件。描述了windows本地NTAPI的接口。当Windows启动时,ntdll.dll就驻留在内存中特定的写保护区域,使别的程序无法占用这个内存区域。
通过加载一个类似于虚拟机一样的独立空间,并为32位进程提供与CPU的接口,WoW64得以实现32位进程在64位硬件上的兼容,以及与64位系统的隔离。详细内容可以参见博客园相关文章和Windows官方文档,本文不再赘述。
前置技能点——PEB及其在32位系统中的表现
Windows是一个强大的操作系统。为了方便开发者,微软将进程中的每个线程都设置了一个独立的结构数据。这个结构体内存储着当前线程大量的信息。这个结构被称为TEB(线程环境块)。通过TEB结构内的成员属性向下扩展,可以得到很多线程信息。这其中还包含大量的未公开数据。
TEB结构的其中一个成员为PEB(进程环境块),顾名思义,这个结构中存储着整个进程的信息。通过对PEB中成员的向下扩展可以找到一个存储着该进程所有模块数据的链表。
也就是说,如果我们想要在调试中遍历整个进程所调用的模块,我们就得找到相对应的结构指针。由于PEB是TEB的子集,所以PEB的指针存在于TEB中,而TEB的指针又存储在段寄存器FS中。
一种简单的辨认方法是依靠前人的经验,fs:[0x18]存储着TEB结构指针,fs:[0x30]存储着PEB结构指针。另一种办法就是,x32dbg,启动(
而整个PEB的原理实际上就是一个巨大的SEH链(程序逆向就是一个巨大的SEH?),所以大致结构跟链表类似。PEB偏移为C处存储着LDR指针,它指向一个_PEB_LDR_DATA
结构,整个表的排序方式存储在其中的,以_LIST_ENTRY
结构体定义的ModuleList
系变量中(例如InLoadOrderModuleList
),而顾名思义,它们每个变量同时又都是表头。定义表头的_LIST_ENTRY
结构体存在F(ront)link
和B(ehind)link
,分别指向链上的上一个和下一个节点,所以其实也不算单纯的SEH链,而是双向链表。
这个链表上的每一个元素实际上都存储在_LDR_DATA_TABLE_ENTRY
结构体中,而为了维持链表_LDR_DATA_TABLE_ENTRY
中又以_LIST_ENTRY
定义了所有排序方式的ModuleLink
,特别地,第一个_LDR_DATA_TABLE_ENTRY
结构中_LIST_ENTRY
的Blink
指向 PEB_LDR_DATA
中对应成员的Blink
,最后一个_LDR_DATA_TABLE_ENTRY结构中的Flink
指向 PEB_LDR_DATA 中对应的成员的Flink
。所以这玩意不但是个双向链表,还是个双向连通环。
我们需要检索对应模块的时候,可以根据不同的排序方式选择不同表头,然后从这条序列中选出对应的元素。
那什么是天堂之门
Reebuf上的相关文章中明确提到过流程——
64位Windows判别进程的位依靠段寄存器CS,当CS=0x23时是32位,而CS=0x33时是64位。而“天堂之门”就是利用模式转换,在32位进程中返回64位模式,使得某处32位无法识别的64位代码,得以在64位的架构下解释并执行。
这里还用到了一个原理,32位的ret只会弹出栈顶的指令寄存器IP,以保证下一条要读取的指令,但是64位的retf还会同时弹出栈顶的CS寄存器。这俩寄存器相关的东西在博客园相关文章里有阐述,本文不再赘述。
一种典型的特征代码如下,这里的call不但是调用,同时也是压栈:
// convert x86 to x64
6A 33 push 0x33 ; cs寄存器的新值
E8 00 00 00 00 call $+5 ;push 下一条指令的地址入栈,并继续执行下一条指令
83 04 24 05 add dword [esp], 5 ;栈顶的返回地址加5,指向retf的下一条指令
CB retf ; 通过retf,程序返回到下一条指令继续执行,但cs 寄存器已经被修改为0x33, 执行的代码是64位
// convert x64 to x86
E8 00 00 00 00 call $+5
C7 44 24 04 23 00 00 00 mov dword [rsp + 4], 0x23
83 04 24 0D add dword [rsp], 0xD
CB retf
——摘自y0hv2y的文章
虽然但是,部分天堂之门也会用到或写入上述PEB理论里涉及的某些64位库,而非直接硬写64位代码,而这都是后话了。
实操方法
1、重建一个64位可执行文件
这种办法适用于你对整个程序的结构有一定理解,且天堂之门埋的一整个64位代码块比较完整,能够直接扒下来,而不是分散在数个地址。关于如何建构程序可以自搜教程,这里放一个CSDN上的文章以供参考。
顺带,如果你真的想硬修也不是不行,就是记得把调用链看清楚了,然后再把jmp和call啥的给改到位了,并且在手搓的主函数里添加适当的调用。
2、相对逃课的打法:Windbg
如果你的可执行文件后缀是exe,恭喜你,Windbg可以直接连接Windows内核进行调试,你只需要打开你的Windbg:
接入你的内核,再打开你的程序attach上:
恭喜你,天堂之门对你仿佛没有作用一样,你可以免除IDA的虚拟内核切换不了位数的困扰,安心逆向了!
3、大佬向逃课打法:Unicorn
这方面我也还在学,要么你可以结合以下要讲的题目食用TLSN的文章或Taardis的文章,要么就直接搜Unicorn使用教程,总而言之,这个贵物可以直接hook指令或地址,直达目的,达到反混淆的效果。
4、其实IDA也可以,但是……
如果你只会使用IDA,或者对IDA有某些特别的感情(比如我),那么这种办法可能就比较适合你了。
这个办法其实还是从程序里扒代码,但是我们可以使用一些别的奇技淫巧来获得函数的位置。一种还是需要结合Windbg内核态调试的trace来寻找x64代码块的起终点,但评价是都这样了为什么不直接用Windbg调呢?
另一种就相对比较复杂,参考Sachiel的文章。直接用64位模式启动,然后在动态调试的时候,先在天堂之门入口点下断,然后在Segment段找到你的进程,或者正在运行中的debugger——
然后,Edit Segment,把所有相关的东西,包括数据段和代码段全部切换为64位模式——
确定完地址之后,把入口点代码段重新Analyze selected area一遍,你在理论上是可以看到一个正常的远跳和CS寄存器变更的。但是这种办法本人在实际上是没试通的,各位可以尝试一波。
用IDA脚本动态扒内存下来,然后放到64位模式里直接看,没有必要管破碎的代码块的链接是怎么影响程序的正常运行的。关于如何扒代码可以自搜,这里的链接仅供参考。一种可行的代码模板如下(注意下列代码必须在动调环境下运行,以正确dump内存):
-----IDAC-----
static main()
{
auto i ,fp;
fp = fopen("[文件地址]","wb");
auto start = [代码起始地址];
auto size = [代码块大小];
for(i=start;i<start+size;i++)
fputc(Byte(i),fp);
fp.close();
}
-----IDApython-----
import idaapi
start_address = [代码起始地址]
data_length = [代码块大小]
data = idaapi.dbg_read_memory(start_address , data_length)
fp = open('[文件地址]', 'wb')
fp.write(data)
fp.close()
具体案例
羊城杯2021 OddCode
回显过于简单,拿不到有用信息,我们直接看程序,Flag格式类似SangFor{……}
,长度0x29,如图能看到类似天堂之门的调用结束段,后面跟着的函数返回值为1为成功:
call只是个伪装成调用的jmp,跳到出口的位置,则前面的jmp必定是某处天堂之门的入口。关于jmp对IP和CS寄存器的修改,可以查阅这篇文章。总之,我们发现jmp far ptr dword_405640
指令实际更改的IP偏移应该是0x5310,而将CS改为了0x33,天堂之门入口没跑了。而0x405310就是call
的位置,你在windbg之类能够接入内核的软件里头能看得更清楚:
看结构,输入存储在偏移为0x701D的位置:
合理怀疑另一个push的偏移0x705C是密文或者key之类,但是经windbg动调结果,除了第一遍还没分析出代码过程之后,这玩意总是在还没调进去的时候就突然进ntdll然后terminate了。IDA动调的时候更加干脆,直接给你卡在32位虚拟CPU动弹不得,模式都没得改。
算了,一不做二不休,构造一个41位用0填充的输入,从偏移0x1010处开始trace,暴力调试后找到的和原文所在地址+flag头偏移=0x7025
相关,并且有用的代码如下——
---about rsi---
---b6326d---
cmp byte ptr [rsi],30h ds:00000000`00b67025=30
jb OddCode+0x3221 (00000000`00b63221) [br=0]
call OddCode+0x3210 (00000000`00b63210)
---if number b63214---
cmp byte ptr [rsi],30h ds:00000000`00b67025=30
jb OddCode+0x3221 (00000000`00b63221) [br=0]
add rsp,8
cmp byte ptr [rsi],39h ds:00000000`00b67025=30
ja OddCode+0x3221 (00000000`00b63221) [br=0]
mov al,byte ptr [rsi] ds:00000000`00b67025=30
sub rax,30h
---if A to Z 23327d---
cmp byte ptr [rsi],41h ds:00000000`00237025=41
jb OddCode+0x361d (00000000`0023361d) [br=0]
cmp byte ptr [rsi],46h ds:00000000`00237025=41
ja OddCode+0x361d (00000000`0023361d) [br=0]
……
sub rax,41h
---about rsi+1---
cmp byte ptr [rsi+1],30h ds:00000000`00b67026=30
jb OddCode+0x2fc5 (00000000`00b62fc5) [br=0]
cmp byte ptr [rsi+1],39h ds:00000000`00b67026=30
ja OddCode+0x2fc5 (00000000`00b62fc5) [br=0]
mov bl,byte ptr [rsi+1] ds:00000000`00b67026=30
---summary for al and bl---
shl al,4
or bl,al
似乎到这里的逻辑是先检查字符是否在数字或’A’到’F’的区间,对每一个字符进行减去’0’或’A’的运算后,再对每两位做(a[i]<<4)|a[i+1]
的运算。接着看密文/key的位置0x705C相关的代码:
---233a3a---
lea rcx,[rdx+rcx]
mov al,byte ptr [rcx] ds:00000000`0023705c=90
xor rcx,rcx
我们发现在这之后,少数提到读取了另一个参数的al寄存器的非模块代码段就只有这一段:
---2338e5---
xor bl,cl
cmp al,bl
看来另一个参数是密文没错了。应当是把密文与加密后的原文进行比对。参与其中的bl寄存器应该是存储原文的,和前面对上了。除此之外,前面还有个指令:
---234a30---
ror bl,cl
经过动调,cl并没有明确的将其它变量的值赋予其的指令,不出意外加密算法是没key了。动态调试,第一次在234a30
的cl是3,第二次在2338e5
的cl是0x64。这就意味着原文在经历一定变换后是先循环右移三位再异或0x64的。虽然但是,再结合密文的数据,其实已经可以开爆了……且慢。
在实际操作的时候,一直输出不了结果。后来动调一看寄存器才发现,整个计算过程实际上并不是单纯的暴力删减,而是将它们化为对应的十六进制数。这似乎也解释了为什么字母只取A到F而不是A到Z。
这样甚至只剩下最后的加密过程了。开搞!
#include <bits/stdc++.h>
using namespace std;
unsigned char enc[] = {0x90, 0xF0, 0x70, 0x7C, 0x52, 0x05, 0x91, 0x90, 0xAA, 0xDA, 0x8F, 0xFA, 0x7B, 0xBC, 0x79, 0x4D};
int main()
{
printf("Sangfor{");
for(int i=0;i<16;i++){
unsigned char tmp = enc[i]^0x64;
printf("%02X",((tmp&0xE0)>>5) + ((tmp&0x1F)<<3));
}
printf("}\n");
return 0;
}
女子,接下来就是交flag——
纳尼!!!
不过接下来的部分就是动态调试了,大不了Windbg再trace几遍,找到错误的位置直接改成小写,flag如下:
SangFor{A7A4A0C0B10Bafa776F55FF4F8C6E849}
月饼杯2 EasyTea
如图,天堂之门标志位,实际跳转地址偏移0x1258:
Windbg用ta命令,从偏移0x1258开始trace,代码块从0x1258的call跳到偏移0x27a50之后,一直跑到偏移0x27c5f(其实用IDA切代码位数尝试一遍了,但最后都只能得到错误的指令,不知道为什么)。直接用IDA脚本把东西扒下来:
import idaapi
import idaapi
start_address = 0x427a50
data_length = 0x20f
data = idaapi.dbg_read_memory(start_address , data_length)
fp = open('D:\比赛\月饼杯2\data', 'wb')
fp.write(data)
fp.close()
然后你就能看到一个非常标致的tea:
顺带,以下有个cmp部分,密文在偏移0x27A30处:
loc_40127F:
cmp dword ptr [ebp-30h], 8
jge loc_401391
mov eax, [ebp-30h]
mov ecx, [ebp-30h]
mov edx, [ebp+eax*4-20h]
cmp edx, dword_427A30[ecx*4]
jz loc_40138C
于是我们就可以直接写exp了:
#include <bits/stdc++.h>
using namespace std;
int key[8] = {0x66, 0x6C, 0x61, 0x67, 0x69, 0x73, 0x6D, 0x65};
int a1[8] = {0xB5ABA743, 0x4C5B3EE0, 0xB70AEB14, 0x6946BC13, 0x906089C4, 0x5B9F98F0, 0x0964B652, 0x78920976};
int main()
{
int sum = 32 * 0x88481145; // 会显示其它数字是ida的锅
for(int i=0;i<32;i++){
a1[7] -= (((a1[0] << 4) ^ (a1[0] >> 5)) + a1[0]) ^ (key[sum&7] + sum);
for(int k=6;k>=0;k--)
a1[k] -= (((a1[k+1] << 4) ^ (a1[k+1] >> 5)) + a1[k+1]) ^ (key[sum&7] + sum);
sum -= 0x88481145;
}
for(int i=0;i<=7;i++)
printf("%x\n",a1[i]);
return 0;
}
Tea_12345_12345_yes_flag_is_easy
西湖论剑2023 Dual Personality
死去的题目在攻击我.jpg
当初看Nep里的佬的复现看得云里雾里,现在发觉这玩意就是送,果然还是我太菜了
因为这里缺运行库所以看不到回显,但是代码还是能勉强看看的——
看你姨母,这玩意因为某些编译错误根本没法f5,不过看了一圈调用函数之后,sub_401120
倒是挺让人在意的,进去看一眼:
dword_407050 = VirtualAlloc(0, Size + 6, 0x3000u, 0x40u);
dword_407000 = (int)dword_407050;
memcpy(dword_407050, retaddr, Size);
v2 = (char *)dword_407050 + Size;
*v2 = 0xE9;
*(_DWORD *)(v2 + 1) = &retaddr[Size] - v2 - 5;
v2[5] = 0xCC;
*retaddr = 0xEA;
*(_DWORD *)(retaddr + 1) = a2;
*(_WORD *)(retaddr + 5) = 0x33;
return 0;
我涅码,这天堂之门写脸上了,而且忽略掉前面的int3中断(0xCC)看Return Address上写的东西,EA XX XX XX XX 33 00
,这不就是一个标准的jmp far开天门吗?但是它在return之前写入这条指令的地址呢?
注意看前面有着几次push:
然而sub_401620
压到函数栈的只有一个参数,f5完也是如此,基本可以确定这玩意就是套皮scanf:
那么接下来,push的size是7,a2是unk_4011D0地址上的东西,也就是0x48B4865,怎么想都不对。故动态调试,得到两次调用偏移分别为0x11D0和0x1290,查看调用链之后发现可能还有一次模式转换,调用了0x40700C上的地址0x401200(00 12 40 00 33 00)。
自此,我们得到了所有代码块的地址,顺序是0x11D0,0x1200和0x1290,并且输入是0x7060,中间对输入还有一次操作,应当就是加密的一大部分。并且它们刚好连在了一块。于是我们直接dump:
import idaapi
import idaapi
start_address = 0x4011d0
data_length = 0x115
data = idaapi.dbg_read_memory(start_address , data_length)
fp = open('D:\比赛\\2023西湖论剑\data', 'wb') #部分单斜杠容易被弄成转义符
fp.write(data)
fp.close()
下面的比较逻辑非常简单粗暴,我们直接提出来即可。我们先来看看64位部分,这是第一个函数——
__int64 sub_0()
{
MEMORY[0x40705C] = *(_BYTE *)(__readgsqword(0x60u) + 2);
if ( !MEMORY[0x40705C] )
MEMORY[0x407058] = 0x5DF966AE;
return MK_FP(MEMORY[0x407008], MEMORY[0x407000])();
}
存入0x407008的数是0x23,则return刚好将CS寄存器还原。看样子只是赋值了一个变量0x407058,下面还有个操作,对其减去一个值:
mov eax, dword_407058
sub eax, 21524111h
mov dword_407058, eax
mov dword ptr [ebp-0Ch], offset unk_407060
mov dword ptr [ebp-18h], 0
jmp short loc_401417
咱们接着往下看,其实中间还夹着一层循环:
完全就是把输入变量循环至第八位,加上0x407058的值,再把结果异或回0x407058,看这阵仗好像还是当long算的。看下一个函数:
__int64 __fastcall sub_30(__int64 a1, __int64 a2, __int64 a3, __int64 a4, __int64 a5)
{
void *retaddr[2]; // [rsp+8h] [rbp+8h]
if ( MEMORY[0x40705C] )
{
*(_QWORD *)retaddr[1] = __ROL8__(*(_QWORD *)retaddr[1], 32);
*((_QWORD *)retaddr[1] + 1) = __ROL8__(*((_QWORD *)retaddr[1] + 1), 32);
*((_QWORD *)retaddr[1] + 2) = __ROL8__(*((_QWORD *)retaddr[1] + 2), 32);
*((_QWORD *)retaddr[1] + 3) = __ROL8__(*((_QWORD *)retaddr[1] + 3), 32);
}
else
{
*(_QWORD *)retaddr[1] = __ROL8__(*(_QWORD *)retaddr[1], 12);
*((_QWORD *)retaddr[1] + 1) = __ROL8__(*((_QWORD *)retaddr[1] + 1), 34);
*((_QWORD *)retaddr[1] + 2) = __ROL8__(*((_QWORD *)retaddr[1] + 2), 56);
*((_QWORD *)retaddr[1] + 3) = __ROL8__(*((_QWORD *)retaddr[1] + 3), 14);
}
return MK_FP(retaddr[0], retaddr[0])(a1, a2, a3, a4, a5);
}
似乎还是同样的原理,但为什么都会有对0x40705C的特判?尽管怀疑这是不是作者的某种反调试变量,反正这个函数要做的事情就是对输入变量进行一个从string到long long的改,然后再一个循环左移,就是和上面一样忽略0x40705C!=0
的情况。最后一个函数非常好理解,就是位运算:
__int64 sub_C0()
{
LODWORD(MEMORY[0x407000]) = 0x4014C5;
MEMORY[0x407014] &= MEMORY[0x407018];
MEMORY[0x407018] |= MEMORY[0x40701C];
MEMORY[0x40701C] ^= MEMORY[0x407020];
MEMORY[0x407020] = ~MEMORY[0x407020];
return MEMORY[0x407050]();
}
0x407014上是9D 44 37 B5
,可能是某种key,我们权当这是对key的某种运算,但是这里的return并没有MK_FP切换回32位,所以我们需要额外进行一次dump,看看最后是什么结果:
还是一个循环,但是确认了输入为32位,div rcx
和mov dl, [rbx+rdx*4]
确认了这玩意是取key[i%4]
,然后用这个key对输入进行循环异或。自此,整个加密逻辑就很明了了(如下部分写法参考了parafish_0的文章:
#include <bits/stdc++.h>
using namespace std;
#define rol(x,i) ((x<<i)|(x>>(64-i)))
#define ror(x,i) ((x>>i)|(x<<(64-i)))
int key[4] = {0x9D, 0x44, 0x37, 0xB5};
char flag[32] = "*******************************";
int main()
{
int key2 = 0x5DF966AE - 0x21524111;
unsigned int *DwordFlag = (unsigned int *)flag;
for(int i=0;i<8;i++){
DwordFlag[i] += key2;
key2 ^= DwordFlag[i];
}
unsigned long *QwordFlag = (unsigned long *)flag;
*QwordFlag = rol(*QwordFlag, 12);
*(QwordFlag+1) = rol(*(QwordFlag+1), 34);
*(QwordFlag+2) = rol(*(QwordFlag+2), 56);
*(QwordFlag+3) = rol(*(QwordFlag+3), 14);
key[0] &= key[1];
key[1] |= key[2];
key[2] ^= key[3];
key[3] = ~key[3];
for(int i=0;i<0x20;i++)flag[i] ^= key[i%4];
printf("%s\n",flag);
return 0;
}
于是解密如何也很明了了:
#include <bits/stdc++.h>
using namespace std;
#define rol(x,i) ((x<<i)|(x>>(64-i)))
#define ror(x,i) ((x>>i)|(x<<(64-i)))
int key[4] = {0x9D, 0x44, 0x37, 0xB5};
char flag[32] = {
0xAA, 0x4F, 0x0F, 0xE2, 0xE4, 0x41, 0x99, 0x54, 0x2C, 0x2B,
0x84, 0x7E, 0xBC, 0x8F, 0x8B, 0x78, 0xD3, 0x73, 0x88, 0x5E,
0xAE, 0x47, 0x85, 0x70, 0x31, 0xB3, 0x09, 0xCE, 0x13, 0xF5,
0x0D, 0xCA
};
int main()
{
// 第三段加密
key[0] &= key[1];
key[1] |= key[2];
key[2] ^= key[3];
key[3] = ~key[3];
for(int i=0;i<0x20;i++)flag[i] ^= key[i%4];
// 第二段long long加密
unsigned long long *QwordFlag = (unsigned long long *)flag;
*QwordFlag = ror(*QwordFlag, 12);
*(QwordFlag+1) = ror(*(QwordFlag+1), 34);
*(QwordFlag+2) = ror(*(QwordFlag+2), 56);
*(QwordFlag+3) = ror(*(QwordFlag+3), 14);
// 第一段long加密
unsigned long *DwordFlag = (unsigned long *)flag;
int key2 = 0x5DF966AE - 0x21524111;
unsigned int Key2 = (unsigned int)key2;
for(int i=0;i<8;i++)Key2 ^= DwordFlag[i];
for(int i=7;i>=0;i--){
Key2 ^= DwordFlag[i];
DwordFlag[i] -= Key2;
}
flag[32] = '\0'; // 防止输出过长,直接在字符尾掐断
printf("DASCTF{%32s}\n",flag);
return 0;
}
DASCTF{6cc1e44811647d38a15017e389b3f704}