解出flag,告诉我这个题的反调试原理

Criado em: 14 de abril de 2026

Respondido usando GPT-5.4 Thinking por Chat01

Pergunta

attachment.exe

解出flag,告诉我这个题的反调试原理

Pensamento

我先把这个可执行文件当成一道逆向题来拆:先确认有没有现成的二进制分析技能可用,再静态看样本结构,必要时跑起来定位反调试和 flag 生成逻辑。

Thought for 12s

Python

python
print("这条消息可忽略。")

Pensamento

Explaining the validation algorithm and anti-debugging

The "Please Input Flag" prompt could be intentionally obfuscated ("1lease" and "1ou" instead of regular words). The validation algorithm involves computing a weight array from the initial seed, then applying XOR to each byte. The result is compared with constants and successfully decodes "You Are Right!" to verify the flag. Anti-debugging checks like IsDebuggerPresent are employed to prevent external debuggers.

Thought for 15m 39s

Resposta

flag 是:

swpuctf{y0u_@re_s0_coo1}

这题的反调试核心不是单纯 IsDebuggerPresent,而是自调试 + 断点补丁 + 上下文改写

它的大致流程是这样:

  1. 程序先调 IsDebuggerPresent。如果你直接用调试器附加,先死一层。
  2. 然后它用 CreateProcessA 以调试方式再拉起一个子进程,父进程自己当调试器。 从导入表能直接看出来它用了这一整套: CreateProcessA / WaitForDebugEvent / ContinueDebugEvent / OpenThread / GetThreadContext / SetThreadContext / ReadProcessMemory / WriteProcessMemory
  3. 子进程里真正关键的代码位置并不是完整明文,而是埋了多个 INT3 + NOP 占位块。
  4. 父进程在 WaitForDebugEvent 循环里等这些断点异常,按不同断点位置分别做两类事:
    • WriteProcessMemory 把占位的 INT3/NOP 区域改写成真实指令
    • 或者用 GetThreadContext / SetThreadContext 改线程寄存器,改执行流
  5. 最后 ContinueDebugEvent 放子进程继续跑。

所以你如果用外部调试器跟它抢调试权,父进程这套“运行时补丁”机制就失效,程序里的关键逻辑就看不全,或者会直接走异常/退出分支。

这题里我能静态确认到几个很明显的点:

  • 有一整个自调试函数,入口在 0x408bc0
  • 它会创建子进程并进入 WaitForDebugEvent 循环
  • 代码里至少有几块 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] ^ 0x34
    • ti = flag[i] ^ (out[i-1] & 0xff)i > 0
  • 然后把 ti 的 8 个 bit 映射成一个加权和: out[i] = sum(bit_j(ti) * b[7-j])
  • 最后把 out[0..23] 和内置 24 个常量比较

那 24 个常量是:

text
3d1 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 风格”的完整解题过程,把关键函数按伪代码写出来。

Pergunta

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代表子进程?子进程怎么知道自己被调试了?

Pensamento

Explaining reverse engineering with Windows API

User is asking for an explanation regarding reverse engineering code, particularly the use of Windows API. I’ll clarify how to work with the DebugEvent structure, explaining flags like DEBUG_PROCESS and DEBUG_ONLY_THIS_PROCESS. I'll also mention how the child process knows it’s being debugged via the PEB and behavior differences during debugging.

Clarifying child process logic in debugging scenario

The child process seems to run the same executable as the parent, but because of the debug flags, it is created as a debuggee. The IsDebuggerPresent function will return true for the child, potentially triggering code that behaves differently due to detecting the debugger. The debugger logic proceeds in sub_DD3922.

Thought for 21s

Resposta

对,这里的 WaitForDebugEvent(&DebugEvent, INFINITE)父进程作为调试器,在等刚创建出来的子进程发来的调试事件。

但要分清三个东西:

1. WaitForDebugEvent 等的是谁

它等的是:

  • 由当前进程“调试着”的那个进程
  • 在这题里就是 CreateProcessA(..., 3u, ...) 创建出来的子进程

这里的 3u 就是:

  • DEBUG_PROCESS = 0x1
  • DEBUG_ONLY_THIS_PROCESS = 0x2

合起来是 0x3

意思是:把新创建的子进程作为被调试进程启动,并且只调它自己,不连带它再创建的孙进程。


2. &DebugEvent 是什么

&DebugEvent 不是子进程本身,也不是“子进程句柄”。

