今天试试复盘这个决赛

题目

介绍

这里有一个在屏幕上画flag的小程序,可好像出了点问题,flag丢失了,需要把它找回来,并尝试截图留念。

找回flag样例:

要求

  • 自行寻找办法加载驱动文件,再执行题目exe文件。
  • 不得直接patch系统组件实现绘制(如:直接编写D3D代码绘制flag),只能对题目自身代码进行修改或调用。
  • 找回的flag需要和预期图案(包括颜色)一致,如果绘制结果存在偏差会扣除一定分数。
  • 修复后的flag截图操作必须在题目同一系统环境中进行(如:虚拟机运行题目则在虚拟机中截图,本机运行题目则在本机截图;不得拍照)。
  • 赛后需要提交找回flag的截图解题代码或文档截图代码或文档进行评分,方法越多得分越高。
  • 建议使用系统版本:Win10 1809、Win10 1903、Win10 1909、Win10 2004、Win10 20h1、Win10 20h2、Win10 21h1、Win10 21h2,在虚拟机中可能无法正常显示图形。
  • 提交结果打包为XXX_writeup_A.zip,XXX为名称,A为提交序号,从1开始。

分析

P.S.,在做复现的时候发现虚拟机无法正常绘制,且自己 Win11 的物理机运行会蓝屏,因此本次复现不含动态调试部分,一切只停留于静态分析和理论阶段,刷它指定的系统成本过高了接受不了。

驱动分析

IDA 打开,

sub_140001150 函数很像是注册的驱动卸载函数。

sub_140001188 函数应该就是获取了一下系统信息,没什么东西。

sub_140001414 函数往下跟到 sub_1400014A0 函数有大东西,不过这个函数不是直接调用的,像是注册了某种回调,三环程序应该是处罚这个回调的。

开头通过调用 sub_140001318 函数获得了 dwm.exeEPROCESS 结构。

对于接下来调用的函数

sub_140001000 比较像是获取指定进程的某个 DLL,具体也跟进来看看

对于这些API,网上找到了一些说法:

GetUserModuleBaseAddress(): 实现取进程中模块基址,该功能在《驱动开发:内核取应用层模块基地址》中详细介绍过原理,这段代码核心原理如下所示,此处最需要注意的是如果是32位进程则我们需要得到PPEB32 Peb32结构体,该结构体通常可以直接使用PsGetProcessWow64Process()这个内核函数获取到,而如果是64位进程则需要将寻找PEB的函数替换为PsGetProcessPeb()

这个地方也不难判断,就是获取 PEB 结构体,只不过多了一个 32 位和 64 位的判断,以 32 位的为例,中间有类似遍历链表的写法,如果找到了那么把某个结果保存到第二个参数指向的位置然后返回。

这里且当 sub_140001264(v24, "D3DCompile"); 函数是获取了某个函数的地址作为返回值出去的,随后是比较关键的点

调用了两次 ZwAllocateVirtualMemory 函数给进程申请内存,然后拷贝 shellcode 并进行了一定的异或混淆,最关键它把 D3DCompile 的地址和第二次申请内存的地址保存在第一次申请的内存后方,应该是方便 shellcode 找到虚拟代码,剩下的大概没有什么了,虽然没有运行成功大概也能猜测这个 shellcode 应该就是直接在屏幕绘制的代码了。

exe分析

三环程序比较大,先用火绒剑分析一下行为,主要是排除 exe 有跟内核做直接数据交互。

然而并没有,但是发现它也打开了 D3DCompiler_47.dll,于是从这里开始交叉引用,通过DLL路径交叉是一个比较好的思路,不管动态加载或者是运行时直接导入,都是可以大概分析到主逻辑的。

里面就进行了一个 NtQuerySystemInformation,外面是创建线程调用的这个函数,这里应该是触发回调的一个函数,为了验证也是准备去调试,但是它根本不触发这个回调,如图所见。

