脱壳 花指令 异常处理 爆破

脱壳

拿到题目查壳发现是一个upx 看十六进制发现变成CAT了 修改回去发现还是脱壳不了 然后尝试手脱壳

由于是UPX使用ESP定律来手脱壳 在pushad之后下一个硬件断点

image

image

然后理想状态是运行到了popad之后 然后找大跳点 但是这次没有到理想状态

image

发现触发了异常 但是我们又不知道哪里触发了异常 我们要去找 一个地方 一个最常见的地方 TLS 回调 也就是作者最容易放暗桩的位置 TLS Callback 是 Windows 加载器在程序正式入口点之前自动执行的函数 也就是我们打开程序的流程是这样的

加载 EXE

执行 TLS Callback

执行程序入口点 EntryPoint

进入 main / WinMain

所以我们要在TLS那里下一个断点 也就是设置中的

image

下了之后断点断在了TLS开始的地方我们要往后运行查看有无什么操作

image

运行到这里发现进行了一个条件断点检测 因为下一个条件断点 就是把目标地址原来的第 1 个字节临时改成0xCC 所以我们挺多方法进行绕过的

我这里选择直接nop

image

然后就运行到了popad后面 发现下面就是一个相对于其他函数的大跳位置 也就是 402FC7 这个位置

我们进去然后dump出来就好了

image

dump出来有挺多的花指令

花指令

image

去除花指令的脚本 让AI写的 有点多 懒得写。。。。

import os
import json
import time

import ida_bytes
import ida_segment
import ida_kernwin
import ida_auto
import ida_idaapi
import ida_loader
import ida_nalt
import ida_name

try:
    import ida_diskio
except Exception:
    ida_diskio = None

BADADDR = ida_idaapi.BADADDR

# For this sample, keep the import-fixing .SCY segment untouched.
ALLOW_SEG_NAMES = {"CAT0", "CAT1"}
MAX_PASSES = 8


def b(ea):
    v = ida_bytes.get_wide_byte(ea)
    if v is None or v < 0:
        return None
    return v & 0xFF


def db(ea, n):
    out = []
    for i in range(n):
        x = b(ea + i)
        if x is None:
            return None
        out.append(x)
    return bytes(out)


def u32(ea):
    bs = db(ea, 4)
    if bs is None:
        return None
    return int.from_bytes(bs, "little", signed=False)


def s32(ea):
    bs = db(ea, 4)
    if bs is None:
        return None
    return int.from_bytes(bs, "little", signed=True)


def s8(x):
    return x - 0x100 if x >= 0x80 else x


def seg_ok(seg):
    if seg is None:
        return False
    name = ida_segment.get_segm_name(seg) or ""
    if name not in ALLOW_SEG_NAMES:
        return False
    if not (seg.perm & ida_segment.SEGPERM_EXEC):
        return False
    return True


def same_seg(a, c):
    sa = ida_segment.getseg(a)
    sc = ida_segment.getseg(c)
    return sa is not None and sc is not None and sa.start_ea == sc.start_ea


def junk_score(bs):
    if not bs:
        return 0
    bad_seq = [
        b"\x0f\x05",      # syscall in x86 user-mode junk
        b"\x9a",           # far call junk
        b"\x64\xe3",      # fs:jcxz split junk
        b"\x80\x83\x54", # fake ModRM block seen in this sample
        b"\x09\xa5\xd9", # fake fnstenv-style junk
        b"\xac\x6b",
        b"\xc0\xf9\x72",
        b"\x54\x09",
    ]
    sc = 0
    for q in bad_seq:
        if q in bs:
            sc += 3
    for x in bs:
        if x in (0x0F, 0x05, 0x9A, 0xF4, 0xD9, 0xAC, 0xC0, 0x64, 0xE3):
            sc += 1
    return sc


patches = []


def is_all_nop(ea, n):
    bs = db(ea, n)
    return bs is not None and all(x == 0x90 for x in bs)