它只是父进程里的一块本地内存,用来接收内核填进来的一个 DEBUG_EVENT 结构体。

这个结构体里会告诉父进程:

  • 发生了什么事件 dwDebugEventCode
  • 是哪个进程 dwProcessId
  • 是哪个线程 dwThreadId
  • 如果是异常,异常码和异常地址是什么
  • 如果是创建进程 / 退出进程 / 加载 DLL,也会带对应信息

所以这句的含义其实是:

“父进程阻塞等待,直到被它调试的子进程发生一个调试事件;事件到了之后,把详情写进 DebugEvent 这个结构体里。”


3. 子进程怎么知道自己被调试了

不需要自己猜,因为从创建那一刻起,系统就已经把它标成“正在被调试”。

具体来说:

当父进程用 CreateProcess(..., DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS, ...) 创建子进程时,Windows 内核会:

  • 给子进程挂上调试对象
  • 设置它的调试状态
  • 让它的异常、线程创建、进程创建等事件都往父进程这边送
  • 同时让一些“我是否被调试”的检查成立

所以子进程里一执行:

c
IsDebuggerPresent()

就会返回真。

这也是你看到的关键逻辑:

c
if ( IsDebuggerPresent() ) { sub_DD3922(); return 0; } else { // 创建子进程并调试它 }

意思就是:

  • 第一次启动的原始进程:没有被调试,所以走 else,负责创建并调试一个新的自己
  • 新创建的子进程:一出生就处于“被父进程调试”的状态,所以 IsDebuggerPresent() 为真,走 sub_DD3922()

这就是经典的**自调试(self-debugging)**结构。


这段代码的完整角色分工

父进程

职责是“调试器”:

  • CreateProcess(DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS) 创建子进程
  • WaitForDebugEvent 收事件
  • 根据断点地址做处理:
    • ReadProcessMemory
    • WriteProcessMemory
    • GetThreadContext
    • SetThreadContext
  • ContinueDebugEvent 让子进程继续执行

子进程

职责是“被调试进程”:

  • 因为被父进程调试,所以 IsDebuggerPresent() 为真
  • 执行真正保护过的逻辑 sub_DD3922()
  • 运行到 int3 断点时,把异常事件发给父进程
  • 父进程收到后决定:
    • 改代码
    • EIP
    • 或直接继续

你贴的这段里,WaitForDebugEvent 收到的主要是什么

最关键的是这段:

c
if ( DebugEvent.u.Exception.ExceptionRecord.ExceptionCode == 0x80000003 )

0x80000003 就是 断点异常,通常对应 INT3

也就是说:

  • 子进程执行到某个 int3
  • Windows 不会让它自己处理,而是先把这个异常作为 EXCEPTION_DEBUG_EVENT 发给父进程
  • 父进程检查异常地址附近的机器码
  • 根据后面有多少个 NOP,判断这是哪一类“占位断点”
  • 然后决定怎么修补

你代码里就有这几种处理:

  • case 4:
    改线程上下文,++Context.Eip,相当于跳过一个字节
  • case 7:
    v16 那 7 字节真代码写回去
  • case 30:
    v15 那 30 字节真代码写回去

这说明子进程里原来埋的是:

  • INT3
  • 后面跟一串 NOP

父进程通过“INT3 + NOP 长度”来识别是哪块补丁点。


为什么这能反调试

因为正常情况下,一个进程只能有一个调试器

所以这题的效果是:

  • 它自己先占住调试权:父进程调试子进程
  • 你外部再想用 OllyDbg / x64dbg / WinDbg 去附加子进程,就会冲突
  • 如果你调父进程,它又只是个“壳调试器”,真正逻辑在子进程
  • 如果你没把父子联动关系看清,关键代码就会一直是“假代码 / 占位代码”

所以这题的反调试本质是:

不是单纯检查有没有调试器,而是主动自己充当调试器,利用调试事件在运行时动态修补子进程代码,从而占用调试权并隐藏真实逻辑。


你那个问题可以浓缩成一句话

WaitForDebugEvent(&DebugEvent, ...) 是针对新创建的子进程吗?

对,是父进程在等被它调试的子进程发来的调试事件。

&DebugEvent 代表子进程吗?

不是。它只是父进程里接收事件信息的一个 DEBUG_EVENT 结构体缓冲区。

子进程怎么知道自己被调试了?

因为它是用 DEBUG_PROCESS 标志创建出来的,系统从创建时就把它置为“被调试”状态,所以 IsDebuggerPresent() 会直接返回真。


我也可以顺手把这段 main_0 画成一张“父进程 / 子进程 / 调试事件”的执行流程图。

Pergunta

我理解父进程是调试器,但是哪里证明了他调试的是他创建的子进程?子进程从创建开始就挂上了“被调试”的标签是吗,从哪里看出的?

Pensamento

Explaining the Windows CreateProcessA function

I’m focusing on explaining the CreateProcessA function, specifically with the flags DEBUG_PROCESS|DEBUG_ONLY_THIS_PROCESS (3u), which is used to make the process debuggable by the caller. The ProcessInformation structure will hold details like the child process handles and IDs. I don't need to pull in web sources—Windows API docs should suffice.

Explaining debugging flow for child processes

When using WaitForDebugEvent, it only gets events for processes that were created or attached by the current debugger and matches the ProcessInformation.dwProcessId. The child process gets “tagged” as being debugged, visible through its IsDebuggerPresent check in its first execution. The parent isn’t debugged, but the new child process behaves differently due to the debug flags.

Thought for 20s

Resposta

能证明,而且证据就在你贴的这段里,分成两层看就很清楚。

第一层:哪里证明“父进程调试的是它刚创建的子进程”

关键就是这一句:

c
CreateProcessA(Filename, 0, 0, 0, 0, 3u, 0, 0, &StartupInfo, &ProcessInformation)

这里第 6 个参数 3udwCreationFlags,值为 3,也就是:

c
DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS

含义不是“普通创建一个子进程”,而是:

  • 把这个新创建的进程作为被调试进程启动
  • 调试者就是当前调用 CreateProcess 的这个父进程
  • 只调这个新进程,不连带它再创建的后代

这不是推测,是 CreateProcess 这个 API 这两个 flag 的语义本身。

然后你代码里还有第二重证据:后面所有调试操作都在拿 ProcessInformation.hProcess 做目标。

例如:

c
ReadProcessMemory(ProcessInformation.hProcess, ...) WriteProcessMemory(ProcessInformation.hProcess, ...)

ProcessInformation 正是刚才 CreateProcessA 返回的那个新创建进程的信息。
所以父进程后面读写内存的对象,就是它刚创建出来的那个子进程

再看事件循环:

c
WaitForDebugEvent(&DebugEvent, 0xFFFFFFFF); ContinueDebugEvent(DebugEvent.dwProcessId, DebugEvent.dwThreadId, ...);

WaitForDebugEvent 等到的是当前调试器所调试的进程发来的事件。
而这个父进程并没有调用 DebugActiveProcess 去附加别人,也没有创建第二个 debuggee。
所以这里收到的事件来源,只能是刚才 CreateProcess(..., DEBUG_PROCESS|DEBUG_ONLY_THIS_PROCESS, ...) 创建的那个子进程。

把这三点合起来,链条就完整了:

  1. 父进程用 debug flag 创建新进程
  2. 返回的 ProcessInformation 就是那个新进程
  3. 后续读写内存、线程上下文、调试事件处理,全都围着这个 ProcessInformationDebugEvent.dwProcessId/dwThreadId

所以可以确定:父进程调试的就是它自己创建出来的子进程。


第二层:哪里证明“子进程一创建出来就带着被调试状态”

这个既能从 Windows 语义 看,也能从 这段代码本身 看出来。

从 Windows 语义看

当进程是用:

c
CreateProcess(..., DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS, ...)

创建出来时,系统会把这个新进程放到“被调试”状态下,并把它和创建者这个调试器关联起来。

对进程自身来说,最直接的表现就是:

  • IsDebuggerPresent() 会返回真
  • 更底层地说,就是 PEB 里的 BeingDebugged 被置位了

所以“子进程从创建开始就挂上被调试标签”这个说法,基本是对的。
更准确点说是:

它从一出生起,就是作为一个 debuggee 被创建的。


第三层:从你这段代码里怎么反推出这一点

看这段最关键:

c
if ( IsDebuggerPresent() ) { sub_DD3922(); return 0; } else { ... CreateProcessA(..., 3u, ..., &ProcessInformation) ... WaitForDebugEvent(...) }

这段程序本身就是一个“父子分工”的自调试结构。

第一次运行时

原始进程自己没有被别人调试,所以:

c
IsDebuggerPresent() == false

于是走 else,去创建一个新的自己,并开始当调试器。

第二次运行时

新创建出来的那个副本,程序入口还是同一份代码。
但这次它是被 DEBUG_PROCESS 创建出来的,所以它一进来就会看到:

c
IsDebuggerPresent() == true

于是它不会再去继续 CreateProcess,而是直接走:

c
sub_DD3922();

这其实就是你在代码里能直接看到的“证明”。

因为如果子进程创建出来以后不算被调试,会发生什么?

那它再次运行到这段代码时,IsDebuggerPresent() 还是假,
它就会再进 else,再创建一个新副本,再去调试下一个自己……
程序会变成无限递归地套娃创建进程。

但作者显然不是这么设计的。
他设计的是:

  • 第一份进程:做调试器
  • 第二份进程:做被调试者,执行真正逻辑

所以从控制流上也能反推出:

子进程一启动,IsDebuggerPresent() 必须已经为真。


第四层:调试事件也在侧面证明它“从创建起就被调试”

父进程调用:

c
WaitForDebugEvent(&DebugEvent, INFINITE)

之后会收到子进程的一系列调试事件。
这类事件不是“子进程自己上报”的,而是操作系统因为它是 debuggee,替它发给调试器的。

也就是说,事件机制能成立,本身就说明:

  • 这个子进程已经被挂到了调试对象下面
  • 它的异常、线程、进程事件已经进入调试通道
  • 父进程已经是它的 debugger

所以不是子进程“主动知道自己被调试”,而是:

系统从创建时就把它纳入调试体系里了。


你可以把它理解成一句最简版

不是“父进程先普通创建子进程,子进程后来才变成被调试”。

而是:

父进程在创建子进程的那一刻,就指定了“这个新进程要以被调试进程的身份出生,而且调试器就是我”。


这段代码里最硬的两个证据

证据 1:CreateProcessA(..., 3u, ...)

这说明新进程不是普通创建,而是以 debuggee 身份创建

证据 2:子进程入口第一句就分流

同一份程序代码里:

  • 原始进程走 else
  • 新副本必须走 if (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 被创建出来
从两处能看出来:

  1. CreateProcessDEBUG_PROCESS 标志本身就表示这一点
  2. 同一份程序代码在子进程里必须让 IsDebuggerPresent() 为真,否则会无限递归创建新进程

你要的话,我下一条可以把 DEBUG_PROCESS 创建后的前几个典型调试事件顺序 也给你列出来,这样你会更清楚 WaitForDebugEvent 为什么能收到子进程的 INT3

Pergunta

WaitForDebugEvent(&DebugEvent, 0xFFFFFFFF);父进程通过这一行获取子进程断点,可是他并没有指定哪个子进程啊,那么多进程他怎么确定等待的就是他创建的这个

Pensamento

undefined

Pensamento

Clarifying WaitForDebugEvent behavior

WaitForDebugEvent doesn't specify a PID because it waits for any debug event related to the process the calling thread is debugging. If multiple processes are involved, the system will return the appropriate information with dwProcessId and dwThreadId. This applies to processes created by the calling thread, not unrelated system processes.

Thought for 28s

Resposta

不是“系统里那么多进程,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)

套回你这段代码:

c
CreateProcessA(Filename, 0, 0, 0, 0, 3u, 0, 0, &StartupInfo, &ProcessInformation)

这里 3u = DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS。官方文档对这两个标志的说明是:

  • DEBUG_PROCESS:调用线程启动并调试新进程以及它的子孙进程。
  • DEBUG_ONLY_THIS_PROCESS:调用线程启动并调试这个新进程本身
  • 两者一起用时:只调这个新进程,不调它的子进程。 (Microsoft Learn)

所以这段代码一执行完,系统就已经建立了这样一个关系:

text
当前父进程里的这个调用线程 ---> 调试会话 ---> 刚创建出来的子进程

因此后面的

c
WaitForDebugEvent(&DebugEvent, INFINITE);

根本不需要再传“等哪个 PID”,因为“等谁”在 CreateProcess(..., DEBUG_...) 那一步就已经由内核绑定好了。它只会从这个线程作为 debugger 所绑定的 debuggee 集合里取事件,不会去理会系统里那些无关进程。(Microsoft Learn)

你可以把 WaitForDebugEvent 理解成“从我的调试事件收件箱里取下一封信”,而不是“去全系统广播收消息”。
这个“收件箱”里装哪些进程的事件,来源只有两种:

  1. 你用 CreateProcess(..., DEBUG_PROCESS / DEBUG_ONLY_THIS_PROCESS, ...) 创建出来的进程。
  2. 你用 DebugActiveProcess(pid) 主动附加进去的进程。(Microsoft Learn)

而你这题里只做了第 1 种,并且用了 DEBUG_ONLY_THIS_PROCESS,所以队列里基本就只有它刚创建的那个子进程的事件。(Microsoft Learn)

再进一步说,真要存在“一个调试器同时调多个目标”的情况,WaitForDebugEvent 也不是没法区分。因为它返回的 DEBUG_EVENT 结构里自带:

  • dwProcessId
  • dwThreadId

也就是说,是否有多个被调试进程不影响取事件;取到以后看 DebugEvent.dwProcessId 就知道这条事件是谁发来的。官方文档也说明,CREATE_PROCESS_DEBUG_EVENT 会把被调试进程和初始线程的句柄交给调试器,后续所有事件也都通过 DEBUG_EVENT 结构返回。(Microsoft Learn)

所以你这个问题的最短答案是:

  • WaitForDebugEvent 不是“指定 PID 等待”模型。
  • 它是“等待当前调试器线程所绑定的 debuggee 集合里的下一条事件”模型。
  • 在这题里,那个集合是怎么来的?就是 CreateProcess(..., DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS, ...) 建出来的。
  • 因为用了 DEBUG_ONLY_THIS_PROCESS,所以这里实际就是:等它自己刚创建的那个子进程。 (Microsoft Learn)

再对应回你的代码,能看到这一点特别明显:

c
ReadProcessMemory(ProcessInformation.hProcess, ...) WriteProcessMemory(ProcessInformation.hProcess, ...)

ProcessInformation.hProcess 就是刚才 CreateProcessA 返回的那个新进程句柄
所以整个逻辑链非常闭合:

  1. CreateProcess(..., DEBUG...) 把子进程挂到当前线程的调试会话里。
  2. WaitForDebugEvent 从这个会话里收事件。
  3. DebugEvent.dwProcessId/dwThreadId 告诉你是哪条线程/进程出的事。
  4. ReadProcessMemory / WriteProcessMemory / GetThreadContext / SetThreadContext 再去操作这个子进程。 (Microsoft Learn)

你要是愿意,我下一条可以把“这题第一次 WaitForDebugEvent 通常会收到哪些事件,顺序大概是什么”也按时间线给你列出来。

Pergunta

意思是 CreateProcessA(Filename, 0, 0, 0, 0, 3u, 0, 0, &StartupInfo, &ProcessInformation)中的参数“3”表明了这个被调试的子进程只能被创建它的父进程调试,是吗

Pensamento

undefined

Pensamento

Thought for 14s

Resposta

可以这么理解,但要改得更准确一点:

不是“因为它是父进程,所以它能调试子进程”。
而是:调用 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)