之前配置环境的时候一直以为是虚拟机没有 dwm.exe 这个进程,结果没想到是回调没有办法调用,于是我选择自己运行一个 dwm.exe 进程(我直接拿初赛的三环程序去改名然后运行,可以在第一个函数成功被获取),然后自己写一个驱动手动调用那个回调写shellcode。

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
#include"ntddk.h"  
#define kprintf(format, ...) DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, format, ##__VA_ARGS__)
typedef void (* func)();
PDRIVER_OBJECT g_Object = NULL;
typedef struct _LDR_DATA_TABLE_ENTRY {
LIST_ENTRY InLoadOrderLinks;
LIST_ENTRY InMemoryOrderLinks;
LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase;
PVOID EntryPoint;//驱动的进入点 DriverEntry
ULONG SizeOfImage;
UNICODE_STRING FullDllName;//驱动的满路径
UNICODE_STRING BaseDllName;//不带路径的驱动名字
ULONG Flags;
USHORT LoadCount;
USHORT TlsIndex;
union {
LIST_ENTRY HashLinks;
struct {
PVOID SectionPointer;
ULONG CheckSum;
};
};
union {
struct {
ULONG TimeDateStamp;
};
struct {
PVOID LoadedImports;
};
};
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;

VOID bianliqudongmokuai(PUNICODE_STRING name, UINT64* pBaseAddr,UINT64* pSize)
{
LDR_DATA_TABLE_ENTRY*TE, *Tmp;
TE = (LDR_DATA_TABLE_ENTRY*)g_Object->DriverSection;
PLIST_ENTRY LinkList;
;
int i = 0;
LinkList = TE->InLoadOrderLinks.Flink;
while (LinkList != &TE->InLoadOrderLinks)
{
Tmp = (LDR_DATA_TABLE_ENTRY*)LinkList;
if (RtlCompareUnicodeString(&Tmp->BaseDllName, name, FALSE))
{
}
else
{
kprintf(("Found Module!\n"));
*pBaseAddr = (UINT64)(Tmp->DllBase);
*pSize = (UINT64)(Tmp->SizeOfImage);

}
LinkList = LinkList->Flink;
i++;
}


}
VOID Unload(PDRIVER_OBJECT DriverObject)
{
kprintf(("BYE xia0ji233\n"));
}
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
kprintf(("Hello xia0ji233\n"));
g_Object = DriverObject;
DriverObject->DriverUnload = Unload;
UNICODE_STRING name;
UINT64 Base = 0;
UINT64 Size=0;
RtlInitUnicodeString(&name, L"2022GameSafeRace.sys");
//
bianliqudongmokuai(&name,&Base,&Size);
if (Base) {
kprintf(("Base:%p Size:%x\n"), Base, Size);
func funcptr = (func)(Base + 0x1490);
DbgBreakPoint();
funcptr();
}

return STATUS_SUCCESS;
}

运行结果如下,用初赛的exe改名为dwm成功被这个函数获取到EProcess。

随后使用静态分析去解一下 shellcode,用下面的IDC脚本即可

1
2
3
4
5
6
7
8
9
10
#include<idc.idc>
static main(){
auto start_ea = 0x000000140005A00;
auto end_ea = 0x000000140005A00+0x16E6;
auto len = end_ea - start_ea;
auto ea=0;
for (ea = start_ea; ea < end_ea; ea++) {
PatchByte(ea, Byte(ea)^0xC3);
}
}

解密后的 shellcode 可以被直接反编译

看起来跟初赛是差不多的,相同的配方,相同的味道。

再往下看

就连这个ACE都是一样的,这里大概是一个全新的虚拟机了。

然后本来是打算搜字节码去看看shellcode有没有写成功的,但是发现还是搜不到,突然想到好像这个回调最后会 free 这片内存,所以决定直接改 sys 去把原来的 free 给 jmp 掉(还是失败,想复现太难了 qwq)。

还是老老实实分析虚拟机代码吧,看到 unk_140004030,它被放到了 BaseAddress + 0x16E6 的位置上,这里的代码在我们看来是在 0x140005A00,而直接分析可得,代码实际在 &qword_140009600[136]=0x140009600+136*8=0x140009a40 的位置上。

然而这里没找到对应的数据,确实也不太会分析了,按理来说如果能直接调试运行到这的话是肯定可以定位shellcode找到位置dump出来的。

这里还原一下虚拟机的流程吧

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
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
unsigned int code[] ={0};