def patch_nops(ea, n, typ, reason):
    if n <= 0:
        return False
    if is_all_nop(ea, n):
        return False
    old = db(ea, n)
    if old is None:
        return False
    for i in range(n):
        ida_bytes.patch_byte(ea + i, 0x90)
    patches.append({
        "type": typ,
        "ea": "0x%X" % ea,
        "size": n,
        "old": old.hex(" "),
        "new": " ".join(["90"] * n),
        "reason": reason,
    })
    return True


def try_call_addret(ea, end_ea):
    if b(ea) != 0xE8 or ea + 10 > end_ea:
        return False
    rel = s32(ea + 1)
    if rel is None:
        return False
    target = ea + 5 + rel
    if not (ea + 5 <= target <= min(ea + 0x40, end_ea - 5)):
        return False
    if not same_seg(ea, target):
        return False
    if db(target, 3) != b"\x83\x04\x24" or b(target + 4) != 0xC3:
        return False
    imm = b(target + 3)
    if imm is None:
        return False
    resume = ea + 5 + imm
    if not (target + 5 <= resume <= min(ea + 0x60, end_ea)):
        return False
    if imm > 0x40:
        return False
    return patch_nops(ea, resume - ea, "call_addesp_ret_skip", "call/add-[esp]/ret trampoline; resumes at 0x%X" % resume)


def try_short_jmp(ea, end_ea):
    if b(ea) != 0xEB or ea + 2 > end_ea:
        return False
    rel = s8(b(ea + 1))
    if rel <= 0:
        return False
    target = ea + 2 + rel
    if target > end_ea:
        return False
    skipped = db(ea + 2, rel)
    if skipped is None:
        return False
    if rel <= 8:
        return patch_nops(ea, 2 + rel, "short_jmp_over_junk", "EB +%d skips %s" % (rel, skipped.hex(" ")))
    if rel <= 0x20 and junk_score(skipped) >= 2:
        return patch_nops(ea, 2 + rel, "short_jmp_over_junk_scored", "EB +%d junk_score=%d" % (rel, junk_score(skipped)))
    return False


def try_cf_guard(ea, end_ea):
    x0, x1 = b(ea), b(ea + 1)
    if (x0, x1) not in ((0xF9, 0x72), (0xF8, 0x73)):  # stc;jb or clc;jae
        return False
    rel = b(ea + 2)
    if rel is None or rel <= 0 or rel > 0x20:
        return False
    target = ea + 3 + rel
    if target > end_ea:
        return False
    skipped = db(ea + 3, rel)
    if skipped is None:
        return False
    if rel <= 5 or junk_score(skipped) >= 2:
        typ = "stc_jb_always" if x0 == 0xF9 else "clc_jae_always"
        return patch_nops(ea, 3 + rel, typ, "constant carry branch skips %s" % skipped.hex(" "))
    return False


def clean():
    for pass_no in range(MAX_PASSES):
        before = len(patches)
        seg = ida_segment.get_first_seg()
        while seg:
            if seg_ok(seg):
                ea = seg.start_ea
                while ea < seg.end_ea:
                    if try_call_addret(ea, seg.end_ea):
                        ea += patches[-1]["size"]
                        continue
                    if try_short_jmp(ea, seg.end_ea):
                        ea += patches[-1]["size"]
                        continue
                    if try_cf_guard(ea, seg.end_ea):
                        ea += patches[-1]["size"]
                        continue
                    ea += 1
            seg = ida_segment.get_next_seg(seg.start_ea)
        if len(patches) == before:
            break