但它不等于“这个子进程永远只能被它的父进程调试”。更准确的说法是:

  1. 创建它的那个调用方,立刻成为它当前的调试器。
  2. 同一时刻,一个进程不会同时挂两个用户态调试器。 所以当父进程已经通过 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)

放回你这题里,逻辑就是:

c
CreateProcessA(Filename, ..., 3u, ..., &ProcessInformation); WaitForDebugEvent(&DebugEvent, INFINITE);

这两句连起来的意思不是“随便等一个进程的事件”,而是:

“把刚创建的这个新进程作为 debuggee 启动,然后由当前线程从自己的调试事件队列里接收它的事件。” (Microsoft Learn)

你要的话,我可以下一条专门给你讲清楚 DEBUG_PROCESSDEBUG_ONLY_THIS_PROCESS 单独用、一起用,各自差别到底是什么

Pergunta

image.png

我用IDA动态调试,更改了第一次isdebugpresenr的返回值,创建进程后确实显示了两个进程(顺便问一下thread不是线程的意思吗),接下来我该怎么调试,我从来没有试过同时调试两个进程,而且创建了子进程之后不是应该另开一个命令行窗口吗,而且我现在也没看到要我输入flag的提示

Pensamento

Exploring ways to debug child process in IDA

It's easier to use x64dbg/OllyDbg with breakpoints on CreateProcessA and WaitForDebugEvent. After child creation, attaching might not be possible because the parent holds the debug port, so debugging the parent could be the way to go. In IDA, the threads window shows the current process threads in decimal/hex formats.

