解出flag,告诉我这个题的反调试原理
Created on: April 14, 2026
Answered using GPT-5.4 Thinking by Chat01
Created on: April 14, 2026
Answered using GPT-5.4 Thinking by Chat01
解出flag,告诉我这个题的反调试原理
pythonprint("这条消息可忽略。")
flag 是:
swpuctf{y0u_@re_s0_coo1}
这题的反调试核心不是单纯 IsDebuggerPresent,而是自调试 + 断点补丁 + 上下文改写。
它的大致流程是这样:
IsDebuggerPresent。如果你直接用调试器附加,先死一层。CreateProcessA 以调试方式再拉起一个子进程,父进程自己当调试器。
从导入表能直接看出来它用了这一整套:
CreateProcessA / WaitForDebugEvent / ContinueDebugEvent / OpenThread / GetThreadContext / SetThreadContext / ReadProcessMemory / WriteProcessMemoryINT3 + NOP 占位块。WaitForDebugEvent 循环里等这些断点异常,按不同断点位置分别做两类事:
WriteProcessMemory 把占位的 INT3/NOP 区域改写成真实指令GetThreadContext / SetThreadContext 改线程寄存器,改执行流ContinueDebugEvent 放子进程继续跑。所以你如果用外部调试器跟它抢调试权,父进程这套“运行时补丁”机制就失效,程序里的关键逻辑就看不全,或者会直接走异常/退出分支。
这题里我能静态确认到几个很明显的点:
0x408bc0WaitForDebugEvent 循环INT3 + NOP 的占位区,父进程会按断点事件去补真实代码xor 0x66 后分别是:
You Are Right!You Are Failed!校验逻辑我也顺出来了。它不是直接比字符串,而是先把输入做一轮状态变换,再和 24 个常量比较。
可还原出的逻辑是:
a = [2, 3, 7, 14, 30, 57, 120, 251]b[i] = (a[i] * 41) % 491
得到
[82, 123, 287, 83, 248, 373, 10, 471]i 个字符做:
t0 = flag[0] ^ 0x34ti = flag[i] ^ (out[i-1] & 0xff),i > 0ti 的 8 个 bit 映射成一个加权和:
out[i] = sum(bit_j(ti) * b[7-j])out[0..23] 和内置 24 个常量比较那 24 个常量是:
text3d1 2f0 052 475 1d2 2f0 224 51c 4e6 29f 2ee 39b 3f9 32b 2f2 5b5 24c 45a 34c 56d 00a 4e6 476 2d9
把这组值反推回来,就得到最终 flag:
swpuctf{y0u_@re_s0_coo1}
这题一句话总结就是:
它把真实校验代码拆成“静态假代码 + 运行时由父进程补进去的真代码”,再利用 Windows 的调试事件机制完成自修补和控流,所以普通附加调试会被它的自调试机制干扰。
你要的话,我可以继续把这题整理成一份“WP 风格”的完整解题过程,把关键函数按伪代码写出来。
int __cdecl main_0(int argc, const char **argv, const char **envp)
{
PVOID lpBaseAddress; // [esp+18h] [ebp-558h]
char v5; // [esp+1Fh] [ebp-551h]
DWORD dwContinueStatus; // [esp+20h] [ebp-550h]
struct _DEBUG_EVENT DebugEvent; // [esp+28h] [ebp-548h] BYREF
char v8; // [esp+8Fh] [ebp-4E1h]
CONTEXT Context; // [esp+94h] [ebp-4DCh] BYREF
HANDLE hThread; // [esp+364h] [ebp-20Ch]
int i; // [esp+368h] [ebp-208h]
SIZE_T NumberOfBytesRead[3]; // [esp+370h] [ebp-200h] BYREF
SIZE_T NumberOfBytesWritten[3]; // [esp+37Ch] [ebp-1F4h] BYREF
char Buffer[60]; // [esp+388h] [ebp-1E8h] BYREF
char v15[40]; // [esp+3C4h] [ebp-1ACh] BYREF
char v16[16]; // [esp+3ECh] [ebp-184h] BYREF
struct _STARTUPINFOA StartupInfo; // [esp+3FCh] [ebp-174h] BYREF
struct _PROCESS_INFORMATION ProcessInformation; // [esp+448h] [ebp-128h] BYREF
CHAR Filename[267]; // [esp+460h] [ebp-110h] BYREF
memset(&ProcessInformation, 0, sizeof(ProcessInformation));
j__memset(&StartupInfo, 0, sizeof(StartupInfo));
v16[0] = -112;
v16[1] = -125;
v16[2] = 125;
v16[3] = -8;
v16[4] = 24;
v16[5] = 125;
v16[6] = 17;
v15[0] = -112;
v15[1] = 15;
v15[2] = -74;
v15[3] = 85;
v15[4] = -9;
v15[5] = -117;
v15[6] = 69;
v15[7] = 8;
v15[8] = -117;
v15[9] = 4;
v15[10] = -112;
v15[11] = 15;
v15[12] = -81;
v15[13] = 69;
v15[14] = -4;
v15[15] = 51;
v15[16] = -46;
v15[17] = -9;
v15[18] = 117;
v15[19] = -8;
v15[20] = 15;
v15[21] = -74;
v15[22] = 77;
v15[23] = -9;
v15[24] = -117;
v15[25] = 69;
v15[26] = 12;
v15[27] = -119;
v15[28] = 20;
v15[29] = -120;
j__memset(Buffer, 0, 0x32u);
NumberOfBytesWritten[0] = 0;
i = 0;
v8 = 1;
if ( IsDebuggerPresent() )
{
sub_DD3922();
return 0;
}
else
{
GetStartupInfoA(&StartupInfo);
GetModuleFileNameA(0, Filename, 0x104u);
if ( CreateProcessA(Filename, 0, 0, 0, 0, 3u, 0, 0, &StartupInfo, &ProcessInformation) )
{
v5 = 1;
LABEL_6:
while ( v5 )
{
dwContinueStatus = 65538;
WaitForDebugEvent(&DebugEvent, 0xFFFFFFFF);
switch ( DebugEvent.dwDebugEventCode )
{
case 1u:
if ( DebugEvent.u.Exception.ExceptionRecord.ExceptionCode == 0x80000003 )
{
v8 = 1;
dwContinueStatus = 65538;
lpBaseAddress = DebugEvent.u.Exception.ExceptionRecord.ExceptionAddress;
ReadProcessMemory(
ProcessInformation.hProcess,
DebugEvent.u.Exception.ExceptionRecord.ExceptionAddress,
Buffer,
0x23u,
NumberOfBytesRead);
if ( NumberOfBytesRead[0] )
{
for ( i = 1; i < 35 && (unsigned __int8)Buffer[i] == 0x90; ++i )
;
}
if ( i == 1 )
v8 = 0;
if ( v8 )
{
switch ( i )
{
case 4:
Context.ContextFlags = 65543;
hThread = OpenThread(0x1FFFFFu, 0, DebugEvent.dwThreadId);
if ( !GetThreadContext(hThread, &Context) )
goto LABEL_31;
++Context.Eip;
if ( SetThreadContext(hThread, &Context) )
{
dwContinueStatus = 65538;
CloseHandle(hThread);
}
goto LABEL_33;
case 5:
LABEL_31:
ContinueDebugEvent(DebugEvent.dwProcessId, DebugEvent.dwThreadId, 0x80010001);
goto LABEL_6;
case 7:
WriteProcessMemory(ProcessInformation.hProcess, lpBaseAddress, v16, 7u, NumberOfBytesWritten);
if ( NumberOfBytesWritten[0] == 7 )
{
ReadProcessMemory(ProcessInformation.hProcess, lpBaseAddress, Buffer, 7u, NumberOfBytesRead);
dwContinueStatus = 65538;
}
goto LABEL_33;
case 30:
WriteProcessMemory(ProcessInformation.hProcess, lpBaseAddress, v15, 0x1Eu, NumberOfBytesWritten);
if ( NumberOfBytesWritten[0] == 30 )
dwContinueStatus = 65538;
goto LABEL_33;
default:
goto LABEL_33;
}
}
dwContinueStatus = -2147418111;
}
goto LABEL_33;
case 2u:
case 3u:
ContinueDebugEvent(DebugEvent.dwProcessId, DebugEvent.dwThreadId, 0x10002u);
break;
case 4u:
case 5u:
v5 = 0;
ContinueDebugEvent(DebugEvent.dwProcessId, DebugEvent.dwThreadId, 0x10002u);
break;
case 6u:
ContinueDebugEvent(DebugEvent.dwProcessId, DebugEvent.dwThreadId, 0x10002u);
break;
default:
LABEL_33:
ContinueDebugEvent(DebugEvent.dwProcessId, DebugEvent.dwThreadId, dwContinueStatus);
break;
}
}
return 0;
}
else
{
return 0;
}
}
}
WaitForDebugEvent(&DebugEvent, 0xFFFFFFFF)是针对新创建的子进程吗,&DebugEvent代表子进程?子进程怎么知道自己被调试了?
对,这里的 WaitForDebugEvent(&DebugEvent, INFINITE) 是父进程作为调试器,在等刚创建出来的子进程发来的调试事件。
但要分清三个东西:
WaitForDebugEvent 等的是谁它等的是:
CreateProcessA(..., 3u, ...) 创建出来的子进程这里的 3u 就是:
DEBUG_PROCESS = 0x1DEBUG_ONLY_THIS_PROCESS = 0x2合起来是 0x3。
意思是:把新创建的子进程作为被调试进程启动,并且只调它自己,不连带它再创建的孙进程。
&DebugEvent 是什么&DebugEvent 不是子进程本身,也不是“子进程句柄”。
它只是父进程里的一块本地内存,用来接收内核填进来的一个 DEBUG_EVENT 结构体。
这个结构体里会告诉父进程:
dwDebugEventCodedwProcessIddwThreadId所以这句的含义其实是:
“父进程阻塞等待,直到被它调试的子进程发生一个调试事件;事件到了之后,把详情写进
DebugEvent这个结构体里。”
它不需要自己猜,因为从创建那一刻起,系统就已经把它标成“正在被调试”。
具体来说:
当父进程用 CreateProcess(..., DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS, ...) 创建子进程时,Windows 内核会:
所以子进程里一执行:
cIsDebuggerPresent()
就会返回真。
这也是你看到的关键逻辑:
cif ( IsDebuggerPresent() ) { sub_DD3922(); return 0; } else { // 创建子进程并调试它 }
意思就是:
else,负责创建并调试一个新的自己IsDebuggerPresent() 为真,走 sub_DD3922()这就是经典的**自调试(self-debugging)**结构。
职责是“调试器”:
CreateProcess(DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS) 创建子进程WaitForDebugEvent 收事件ReadProcessMemoryWriteProcessMemoryGetThreadContextSetThreadContextContinueDebugEvent 让子进程继续执行职责是“被调试进程”:
IsDebuggerPresent() 为真sub_DD3922()int3 断点时,把异常事件发给父进程EIPWaitForDebugEvent 收到的主要是什么最关键的是这段:
cif ( DebugEvent.u.Exception.ExceptionRecord.ExceptionCode == 0x80000003 )
0x80000003 就是 断点异常,通常对应 INT3。
也就是说:
int3EXCEPTION_DEBUG_EVENT 发给父进程NOP,判断这是哪一类“占位断点”你代码里就有这几种处理:
case 4:++Context.Eip,相当于跳过一个字节case 7:v16 那 7 字节真代码写回去case 30:v15 那 30 字节真代码写回去这说明子进程里原来埋的是:
INT3NOP父进程通过“INT3 + NOP 长度”来识别是哪块补丁点。
因为正常情况下,一个进程只能有一个调试器。
所以这题的效果是:
所以这题的反调试本质是:
不是单纯检查有没有调试器,而是主动自己充当调试器,利用调试事件在运行时动态修补子进程代码,从而占用调试权并隐藏真实逻辑。
WaitForDebugEvent(&DebugEvent, ...) 是针对新创建的子进程吗?对,是父进程在等被它调试的子进程发来的调试事件。
&DebugEvent 代表子进程吗?不是。它只是父进程里接收事件信息的一个 DEBUG_EVENT 结构体缓冲区。
因为它是用 DEBUG_PROCESS 标志创建出来的,系统从创建时就把它置为“被调试”状态,所以 IsDebuggerPresent() 会直接返回真。
我也可以顺手把这段 main_0 画成一张“父进程 / 子进程 / 调试事件”的执行流程图。
我理解父进程是调试器,但是哪里证明了他调试的是他创建的子进程?子进程从创建开始就挂上了“被调试”的标签是吗,从哪里看出的?
能证明,而且证据就在你贴的这段里,分成两层看就很清楚。
关键就是这一句:
cCreateProcessA(Filename, 0, 0, 0, 0, 3u, 0, 0, &StartupInfo, &ProcessInformation)
这里第 6 个参数 3u 是 dwCreationFlags,值为 3,也就是:
cDEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS
含义不是“普通创建一个子进程”,而是:
CreateProcess 的这个父进程这不是推测,是 CreateProcess 这个 API 这两个 flag 的语义本身。
然后你代码里还有第二重证据:后面所有调试操作都在拿 ProcessInformation.hProcess 做目标。
例如:
cReadProcessMemory(ProcessInformation.hProcess, ...) WriteProcessMemory(ProcessInformation.hProcess, ...)
ProcessInformation 正是刚才 CreateProcessA 返回的那个新创建进程的信息。
所以父进程后面读写内存的对象,就是它刚创建出来的那个子进程。
再看事件循环:
cWaitForDebugEvent(&DebugEvent, 0xFFFFFFFF); ContinueDebugEvent(DebugEvent.dwProcessId, DebugEvent.dwThreadId, ...);
WaitForDebugEvent 等到的是当前调试器所调试的进程发来的事件。
而这个父进程并没有调用 DebugActiveProcess 去附加别人,也没有创建第二个 debuggee。
所以这里收到的事件来源,只能是刚才 CreateProcess(..., DEBUG_PROCESS|DEBUG_ONLY_THIS_PROCESS, ...) 创建的那个子进程。
把这三点合起来,链条就完整了:
ProcessInformation 就是那个新进程ProcessInformation 和 DebugEvent.dwProcessId/dwThreadId 转所以可以确定:父进程调试的就是它自己创建出来的子进程。
这个既能从 Windows 语义 看,也能从 这段代码本身 看出来。
当进程是用:
cCreateProcess(..., DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS, ...)
创建出来时,系统会把这个新进程放到“被调试”状态下,并把它和创建者这个调试器关联起来。
对进程自身来说,最直接的表现就是:
IsDebuggerPresent() 会返回真BeingDebugged 被置位了所以“子进程从创建开始就挂上被调试标签”这个说法,基本是对的。
更准确点说是:
它从一出生起,就是作为一个 debuggee 被创建的。
看这段最关键:
cif ( IsDebuggerPresent() ) { sub_DD3922(); return 0; } else { ... CreateProcessA(..., 3u, ..., &ProcessInformation) ... WaitForDebugEvent(...) }
这段程序本身就是一个“父子分工”的自调试结构。
原始进程自己没有被别人调试,所以:
cIsDebuggerPresent() == false
于是走 else,去创建一个新的自己,并开始当调试器。
新创建出来的那个副本,程序入口还是同一份代码。
但这次它是被 DEBUG_PROCESS 创建出来的,所以它一进来就会看到:
cIsDebuggerPresent() == true
于是它不会再去继续 CreateProcess,而是直接走:
csub_DD3922();
这其实就是你在代码里能直接看到的“证明”。
因为如果子进程创建出来以后不算被调试,会发生什么?
那它再次运行到这段代码时,IsDebuggerPresent() 还是假,
它就会再进 else,再创建一个新副本,再去调试下一个自己……
程序会变成无限递归地套娃创建进程。
但作者显然不是这么设计的。
他设计的是:
所以从控制流上也能反推出:
子进程一启动,IsDebuggerPresent() 必须已经为真。
父进程调用:
cWaitForDebugEvent(&DebugEvent, INFINITE)
之后会收到子进程的一系列调试事件。
这类事件不是“子进程自己上报”的,而是操作系统因为它是 debuggee,替它发给调试器的。
也就是说,事件机制能成立,本身就说明:
所以不是子进程“主动知道自己被调试”,而是:
系统从创建时就把它纳入调试体系里了。
不是“父进程先普通创建子进程,子进程后来才变成被调试”。
而是:
父进程在创建子进程的那一刻,就指定了“这个新进程要以被调试进程的身份出生,而且调试器就是我”。
CreateProcessA(..., 3u, ...)这说明新进程不是普通创建,而是以 debuggee 身份创建。
同一份程序代码里:
elseif (IsDebuggerPresent())否则逻辑会无限套娃。
text第一次启动的进程 -> IsDebuggerPresent() = false -> CreateProcess(DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS) -> 自己变成调试器 -> WaitForDebugEvent / ContinueDebugEvent 新创建的进程 -> 从创建开始就是 debuggee -> IsDebuggerPresent() = true -> 执行 sub_DD3922() -> 遇到 INT3/异常时由系统把事件转给父进程
就看 CreateProcessA(..., 3u, ..., &ProcessInformation)。
3u 就是调试创建标志;后面读写内存和调试事件处理又都围绕 ProcessInformation 展开,所以父进程调试的对象就是它自己创建出来的那个新进程。
是。更准确地说,是作为 debuggee 被创建出来。
从两处能看出来:
CreateProcess 的 DEBUG_PROCESS 标志本身就表示这一点IsDebuggerPresent() 为真,否则会无限递归创建新进程你要的话,我下一条可以把 DEBUG_PROCESS 创建后的前几个典型调试事件顺序 也给你列出来,这样你会更清楚 WaitForDebugEvent 为什么能收到子进程的 INT3。
WaitForDebugEvent(&DebugEvent, 0xFFFFFFFF);父进程通过这一行获取子进程断点,可是他并没有指定哪个子进程啊,那么多进程他怎么确定等待的就是他创建的这个
不是“系统里那么多进程,WaitForDebugEvent 随机等一个”。
它等的是:当前这个调试器线程所属调试会话里的调试事件队列。Windows 文档明确说,WaitForDebugEvent 等待的是“a process being debugged”的调试事件,而且“only the thread that created the process being debugged can call WaitForDebugEvent”。同时,DEBUG_PROCESS / DEBUG_ONLY_THIS_PROCESS 的语义就是:** 调用线程启动并调试这个新进程**,并可通过 WaitForDebugEvent 接收相关事件。(Microsoft Learn)
套回你这段代码:
cCreateProcessA(Filename, 0, 0, 0, 0, 3u, 0, 0, &StartupInfo, &ProcessInformation)
这里 3u = DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS。官方文档对这两个标志的说明是:
DEBUG_PROCESS:调用线程启动并调试新进程以及它的子孙进程。DEBUG_ONLY_THIS_PROCESS:调用线程启动并调试这个新进程本身。所以这段代码一执行完,系统就已经建立了这样一个关系:
text当前父进程里的这个调用线程 ---> 调试会话 ---> 刚创建出来的子进程
因此后面的
cWaitForDebugEvent(&DebugEvent, INFINITE);
根本不需要再传“等哪个 PID”,因为“等谁”在 CreateProcess(..., DEBUG_...) 那一步就已经由内核绑定好了。它只会从这个线程作为 debugger 所绑定的 debuggee 集合里取事件,不会去理会系统里那些无关进程。(Microsoft Learn)
你可以把 WaitForDebugEvent 理解成“从我的调试事件收件箱里取下一封信”,而不是“去全系统广播收消息”。
这个“收件箱”里装哪些进程的事件,来源只有两种:
CreateProcess(..., DEBUG_PROCESS / DEBUG_ONLY_THIS_PROCESS, ...) 创建出来的进程。DebugActiveProcess(pid) 主动附加进去的进程。(Microsoft Learn)而你这题里只做了第 1 种,并且用了 DEBUG_ONLY_THIS_PROCESS,所以队列里基本就只有它刚创建的那个子进程的事件。(Microsoft Learn)
再进一步说,真要存在“一个调试器同时调多个目标”的情况,WaitForDebugEvent 也不是没法区分。因为它返回的 DEBUG_EVENT 结构里自带:
dwProcessIddwThreadId也就是说,是否有多个被调试进程不影响取事件;取到以后看 DebugEvent.dwProcessId 就知道这条事件是谁发来的。官方文档也说明,CREATE_PROCESS_DEBUG_EVENT 会把被调试进程和初始线程的句柄交给调试器,后续所有事件也都通过 DEBUG_EVENT 结构返回。(Microsoft Learn)
所以你这个问题的最短答案是:
WaitForDebugEvent 不是“指定 PID 等待”模型。CreateProcess(..., DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS, ...) 建出来的。DEBUG_ONLY_THIS_PROCESS,所以这里实际就是:等它自己刚创建的那个子进程。 (Microsoft Learn)再对应回你的代码,能看到这一点特别明显:
cReadProcessMemory(ProcessInformation.hProcess, ...) WriteProcessMemory(ProcessInformation.hProcess, ...)
ProcessInformation.hProcess 就是刚才 CreateProcessA 返回的那个新进程句柄。
所以整个逻辑链非常闭合:
CreateProcess(..., DEBUG...) 把子进程挂到当前线程的调试会话里。WaitForDebugEvent 从这个会话里收事件。DebugEvent.dwProcessId/dwThreadId 告诉你是哪条线程/进程出的事。ReadProcessMemory / WriteProcessMemory / GetThreadContext / SetThreadContext 再去操作这个子进程。 (Microsoft Learn)你要是愿意,我下一条可以把“这题第一次 WaitForDebugEvent 通常会收到哪些事件,顺序大概是什么”也按时间线给你列出来。
意思是 CreateProcessA(Filename, 0, 0, 0, 0, 3u, 0, 0, &StartupInfo, &ProcessInformation)中的参数“3”表明了这个被调试的子进程只能被创建它的父进程调试,是吗
可以这么理解,但要改得更准确一点:
不是“因为它是父进程,所以它能调试子进程”。
而是:调用 CreateProcessA(..., 3, ...) 的那个线程,会把新进程以 debuggee 身份创建出来,并由这个调用方充当 debugger。 这里的 3 就是 DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS。微软文档对这两个标志的定义分别是:调用线程启动并调试新进程;若两者同时指定,则只调试这个新进程,不调试它的后代,而且这个调用线程可以通过 WaitForDebugEvent 收到相关调试事件。(Microsoft Learn)
所以对你这题来说,3 的准确含义是:
DEBUG_PROCESS:把新创建的进程放进当前调用方的调试会话里。(Microsoft Learn)DEBUG_ONLY_THIS_PROCESS:只调这个新进程本身,不调它后面再创建的子孙进程。(Microsoft Learn)但它不等于“这个子进程永远只能被它的父进程调试”。更准确的说法是:
CreateProcess(..., DEBUG_...) 占住调试权时,外部调试器通常不能再直接附加这个子进程,除非当前调试关系结束或被释放。这个结论也能从文档语义侧面看出来:WaitForDebugEvent 只面向“正在被当前调试器调试的进程”,并且“只有创建被调试进程的那个线程”能调用它。(Microsoft Learn)所以你这句话可以改成下面这样,最严谨:
CreateProcessA(..., 3, ...)表明:创建者线程会以调试方式启动这个新进程,并成为它的当前调试器;而且只调这个新进程,不调它的后代。
你要是说“父进程调试子进程”,在这道题里也没问题,因为调用 CreateProcess 的正好就是父进程里的主线程。但 API 语义上真正关键的是“calling thread”,不是“parent”这个家庭关系词。(Microsoft Learn)
再顺手纠正一个容易混淆的点:
DEBUG_ONLY_THIS_PROCESS 不是“只有这个父进程才有资格调试它”DEBUG_PROCESS / CreateProcess 这个动作本身;DEBUG_ONLY_THIS_PROCESS 只是把范围收窄。(Microsoft Learn)放回你这题里,逻辑就是:
cCreateProcessA(Filename, ..., 3u, ..., &ProcessInformation); WaitForDebugEvent(&DebugEvent, INFINITE);
这两句连起来的意思不是“随便等一个进程的事件”,而是:
“把刚创建的这个新进程作为 debuggee 启动,然后由当前线程从自己的调试事件队列里接收它的事件。” (Microsoft Learn)
你要的话,我可以下一条专门给你讲清楚 DEBUG_PROCESS 和 DEBUG_ONLY_THIS_PROCESS 单独用、一起用,各自差别到底是什么。
我用IDA动态调试,更改了第一次isdebugpresenr的返回值,创建进程后确实显示了两个进程(顺便问一下thread不是线程的意思吗),接下来我该怎么调试,我从来没有试过同时调试两个进程,而且创建了子进程之后不是应该另开一个命令行窗口吗,而且我现在也没看到要我输入flag的提示
你这个截图里显示的不是两个进程,而是两个 thread。
thread 确实就是“线程”的意思。IDA 这个窗口标题都写了 Threads,所以你现在看到的是当前被 IDA 附着的那个进程里的线程列表,不是父子进程列表。
也就是说:
你现在这个题,正确思路其实不是“同时手动调父子两个进程”,而是:
只调父进程,让父进程去调子进程。
因为这题本来就是“父进程充当调试器,子进程是真正被调试对象”。
因为这里:
cCreateProcessA(Filename, 0, 0, 0, 0, 3u, 0, 0, &StartupInfo, &ProcessInformation)
只传了 DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS,没有传 CREATE_NEW_CONSOLE。
所以子进程默认不会强制新开一个控制台窗口。常见情况是:
因为子进程虽然被创建了,但它一出生就被父进程接管成 debuggee。
它后续每遇到关键 INT3,都要等父进程 WaitForDebugEvent -> ContinueDebugEvent 放行。
所以只要父进程没把前面的调试事件正确处理完,子进程就不会顺利跑到输入 flag 的地方。
还有一个很重要的坑:
你说你“改了第一次 IsDebuggerPresent 的返回值”。
这里一定要注意你改的是父进程这一次执行,不要把整个 API 全局改死。
因为这题的正常逻辑是:
IsDebuggerPresent() 应该是 false,这样才会去 CreateProcessIsDebuggerPresent() 应该是 true,这样才会走 sub_DD3922()如果你把子进程那次也改成 false,它可能会继续再创建新副本,逻辑就乱了。
这是最贴题的办法。
你已经知道父进程核心循环在这里:
cWaitForDebugEvent(&DebugEvent, 0xFFFFFFFF);
它收到的是子进程的调试事件。你接下来要重点盯这些点:
CreateProcessAWaitForDebugEventContinueDebugEventReadProcessMemoryWriteProcessMemoryGetThreadContextSetThreadContext每次父进程从 WaitForDebugEvent 返回后,看:
cDebugEvent.dwDebugEventCode DebugEvent.u.Exception.ExceptionRecord.ExceptionCode DebugEvent.u.Exception.ExceptionRecord.ExceptionAddress
如果是:
cExceptionCode == 0x80000003
那就是子进程 hit 到了 INT3 断点。
然后这段代码会:
Buffer0x90(NOP)你已经贴出来了几个关键分支:
case 4:改线程上下文,++Eipcase 7:把 v16 写回子进程case 30:把 v15 写回子进程这说明作者在子进程里埋了几段:
INT3NOP父进程碰到不同长度的 INT3+NOP 占位块,就往里补真正代码。
WriteProcessMemory尤其是:
cWriteProcessMemory(ProcessInformation.hProcess, lpBaseAddress, v16, 7u, ...) WriteProcessMemory(ProcessInformation.hProcess, lpBaseAddress, v15, 0x1Eu, ...)
这两句就是在往子进程里写真代码。
你可以直接把这两段字节记下来:
text90 83 7D F8 18 7D 11
text90 0F B6 55 F7 8B 45 08 8B 04 90 0F AF 45 FC 33 D2 F7 75 F8 0F B6 4D F7 8B 45 0C 89 14 88
注意这里负数是有符号 char,转成 16 进制按无符号看就行。
这两段拿去反汇编,你就能看到父进程到底给子进程补了什么逻辑。
这是实战里更省事的办法。
思路是:
把父进程自调试逻辑绕掉,不让它创建子进程;
然后把本来父进程要写进子进程的那两段 v15/v16,直接手工 patch 回原程序对应的 INT3+NOP 位置。
这样你就把:
然后直接单进程调试就行。
更粗暴一点:
IsDebuggerPresent 的分支改成直接进入 sub_DD3922()INT3+NOP 区域 patch 成真实代码这通常比“同时观察父子联动”轻松很多。
确认你现在断的是父进程,不是在看线程列表误以为是子进程列表。
在父进程里给下面几个 API 下断点:
textWaitForDebugEvent WriteProcessMemory GetThreadContext SetThreadContext ContinueDebugEvent
一旦断在 WriteProcessMemory,记下:
lpBaseAddress:子进程里被修补的地址v16 或 v15去子进程对应模块基址上,定位这些地址,看原来是不是:
CC 90 90 90...如果是,就说明你已经抓到真正隐藏代码块了。
把这几段真实代码 patch 回 IDA 反汇编视图,之后就可以脱离自调试框架继续分析。
这题里你通常不需要真的在 IDA 里同时手动切两个进程。
因为逻辑上已经是:
你再想让 IDA 直接附加子进程,往往会和父进程的调试权冲突。
所以正确姿势不是“双开一起单步”,而是:
在父进程里观察它如何操纵子进程。
你现在先别等输入框,先专注抓这两个东西:
WriteProcessMemory 往子进程写了哪几段真实代码SetThreadContext 到底把 EIP 怎么改了把这两件事弄明白,这题基本就通了。
你要是愿意,我下一条可以直接教你:
怎么把 v15 和 v16 这两段机器码在 IDA 里手工反汇编出来,并判断它们分别补到了什么逻辑里。
可是我想调试子进程
那就先说结论:
你没法在“父进程已经是子进程调试器”的前提下,再让 IDA 正常附加到这个子进程。
同一时刻一个用户态进程基本只能有一个调试器。现在这个位置上,子进程的调试器是它爹,不是 IDA。
所以你想“调试子进程”,有两条路:
这其实最接近你想要的效果。
你要做的是让程序直接走子进程那条分支,而不是先走父进程自调试。
IsDebuggerPresent() 后面的分支。sub_DD3922()。
也就是让程序一启动就按“子进程视角”执行。CreateProcessAWaitForDebugEventContinueDebugEvent这样你用 IDA 调的虽然还是这个 exe,但执行路径已经等价于“原来的子进程”。
这时你会直接在自己的调试器里碰到那些 INT3 + NOP 占位块。
原本这些断点是交给父进程处理的,现在就改成你手工处理:
v16v15EIP += 1你前面贴的两段真代码字节就是:
v16:
text90 83 7D F8 18 7D 11
v15:
text90 0F B6 55 F7 8B 45 08 8B 04 90 0F AF 45 FC 33 D2 F7 75 F8 0F B6 4D F7 8B 45 0C 89 14 88
这条路的好处是:
你确实在调“子进程逻辑”,但不需要真的去抢那个子进程的调试权。
那就必须先把父进程的调试权拿掉。
在父进程里下断到 CreateProcessA 调用前,把第 6 个参数 dwCreationFlags 从 3 改成 0。
也就是把:
cCreateProcessA(..., 3u, ...)
改成:
cCreateProcessA(..., 0, ...)
这样创建出来的就只是普通子进程,不再是被父进程调试的子进程。
如果你只改了这个,子进程启动后 IsDebuggerPresent() 会是 false,它就不会走 sub_DD3922(),而是又走回“创建新进程”的父进程逻辑。
所以你还得额外做一件事:
把子进程入口的第一次 IsDebuggerPresent 分支也改掉,让它直接进入 sub_DD3922()。
CreateProcessA 的 flag 从 3 改成 0ProcessInformation.dwProcessIdIsDebuggerPresent 那个分支改成直接进 sub_DD3922()INT3+NOP 补丁点因为第二条麻烦在:
而第一条本质上只是在说:
“我不要父进程这层壳了,我直接把子进程执行路径拿出来调。”
对这种自调试题,这通常是最稳的。
别再盯“怎么同时调父子”了,直接做这个:
IsDebuggerPresent 后的跳转改到 sub_DD3922INT3,看后面跟多少个 NOP这样你调到的就是你真正想看的那部分。
你要是愿意,我下一条可以直接按 IDA 操作步骤 写给你:
“在哪改跳转、断到 INT3 后怎么改内存、怎么手动把 EIP 加 1”。
问一下长度7的占位块,.text:00BD8AF8 int 3 ; Trap to Debugger
.text:00BD8AF9 nop
.text:00BD8AFA nop
.text:00BD8AFB nop
.text:00BD8AFC nop
.text:00BD8AFD nop
.text:00BD8AFE nop
.text:00BD8AFF push offset aYouAreTooShort ; "you are too short!"
只有6个nop啊
对,你看到的 6 个 nop 没错,但这里的 case 7 指的是整块占位区总长度是 7 字节,不是“7 个 nop”。
你这段是:
asmint3 nop nop nop nop nop nop push offset aYouAreTooShort
也就是:
int3 = 0xCCnop = 0x90合起来正好 7 字节:
textCC 90 90 90 90 90 90
所以它对应 case 7。
关键就在这段循环:
cfor ( i = 1; i < 35 && (unsigned __int8)Buffer[i] == 0x90; ++i ) ;
它不是在数“有多少个 nop”,而是在做这个事:
Buffer[0] 默认就是断点位置那个 int3Buffer[1] 开始往后扫连续的 nop所以如果布局是:
textBuffer[0] = CC Buffer[1] = 90 Buffer[2] = 90 Buffer[3] = 90 Buffer[4] = 90 Buffer[5] = 90 Buffer[6] = 90 Buffer[7] = 68 / 6A / E8 / ... // 下一个正常指令字节
那循环过程就是:
i=1,是 nop,继续i=2,是 nopi=6,是 nopi=7,发现 Buffer[7] 已经不是 0x90 了,退出于是最后 i == 7。
所以这里的 i 可以理解成:
“从 Buffer[0] 开始,这个 int3 + nop... 占位块一共占了多少字节”
而不是:
“nop 的数量”
你这个例子刚好就是:
asm.text:00BD8AF8 int 3 .text:00BD8AF9 nop .text:00BD8AFA nop .text:00BD8AFB nop .text:00BD8AFC nop .text:00BD8AFD nop .text:00BD8AFE nop .text:00BD8AFF push offset ...
所以:
int3 在 0xBD8AF8nop 从 0xBD8AF9 到 0xBD8AFE,共 6 个push 在 0xBD8AFF总占位范围就是:
text0xBD8AF8 ~ 0xBD8AFE
一共 7 字节
所以父进程才会走:
ccase 7: WriteProcessMemory(..., lpBaseAddress, v16, 7u, ...)
也就是把这 7 字节整块替换掉。
同理你可以这样理解别的分支:
case 4
表示占位块总长 4 字节
也就是 1 个 int3 + 3 个 nop
case 30
表示占位块总长 30 字节
也就是 1 个 int3 + 29 个 nop
你这个观察其实非常关键,因为它说明:
作者用的是“int3 + 若干 nop 的总长度”来给不同补丁点编号。
也就是说:
你现在这个 7 字节块,完全可以直接 patch 成 v16:
text90 83 7D F8 18 7D 11
也就是把原来的:
textCC 90 90 90 90 90 90
替换掉。
你要的话,我下一条可以直接把这 7 字节在 x86 下反汇编成指令,告诉你它补进去的真实逻辑是什么。