int main(){
int Stack[0x50];
unsigned __int64 RIP_S=0; // rsi
unsigned __int64 v10; // r8
unsigned int opcode; // ecx
unsigned __int64 v12; // rdx
__int64 v13; // rcx
__int32 v14; // r9d
__int64 v15; // r8
__int64 v16; // r9
__int32 v17; // edx
unsigned __int64 v18; // r10
unsigned __int64 v19; // rcx
__int32 v20; // r9d
__int32 v21; // r9d
__int32 v22; // eax
__int64 result; // rax
memset(Stack,0,sizeof(Stack));
Stack[8] = 50;
Stack[9] = 50;
do
{
v10 = RIP_S;
opcode = code[RIP_S + 272];
if ( (int)opcode > (int)0x9A8ECD52 )
{
switch ( opcode )
{
case 0xEE2362FC:
++RIP_S;
v21 = Stack[0];
v22 = Stack[0] * (Stack[1] + 1);
Stack[0] = code[RIP_S + 272] ^ 0x414345;
Stack[1] = (Stack[0] ^ (Stack[1] + v21)) % 256
+ (((Stack[0] ^ (v21 * Stack[1])) % 256 + (((Stack[0] ^ (Stack[1] + v22)) % 256) << 8)) << 8);
break;
case 0xEE69524A:
v19 = 0;
v20 = code[v10 + 273];
code[RIP_S + 272] = -1;
code[v10 + 273] = -1;
if ( RIP_S != 1 )
{
do
{
code[v19 + 272] ^= v20;
++v19;
}
while ( v19 < RIP_S - 1 );
}
++RIP_S;
break;
case 0xFF4578AE:
RIP_S += 2;
v16 = code[v10 + 273];
v17 = code[RIP_S + 272];
if ( v16 )
{
v18 = RIP_S;
do
{
code[++v18 + 272] ^= v17;
v17 = code[v18 + 271] + 305419896 * v17;
--v16;
}
while ( v16 );
}
code[v10 + 272] = -1;
code[v10 + 273] = -1;
code[RIP_S + 272] = -1;
break;
case 0x1132EADF:
RIP_S += 2;
Stack[code[RIP_S + 272]] = code[v10 + 273];
break;
default:
if ( opcode == 2018683631 && code[272] == -295083446 && code[273] == 1755241482 && code[274] == -1729111095 )
printf("call Paint(%d, %d, %d, %d, NAN, a3, a4, a5, a6, a7)",Stack[4], Stack[5], Stack[6], Stack[7]);
break;
}
}
else
{
switch ( opcode )
{
case 0x9A8ECD52:
Stack[0] -= Stack[1];
break;
case 0x88659264:
RIP_S += 2;
v12 = RIP_S;
v13 = code[v10 + 273];
v14 = code[RIP_S + 272];
code[v10 + 272] = -1;
code[v10 + 273] = -1;
v15 = v13;
code[RIP_S + 272] = -1;
if ( v13 )
{
do
{
code[++v12 + 272] ^= v14;
--v15;
}
while ( v15 );
}
break;
case 0x89657EAD:
Stack[0] += Stack[1];
break;
case 0x8E7CADF2:
RIP_S += 2;
Stack[code[RIP_S + 272]] = Stack[code[v10 + 273]];
break;
case 0x9645AAED:
if ( code[272] == 0xEE69624A && code[273] == 0x689EDC0A && code[274] == 0x98EFDBC9 )
printf("call Paint(%d, %d, %d, %d, NAN, a3, a4, a5, a6, a7)",Stack[4], Stack[5], Stack[6], Stack[7]);
break;
case 0x9645AEDC:
RIP_S = 0x671;
break;
}
}
result = 0x671;
++RIP_S;
}
while ( RIP_S < 0x671 );
}

对比起来这个虚拟机的流程也是更大更难去分析了,但是根据已有的资料看来,似乎出的问题与初赛一致,最好的办法就是做 hook 然后替换坐标。据说决赛是卷方法数,当然其他的方法也可以有,这里可以说一些理论可行的方案:

  • 自己生成正确的指令流,直接PATCH SYS 文件。
  • 等代码注入完成之后,搜索指令的特征码找到三环程序中代码的位置,替换(感觉和上面算一种)。
  • hook 绘制的代码,写入正确坐标。
  • 不用虚拟机,自己接管流程,然后自己计算正确的坐标和加密的参数调用绘制函数。
  • 不知道它代码坐标计算出错的原因,如果是逻辑错误可以直接修虚拟机,也能算一种。

脑子有限,只能想那么多了,希望有时间那个旧电脑退役了刷个系统再去实现这些操作把。