Thought for 1m 1s

Resposta

你这个截图里显示的不是两个进程,而是两个 thread
thread 确实就是“线程”的意思。IDA 这个窗口标题都写了 Threads,所以你现在看到的是当前被 IDA 附着的那个进程里的线程列表,不是父子进程列表。

也就是说:

  • 你现在大概率还在调 父进程
  • 父进程里出现了两个线程
  • 不等于你已经在同时调两个进程

你现在这个题,正确思路其实不是“同时手动调父子两个进程”,而是:

只调父进程,让父进程去调子进程。
因为这题本来就是“父进程充当调试器,子进程是真正被调试对象”。


先回答你两个现象

1. 为什么没弹新的命令行窗口

因为这里:

c
CreateProcessA(Filename, 0, 0, 0, 0, 3u, 0, 0, &StartupInfo, &ProcessInformation)

只传了 DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS没有CREATE_NEW_CONSOLE

所以子进程默认不会强制新开一个控制台窗口。常见情况是:

  • 继承父进程原来的控制台
  • 或者在调试流程里根本还没跑到显示输入提示那一步

2. 为什么没看到输入 flag 的提示

因为子进程虽然被创建了,但它一出生就被父进程接管成 debuggee。
它后续每遇到关键 INT3,都要等父进程 WaitForDebugEvent -> ContinueDebugEvent 放行。