def annotate_known_routes():
    names = {
        0x401180: "hash_rolling_probe_4000",
        0x401210: "seh_filter_av_or_singlestep",
        0x4012D0: "rtl_continue_handler_patch_context",
        0x401300: "seh_filter_state_machine",
        0x4013F0: "maybe_install_rtl_continue_handler",
        0x401540: "anti_debug_hide_thread_wow64_probe",
        0x401650: "worker_check_thread",
        0x401790: "veh_exception_dispatcher",
        0x401BB0: "anti_debug_peb_beingdebugged_toggle",
        0x401C70: "validate_input_core",
        0x401F50: "main_prompt_and_check",
    }
    for ea, nm in names.items():
        try:
            ida_name.set_name(ea, nm, ida_name.SN_CHECK | ida_name.SN_FORCE)
        except Exception:
            pass
    comments = {
        0x4013F0: "Dynamically resolves ntdll!RtlAddVectoredContinueHandler and installs 0x4012D0 when command line condition passes.",
        0x401540: "Anti-debug helper: OpenThread/GetCurrentThreadId, IsWow64Process, NtSetInformationThread(ThreadHideFromDebugger=0x11).",
        0x401650: "Worker check route: IsDebuggerPresent index + int 0x2D + rolling hash blocks over global string at 0x40601C.",
        0x401790: "Unhandled/VEH exception dispatcher: handles breakpoint/single-step paths and edits CONTEXT to redirect control flow.",
        0x401C70: "Core validator: length 0x1E, prefix/suffix gates, printable range, then starts worker thread at 0x401650.",
        0x401F50: "Main route: print prompt, read std::cin line, call validate_input_core, print CORRECT/WRONG, system('pause').",
    }
    for ea, c in comments.items():
        try:
            ida_bytes.set_cmt(ea, c, 0)
        except Exception:
            pass