所以只要父进程没把前面的调试事件正确处理完,子进程就不会顺利跑到输入 flag 的地方

还有一个很重要的坑:

你说你“改了第一次 IsDebuggerPresent 的返回值”。
这里一定要注意你改的是父进程这一次执行,不要把整个 API 全局改死。

因为这题的正常逻辑是:

  • 父进程IsDebuggerPresent() 应该是 false,这样才会去 CreateProcess
  • 子进程IsDebuggerPresent() 应该是 true,这样才会走 sub_DD3922()

如果你把子进程那次也改成 false,它可能会继续再创建新副本,逻辑就乱了。


你接下来怎么调,最稳的是这两条路


路线 A:继续调父进程,看它怎么修补子进程

这是最贴题的办法。

你已经知道父进程核心循环在这里:

c
WaitForDebugEvent(&DebugEvent, 0xFFFFFFFF);

它收到的是子进程的调试事件。你接下来要重点盯这些点:

  • CreateProcessA
  • WaitForDebugEvent
  • ContinueDebugEvent
  • ReadProcessMemory
  • WriteProcessMemory
  • GetThreadContext
  • SetThreadContext

你在 IDA 里具体看什么

每次父进程从 WaitForDebugEvent 返回后,看:

c
DebugEvent.dwDebugEventCode DebugEvent.u.Exception.ExceptionRecord.ExceptionCode DebugEvent.u.Exception.ExceptionRecord.ExceptionAddress

如果是:

c
ExceptionCode == 0x80000003

那就是子进程 hit 到了 INT3 断点。

然后这段代码会:

  1. 读取子进程断点地址附近的字节到 Buffer
  2. 统计后面有多少个 0x90(NOP)
  3. 根据长度进不同分支

你已经贴出来了几个关键分支:

  • case 4:改线程上下文,++Eip
  • case 7:把 v16 写回子进程
  • case 30:把 v15 写回子进程

这说明作者在子进程里埋了几段:

  • INT3
  • 后面跟很多 NOP

父进程碰到不同长度的 INT3+NOP 占位块,就往里补真正代码。

你真正该盯的是这两次 WriteProcessMemory

尤其是:

c
WriteProcessMemory(ProcessInformation.hProcess, lpBaseAddress, v16, 7u, ...) WriteProcessMemory(ProcessInformation.hProcess, lpBaseAddress, v15, 0x1Eu, ...)

这两句就是在往子进程里写真代码

你可以直接把这两段字节记下来:

v16,7 字节

text
90 83 7D F8 18 7D 11

v15,30 字节

text
90 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 进制按无符号看就行。

这两段拿去反汇编,你就能看到父进程到底给子进程补了什么逻辑。


路线 B:不要硬追父子调试,直接改单进程分析

这是实战里更省事的办法。

思路是:

做法 1

把父进程自调试逻辑绕掉,不让它创建子进程;
然后把本来父进程要写进子进程的那两段 v15/v16,直接手工 patch 回原程序对应的 INT3+NOP 位置。