def write_log():
    inp = ida_nalt.get_input_file_path()
    base = os.path.splitext(inp)[0]
    out_json = base + ".ida_flower_patchlog.json"
    data = {
        "time": time.strftime("%Y-%m-%d %H:%M:%S"),
        "input": inp,
        "patched_total": len(patches),
        "patched_bytes_total": sum(p["size"] for p in patches),
        "patches": patches,
    }
    with open(out_json, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    print("[+] patchlog:", out_json)
    return out_json


def export_exe():
    inp = ida_nalt.get_input_file_path()
    base = os.path.splitext(inp)[0]
    out_exe = base + ".ida_flower_cleaned.exe"
    try:
        if ida_diskio is not None:
            qf = ida_diskio.qfopen(out_exe, "wb")
            ok = ida_loader.gen_file(ida_loader.OFILE_EXE, qf, 0, BADADDR, 0)
            ida_diskio.qfclose(qf)
        else:
            ok = ida_loader.gen_file(ida_loader.OFILE_EXE, out_exe, 0, BADADDR, 0)
        print("[+] exported:", out_exe, "ok=", ok)
    except Exception as e:
        print("[!] export failed, use File -> Produce file -> Create EXE file:", e)
        out_exe = None
    return out_exe


clean()
annotate_known_routes()
ida_auto.plan_and_wait(ida_idaapi.cvar.inf.min_ea, ida_idaapi.cvar.inf.max_ea)
log_path = write_log()
export_exe()
ida_kernwin.msg("[BugCat flower cleaner] patched %d sites, %d bytes. Log: %s\n" % (len(patches), sum(p["size"] for p in patches), log_path))

我简单看了看 一共三种 花指令

call $+...
add dword ptr [esp], imm
ret
; 垃圾字节
; 真实代码

stc
jb short xxx      ; 永真跳转

clc
jae short xxx     ; 永真跳转

jmp short +1/+2/+3 ; 跳过垃圾字节

主程序 进行了一个反调试检测 一个VEH异常处理

int __cdecl main(int argc, const char **argv, const char **envp)
{
  unsigned __int8 v3; // al
  int v5; // [esp+20h] [ebp-28h]
  int v6; // [esp+24h] [ebp-24h]
  int v7; // [esp+28h] [ebp-20h]
  int v8; // [esp+2Ch] [ebp-1Ch]
  int v9; // [esp+30h] [ebp-18h]
  int v10; // [esp+34h] [ebp-14h]
  int v11; // [esp+44h] [ebp-4h]

  NtSetInformationThread((HANDLE)0xFFFFFFFE, ThreadHideFromDebugger, nullptr, 0);
  if ( !NtSetInformationThread((HANDLE)0xFFFFFFFE, ThreadHideFromDebugger, (PVOID)4, 4u) )
    exit(1);
  AddVectoredExceptionHandler(0, Handler);
  v5 = 0;
  v9 = 0;
  v10 = 15;
  v11 = 0;
  sub_402360(std::cout, aCapooWantsASpe);
  v3 = std::ios::widen(std::cin + *(_DWORD *)(std::cin + 4), 10);
  sub_402890(v3);
  if ( (unsigned __int8)sub_401C70(nullptr, v6, v7, v8, 0, 15) )
    sub_402360(std::cout, dword_404268);
  else
    sub_402360(std::cout, dword_40427C);
  return system(Command);
}

sub_401C70 最上面就是一个长度判断 和一个 flag包裹判断

char __cdecl sub_401C70(_DWORD *Block, int a2, int a3, int a4, unsigned int a5, unsigned int a6)
{
  size_t v6; // ecx
  _DWORD **p_Block; // eax
  void *v8; // eax
  void **v9; // ecx
  void **v10; // esi
  char result; // al
  char *v12; // ecx
  __int128 v13; // [esp+10h] [ebp-3Ch] BYREF
  __int64 v14; // [esp+20h] [ebp-2Ch]
  void **v15; // [esp+28h] [ebp-24h]
  void **v16; // [esp+2Ch] [ebp-20h]
  char v17; // [esp+33h] [ebp-19h]
  HANDLE ThreadHandle; // [esp+34h] [ebp-18h] BYREF
  DWORD ExitCode[5]; // [esp+38h] [ebp-14h] BYREF

  ExitCode[4] = 0;
  if ( a5 == 30 && (unsigned __int8)sub_4027A0(v16) && (unsigned __int8)sub_402800(v16) )
  {
    LODWORD(v13) = 0;
    v14 = 0xF00000000LL;
    if ( a5 < 5 )
      sub_402350();
    v6 = 25;
    if ( a5 - 5 < 0x19 )
      v6 = a5 - 5;
    p_Block = &Block;
    if ( a6 >= 0x10 )
      p_Block = (_DWORD **)Block;
    sub_402210(&v13, (char *)p_Block + 5, v6);
    if ( HIDWORD(qword_40602C) >= 0x10 )
    {
      v8 = ::Block;
      if ( (unsigned int)(HIDWORD(qword_40602C) + 1) >= 0x1000 )
      {
        if ( (unsigned int)::Block - *((_DWORD *)::Block - 1) - 4 > 0x1F )
          goto LABEL_27;
        v8 = *((void **)::Block - 1);
      }
      sub_402D4F(v8);
    }
    *(__int128 *)&::Block = v13;
    qword_40602C = v14;
    v9 = &::Block;
    v10 = &::Block;
    if ( HIDWORD(v14) >= 0x10 )
    {
      v10 = (void **)::Block;
      v9 = (void **)::Block;
    }
    v15 = v10;
    v16 = (void **)((char *)v9 + v14);
    if ( v10 == (void **)((char *)v9 + v14) )
    {
LABEL_19:
      ThreadHandle = nullptr;
      ExitCode[0] = dword_406018;
      NtCreateThreadEx(&ThreadHandle, 0x1FFFFF, 0, -1, sub_401650, 6, 6, 0, 0, 0, 0);
      NtSetInformationThread(ThreadHandle, ThreadHideFromDebugger, nullptr, 0);
      if ( !WaitForSingleObject(ThreadHandle, 0xFFFFFFFF) )
      {
        if ( GetExitCodeThread(ThreadHandle, ExitCode) )
        {
          ExitCode[0] = LOBYTE(ExitCode[0]);
          if ( LOBYTE(ExitCode[0]) == 1 )
          {
            result = 1;
            goto LABEL_24;
          }
        }
      }
    }
    else
    {
      while ( 1 )
      {
        v17 = *(_BYTE *)v10;
        if ( (unsigned __int8)(v17 - 33) > 0x5Du )
          break;
        v10 = (void **)((char *)v15 + 1);
        v15 = v10;
        if ( v10 == v16 )
          goto LABEL_19;
      }
    }
  }
  result = 0;
LABEL_24:
  v17 = result;
  if ( a6 >= 0x10 )
  {
    v12 = (char *)Block;
    if ( a6 + 1 >= 0x1000 )
    {
      v12 = (char *)*(Block - 1);
      if ( (unsigned int)((char *)Block - v12 - 4) > 0x1F )
LABEL_27:
        invalid_parameter_noinfo_noreturn();
    }
    sub_402D4F(v12);
    return v17;
  }
  return result;
}

也就是flag长这样 同时flag也要在33-126这个范围内

flag{xxxxxxxxxxxxxxxxxxxxxxxx}

然后我们从下往上看 能看到有一个 创造隐藏线程的函数

NtCreateThreadEx(&ThreadHandle, 0x1FFFFF, 0, -1, sub_401650, 6, 6, 0, 0, 0, 0);
      NtSetInformationThread(ThreadHandle, ThreadHideFromDebugger, nullptr, 0);

线程函数是 sub_401650

char __cdecl sub_401650(int a1)
{
  int v1; // esi
  int v2; // ebx
  void **v3; // ecx
  void **v4; // ecx
  void **v5; // edi
  void **i; // eax
  char v7; // cl
  _OWORD v9[4]; // [esp+10h] [ebp-64h]
  int v10; // [esp+50h] [ebp-24h]
  int v11; // [esp+54h] [ebp-20h]
  void **v12; // [esp+58h] [ebp-1Ch]
  CPPEH_RECORD ms_exc; // [esp+5Ch] [ebp-18h]

  ms_exc.registration.TryLevel = 0;
  v9[0] = xmmword_4042B0;
  v9[1] = xmmword_4042E0;
  v9[2] = xmmword_4042D0;
  v9[3] = xmmword_4042C0;
  v1 = 0;
  v2 = 0;
  v10 = 0;
  v11 = IsDebuggerPresent();
  __asm { int     2Dh; Windows NT - debugging services: eax = type }
  v3 = &Block;
  if ( HIDWORD(qword_40602C) >= 0x10 )
    v3 = (void **)Block;
  v12 = v3;
  v4 = &Block;
  if ( HIDWORD(qword_40602C) >= 0x10 )
    v4 = (void **)Block;
  v5 = (void **)((char *)v4 + qword_40602C);
  for ( i = v12; ; v12 = i )
  {
    while ( 1 )
    {
      if ( i == v5 )
      {
        ms_exc.registration.TryLevel = -2;
        return 1;
      }
      v7 = *(_BYTE *)i;
      v10 = ++v2;
      if ( v2 == a1 )
        break;
      v1 = v7 + 31 * v1;
      i = (void **)((char *)v12 + 1);
      v12 = (void **)((char *)v12 + 1);
    }
    if ( (dword_406018 ^ v1) != *((_DWORD *)v9 + v11) )
      break;
    ++v11;
    v2 = 0;
    v10 = 0;
    v1 = 0;
    i = (void **)((char *)i + 1);
  }
  return 0;
}

发现有一个会触发异常 也就是说会触发异常码0x80000003的函数 正正好好我们下面就是一个判断

__asm { int     2Dh; Windows NT - debugging services: eax = type }

这里不能直接相信 sub_401650 的伪代码 因为 IsDebuggerPresent 后面有一个 int 2D 这个指令会主动触发异常 而程序前面已经注册了 VEH 异常处理函数 所以这里不是程序崩溃 而是故意进入异常处理逻辑

LONG __stdcall Handler(struct _EXCEPTION_POINTERS *ExceptionInfo)
{
  if ( ExceptionInfo->ExceptionRecord->ExceptionCode == 0x80000003 )
  {
    if ( byte_406420 )
      JUMPOUT(0);
    *(_DWORD *)(ExceptionInfo->ContextRecord->Ebp - 32) *= 2;
    ExceptionInfo->ContextRecord->Dr0 = (DWORD)&loc_4016FC;
    ExceptionInfo->ContextRecord->Dr1 = (DWORD)&loc_401706;
    ExceptionInfo->ContextRecord->Dr2 = (DWORD)&loc_401711;
    ExceptionInfo->ContextRecord->Dr3 = (DWORD)&loc_401726;
    ExceptionInfo->ContextRecord->Dr7 = 85;
    ++ExceptionInfo->ContextRecord->Eip;
    byte_406420 = 1;
    sub_401540();
    return -1;
  }
  else
  {
    if ( ExceptionInfo->ExceptionRecord->ExceptionCode == -2147483644 )
    {
      switch ( ExceptionInfo->ContextRecord->Dr6 & 0xF )
      {
        case 1u:
          ExceptionInfo->ContextRecord->Ecx += 33;
          ExceptionInfo->ContextRecord->Eip += 4;
          break;
        case 2u:
          ++ExceptionInfo->ContextRecord->Ebx;
          ++ExceptionInfo->ContextRecord->Eip;
          break;
        case 4u:
          ExceptionInfo->ContextRecord->Esi = 53 * ExceptionInfo->ContextRecord->Eax;
          ExceptionInfo->ContextRecord->Eip += 5;
          break;
        case 8u:
          ExceptionInfo->ContextRecord->Ecx = *(_DWORD *)(ExceptionInfo->ContextRecord->Ebp - 32);
          ExceptionInfo->ContextRecord->Esi ^= dword_406018;
          ExceptionInfo->ContextRecord->Eip += 3;
          break;
        default:
          return 0;
      }
    }
    return 0;
  }
}

第一次触发的是 0x80000003 异常 进入 Handler 后 程序设置了四个硬件断点

ExceptionInfo->ContextRecord->Dr0 = (DWORD)&loc_4016FC;
ExceptionInfo->ContextRecord->Dr1 = (DWORD)&loc_401706;
ExceptionInfo->ContextRecord->Dr2 = (DWORD)&loc_401711;
ExceptionInfo->ContextRecord->Dr3 = (DWORD)&loc_401726;
ExceptionInfo->ContextRecord->Dr7 = 85;

Dr0Dr3 是硬件断点地址寄存器 Dr7 = 85 用来启用这些硬件断点

然后 Handler 又修改了 EIP

++ExceptionInfo->ContextRecord->Eip;

这样程序就会跳过 int 2D 继续执行后面的校验逻辑

所以真实执行路线是

进入 sub_401650
↓
执行 IsDebuggerPresent
↓
执行 int 2D
↓
触发 0x80000003 异常
↓
进入 Handler
↓
设置 Dr0 Dr1 Dr2 Dr3
↓
设置 Dr7 启用硬件断点
↓
修改 EIP 跳过 int 2D
↓
返回 sub_401650 继续执行

后面执行到 loc_4016FC loc_401706 loc_401711 loc_401726 这些地址时 会再次触发硬件断点异常

第二次触发的是 0x80000004 单步异常 Handler 通过 Dr6 &amp; 0xF 判断当前命中了哪个硬件断点

switch ( ExceptionInfo->ContextRecord->Dr6 & 0xF )
{
    case 1:
        ExceptionInfo->ContextRecord->Ecx += 33;
        ExceptionInfo->ContextRecord->Eip += 4;
        break;

    case 2:
        ++ExceptionInfo->ContextRecord->Ebx;
        ++ExceptionInfo->ContextRecord->Eip;
        break;

    case 4:
        ExceptionInfo->ContextRecord->Esi = 53 * ExceptionInfo->ContextRecord->Eax;
        ExceptionInfo->ContextRecord->Eip += 5;
        break;

    case 8:
        ExceptionInfo->ContextRecord->Ecx = *(_DWORD *)(ExceptionInfo->ContextRecord->Ebp - 32);
        ExceptionInfo->ContextRecord->Esi ^= dword_406018;
        ExceptionInfo->ContextRecord->Eip += 3;
        break;
}

这里四个 case 分别对应四个硬件断点

case 1 对应 Dr0 作用是把 ECX 加上 33 也就是加上 0x21

case 2 对应 Dr1 作用是把 EBX 加 1 这里的 EBX 是循环计数器

case 4 对应 Dr2 作用是把 ESI 改成 53 * EAX 这里的 ESI 是 hash 累加变量

case 8 对应 Dr3 作用是把 ESIdword_406018 异或 这是每组 hash 结束后的异或操作

所以静态伪代码里看到的

v1 = v7 + 31 * v1;

并不是真实完整逻辑

因为真实执行过程中 VEH 会通过硬件断点不断修改 ECX EBX ESI EIP

也就是说 这里的真实逻辑不是单纯由普通汇编决定的 而是由普通指令加异常处理器一起决定的

结合这些寄存器变化 可以把每组 hash 逻辑还原成

h = 0;
h = h * 257 + ch0;
h = h * 257 + ch1;
h = h * 257 + ch2;
h = h * 257 + ch3;
h ^= dword_406018;

其中 dword_406018 的值是

0x052251FF

也就是每 4 个字符为一组 先按照 257 做滚动 hash 最后再异或 seed

前面的 sub_401C70 已经判断了输入长度 包裹格式 以及字符范围

if ( a5 == 30 && sub_4027A0(v16) && sub_402800(v16) )

后面又把输入从偏移 5 的位置开始复制出来

sub_402210(&v13, (char *)p_Block + 5, v6);

所以真正参与校验的是去掉前面包裹之后的主体内容

再看字符范围判断

if ( (unsigned __int8)(v17 - 33) > 0x5Du )
    break;

这里限制字符范围是 0x210x7E

主体内容一共 24 字节 目标 hash 一共有 6 个 所以刚好可以分成 6 组 每组 4 字节

24 / 6 = 4

目标 hash 表来自 sub_401650 开头加载的几个 xmmword

v9[0] = xmmword_4042B0;
v9[1] = xmmword_4042E0;
v9[2] = xmmword_4042D0;
v9[3] = xmmword_4042C0;

这里不能直接按照地址从小到大取 要按照程序实际加载顺序取

最后得到 6 个目标 hash

hs = [
    0x66BF1BAC,
    0x473AC6FC,
    0x4433C0D0,
    0x289B6CEF,
    0x71A8B6EC,
    0x53775C73,
]

一开始可以用四层循环枚举每组 4 个字符 但是这里其实可以直接反推

因为每组 hash 的公式是

h = (((a * 257 + b) * 257 + c) * 257 + d) ^ 0x052251FF

先把异或还原掉

x = h ^ 0x052251FF

那么就有

x = ((a * 257 + b) * 257 + c) * 257 + d

这个形式可以看成 257 进制展开

因为每个字符都小于 257 所以可以直接用取模和整除把 4 个字符反推出来

反推顺序是从最后一个字符开始

d = x % 257
x //= 257

c = x % 257
x //= 257

b = x % 257
x //= 257

a = x % 257

最后得到的顺序是 a b c d 我看他们的wp都是写了一个爆破的exp 我在想这样就不需要爆破 94^4 了 每个 hash 都可以直接反推出对应的 4 个字符 最后把 6 组结果按顺序拼接 再按照题目要求补上外层包裹即可 同时程序里还有一个 SEH 异常处理函数 sub_401300 它同样处理 0x80000004 单步异常 会根据 Dr6 判断命中的硬件断点并修改 EAX EBX 等寄存器 其中 sub_401180 会计算程序前 0x4000 字节的滚动 hash 更像是代码完整性校验和反调试干扰 不是最终 flag hash 表的来源 所以解题时只需要知道它们会影响异常路线 不需要把它当成主加密逻辑

exp

hs = [0x66BF1BAC, 0x473AC6FC, 0x4433C0D0, 0x289B6CEF, 0x71A8B6EC, 0x53775C73]
seed = 0x052251FF

out = []

for h in hs:
    x = h ^ seed
    d = x % 257
    x //= 257
    c = x % 257
    x //= 257
    b = x % 257
    x //= 257
    a = x % 257

    s = bytes([a, b, c, d])
    out.append(s)

print(b"".join(out))

flag

CatCTF{bu9CAT+C@P0o-1S_s0-CUTe~}