这样你就把:

  • “运行时动态补丁”
  • 变成了
  • “静态补丁后的正常程序”

然后直接单进程调试就行。

做法 2

更粗暴一点:

  • 把第一次 IsDebuggerPresent 的分支改成直接进入 sub_DD3922()
  • 再把几个 INT3+NOP 区域 patch 成真实代码
  • 这样整个程序就不需要父进程那套调试器逻辑了

这通常比“同时观察父子联动”轻松很多。


你现在在 IDA 里最该做的事

第一步

确认你现在断的是父进程,不是在看线程列表误以为是子进程列表。

第二步

在父进程里给下面几个 API 下断点:

text
WaitForDebugEvent WriteProcessMemory GetThreadContext SetThreadContext ContinueDebugEvent

第三步

一旦断在 WriteProcessMemory,记下:

  • lpBaseAddress:子进程里被修补的地址
  • 写入内容:v16v15
  • 写入长度:7 或 30

第四步

去子进程对应模块基址上,定位这些地址,看原来是不是:

  • CC 90 90 90...

如果是,就说明你已经抓到真正隐藏代码块了。

第五步

把这几段真实代码 patch 回 IDA 反汇编视图,之后就可以脱离自调试框架继续分析。


关于“同时调试两个进程”

这题里你通常不需要真的在 IDA 里同时手动切两个进程。

因为逻辑上已经是:

  • IDA 调父进程
  • 父进程调子进程

你再想让 IDA 直接附加子进程,往往会和父进程的调试权冲突。

所以正确姿势不是“双开一起单步”,而是:

在父进程里观察它如何操纵子进程。


一句最实用的建议

你现在先别等输入框,先专注抓这两个东西:

  1. WriteProcessMemory 往子进程写了哪几段真实代码
  2. SetThreadContext 到底把 EIP 怎么改了

把这两件事弄明白,这题基本就通了。

你要是愿意,我下一条可以直接教你:
怎么把 v15v16 这两段机器码在 IDA 里手工反汇编出来,并判断它们分别补到了什么逻辑里。

Pergunta

可是我想调试子进程

Pensamento

Providing debugging advice for child processes

The user’s asking about debugging a child process in a malware-like executable, specifically a CTF reversing challenge. The easiest approach is to prevent the parent process from debugging the child. Debugging child processes in IDA or x64dbg can be cumbersome, considering the parent already attaches the debugger. A helpful general step would be to break or patch the parent’s debug relationship.

Debugging child processes in reverse engineering

To debug the child process, you can patch the parent’s CreateProcess flags from 3 to 0, which creates a normal child process. However, this prevents the child from recognizing itself as being debugged. To address this, patch the child's IsDebuggerPresent check to return true, or simplify by running the child directly under your external debugger and forcing the desired behavior.

Thought for 1m 23s

Resposta

那就先说结论:

你没法在“父进程已经是子进程调试器”的前提下,再让 IDA 正常附加到这个子进程。
同一时刻一个用户态进程基本只能有一个调试器。现在这个位置上,子进程的调试器是它爹,不是 IDA。

所以你想“调试子进程”,有两条路:

最实用的路:把“子进程路径”单独拿出来调

这其实最接近你想要的效果。

你要做的是让程序直接走子进程那条分支,而不是先走父进程自调试。

具体做法

  1. 找到第一次 IsDebuggerPresent() 后面的分支。
  2. 把分支改成永远进入 sub_DD3922()。 也就是让程序一启动就按“子进程视角”执行。
  3. 不再让它进入下面这坨:
    • CreateProcessA
    • WaitForDebugEvent
    • ContinueDebugEvent

这样你用 IDA 调的虽然还是这个 exe,但执行路径已经等价于“原来的子进程”。

接下来怎么调

这时你会直接在自己的调试器里碰到那些 INT3 + NOP 占位块。
原本这些断点是交给父进程处理的,现在就改成你手工处理:

  • 遇到“长度 7”的占位块,补上 v16
  • 遇到“长度 30”的占位块,补上 v15
  • 遇到“长度 4”的那个点,效果相当于 EIP += 1

你前面贴的两段真代码字节就是:

v16

text
90 83 7D F8 18 7D 11

v15

text
90 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 个参数 dwCreationFlags3 改成 0

也就是把:

c
CreateProcessA(..., 3u, ...)

改成:

c
CreateProcessA(..., 0, ...)

这样创建出来的就只是普通子进程,不再是被父进程调试的子进程。

但这里有个大坑

如果你只改了这个,子进程启动后 IsDebuggerPresent() 会是 false,它就不会走 sub_DD3922(),而是又走回“创建新进程”的父进程逻辑。

所以你还得额外做一件事:

把子进程入口的第一次 IsDebuggerPresent 分支也改掉,让它直接进入 sub_DD3922()

所以完整步骤是

  1. 父进程里把 CreateProcessA 的 flag 从 3 改成 0
  2. 让子进程创建出来
  3. 记下 ProcessInformation.dwProcessId
  4. 立刻附加到这个 PID
  5. 在子进程入口把 IsDebuggerPresent 那个分支改成直接进 sub_DD3922()
  6. 后面继续像上面那样,手工处理 INT3+NOP 补丁点

为什么我更推荐第一条

因为第二条麻烦在:

  • 你得卡创建时机
  • 你得找 PID
  • 你得附加
  • 你还得处理子进程分支
  • 还得避免它继续套娃创建

而第一条本质上只是在说:

“我不要父进程这层壳了,我直接把子进程执行路径拿出来调。”

对这种自调试题,这通常是最稳的。


你现在立刻能做的

别再盯“怎么同时调父子”了,直接做这个:

  1. 把第一次 IsDebuggerPresent 后的跳转改到 sub_DD3922
  2. 重新在 IDA 里跑
  3. 一旦断在 INT3,看后面跟多少个 NOP
  4. 按 7 / 30 / 4 这三种情况手工补

这样你调到的就是你真正想看的那部分。

你要是愿意,我下一条可以直接按 IDA 操作步骤 写给你:
“在哪改跳转、断到 INT3 后怎么改内存、怎么手动把 EIP 加 1”。

Pergunta

问一下长度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啊

Pensamento

Explaining the loop and byte calculation

In this code, I see that the loop starts at i = 1 and counts consecutive nops (0x90 bytes) after the first byte. This explains why the user only counted 6 nops but the total is 7 bytes. The code looks for nops starting from i = 1, which leads to the 7-byte output.

Thought for 25s

Resposta

对,你看到的 6 个 nop 没错,但这里的 case 7 指的是整块占位区总长度是 7 字节,不是“7 个 nop”。

你这段是:

asm
int3 nop nop nop nop nop nop push offset aYouAreTooShort

也就是:

  • 第 1 个字节:int3 = 0xCC
  • 后面 6 个字节:6 个 nop = 0x90

合起来正好 7 字节

text
CC 90 90 90 90 90 90

所以它对应 case 7


关键就在这段循环:

c
for ( i = 1; i < 35 && (unsigned __int8)Buffer[i] == 0x90; ++i ) ;

它不是在数“有多少个 nop”,而是在做这个事:

  • Buffer[0] 默认就是断点位置那个 int3
  • Buffer[1] 开始往后扫连续的 nop
  • 一直扫到第一个不是 nop 的字节才停

所以如果布局是:

text
Buffer[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,是 nop
  • ...
  • i=6,是 nop
  • i=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 ...

所以:

  • int30xBD8AF8
  • nop0xBD8AF90xBD8AFE,共 6 个
  • 下一个正常指令 push0xBD8AFF

总占位范围就是:

text
0xBD8AF8 ~ 0xBD8AFE

一共 7 字节

所以父进程才会走:

c
case 7: WriteProcessMemory(..., lpBaseAddress, v16, 7u, ...)

也就是把这 7 字节整块替换掉。


同理你可以这样理解别的分支:

  • case 4
    表示占位块总长 4 字节
    也就是 1 个 int3 + 3 个 nop

  • case 30
    表示占位块总长 30 字节
    也就是 1 个 int3 + 29 个 nop


你这个观察其实非常关键,因为它说明:

作者用的是“int3 + 若干 nop 的总长度”来给不同补丁点编号。

也就是说:

  • 不是靠地址区分
  • 而是靠“断点后面跟了多少个 nop”区分是哪一类占位块

你现在这个 7 字节块,完全可以直接 patch 成 v16

text
90 83 7D F8 18 7D 11

也就是把原来的:

text
CC 90 90 90 90 90 90

替换掉。

你要的话,我下一条可以直接把这 7 字节在 x86 下反汇编成指令,告诉你它补进去的真实逻辑是什么。

Compartilhar esta Q&A