-
-
[原创]2026 腾讯游戏安全技术竞赛 PC客户端安全 初赛 ShadowGate Writeup
-
发表于: 6小时前 195
-
从2022年开始打,到今年应该算最后一次打了。这么多年下来感觉今年算是初赛最ex的一年了,感觉是为了反AI把junk code拉满了)
之前也从来没发过帖,不过今年这么ex还是发一下,正好也算最后一次打了纪念一下(
2026腾讯游戏安全技术竞赛 PC客户端安全 初赛 ShadowGate Writeup
驱动的加载与通信
驱动是没有签名的,因此首先打开测试模式并关闭Windows驱动程序强制签名。
接下来利用App中提供的指令机型sc create创建内核服务即可,创建完毕后需要使用sc start启动服务
用代码来进行加载和启动如下:
namespace constants {
// ...
constexpr auto kDriverFilename = L"ShadowGateSys.sys";
constexpr auto kDriverServiceName = L"ShadowGate";
constexpr auto kDriverDisplayName = kDriverServiceName;
constexpr auto kDriverDeviceName = LR"(\\.\ShadowGate)";
// ...
} // namespace constants
// ...
void DriverObject::StartDriver() {
using unique_sc_handle = std::unique_ptr<std::remove_pointer_t<SC_HANDLE>, decltype(&::CloseServiceHandle)>;
unique_sc_handle manager{::OpenSCManagerW(nullptr, nullptr, SC_MANAGER_ALL_ACCESS), &::CloseServiceHandle};
if (manager == nullptr) {
return;
}
const auto driver_path = std::filesystem::current_path() / constants::kDriverFilename;
unique_sc_handle service{
::CreateServiceW(manager.get(), constants::kDriverServiceName, constants::kDriverDisplayName, SERVICE_START,
SERVICE_KERNEL_DRIVER, SERVICE_DEMAND_START, SERVICE_ERROR_IGNORE, driver_path.wstring().c_str(),
nullptr, nullptr, nullptr, nullptr, nullptr),
&::CloseServiceHandle};
if (service == nullptr) {
DWORD last_error = ::GetLastError();
if (last_error != ERROR_IO_PENDING && last_error != ERROR_SERVICE_EXISTS) {
return;
}
service.reset(::OpenServiceW(manager.get(), constants::kDriverServiceName, SERVICE_START));
if (service == nullptr) {
return;
}
}
if (!::StartServiceW(service.get(), 0, nullptr)) {
DWORD last_error = ::GetLastError();
if (last_error != ERROR_SERVICE_ALREADY_RUNNING) {
return;
}
}
driver_.reset(::CreateFileW(constants::kDriverDeviceName, GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL, nullptr));
}
在成功加载驱动之后再运行App即可看到如下交互界面:

本题目没有设置反调试的相关陷阱,因此虽然有大量的加固代码但可以容易的使用调试器来进行关键行为验证。
大部分的加固代码都可以通过临时patch掉加固的call函数和修补push/ret对为jmp来使得ida输出较为正常的结果。
其中patch掉加固的函数不影响主要逻辑,被patch掉的部分之后再进行专门的分析。
从R3侧App的入口点函数出发:

观察可以发现其主要逻辑是循环使用getch来读取用户的输入,并且使用push/retn以及查找表来实现jmp到对应的handler的目的,实际上就是一个switch。
还原成jmp后再反编译即可得到清晰的逻辑:
int __fastcall main_0(int argc, const char **argv, const char **envp)
{
// [COLLAPSED LOCAL DECLARATIONS. PRESS NUMPAD "+" TO EXPAND]
Show_Info();
printf("[*] Connecting to Shadow gate driver...\n");
hObject = Open_Driver_Handle();
if ( hObject == (HANDLE)-1LL )
{
printf("[!] Cannot continue without driver.\n");
printf("[*] Press any key to exit...\n");
getch();
return 1;
}
printf("[+] Gate module online.\n\n");
Init_Stuffs();
lpOutBuffer_ = 0;
v19 = 0;
if ( DeviceIO_FetchMaze(v4, &lpOutBuffer_) )
printf(
"[*] Maze grid: %ux%u, Entry=(%u,%u), Exit=(%u,%u)\n",
(_DWORD)lpOutBuffer_,
DWORD1(lpOutBuffer_),
DWORD2(lpOutBuffer_),
HIDWORD(lpOutBuffer_),
v19,
HIDWORD(v19));
Show_Help();
memset(myrecord, 0, sizeof(myrecord));
mycount = 0;
v6 = 0;
while ( 2 )
{
printf("[op #%d] > ", mycount);
_AL_1 = getch();
printf("%c\n", _AL_1);
_AL_1 -= 0x1B;
_AL = _AL_1;
_CL = (unsigned __int8)&myrecord[96] + 11;
switch ( _AL_1 )
{
case 0u:
case 0x36u:
case 0x56u:
goto LABEL_27;
case 0x24u:
case 0x2Du:
case 0x4Du:
Show_Help();
continue;
case 0x26u:
case 0x2Fu:
case 0x46u:
case 0x4Fu:
move = 0x30;
n76 = 'L';
goto LABEL_11;
case 0x29u:
case 0x31u:
case 0x49u:
case 0x51u:
move = 0x40;
__asm { rcl al, cl }
n76 = 'R';
goto LABEL_11;
case 0x2Eu:
case 0x3Cu:
case 0x4Eu:
case 0x5Cu:
move = 0x10;
n76 = 'U';
goto LABEL_11;
case 0x30u:
case 0x38u:
case 0x50u:
case 0x58u:
move = 0x20;
n76 = 'D';
LABEL_11:
if ( mycount < 255 )
{
myrecord[v6] = n76;
++mycount;
++v6;
if ( (unsigned int)mycount >= 0x100 )
_report_securityfailure_();
myrecord[v6] = 0;
}
current_step = CurrentStep;
memset(msg, 0, sizeof(msg));
msglen_ = 0;
++CurrentStep;
v14 = DeviceIO_DoMove(hObject, *(__int64 *)&move, current_step, msg, &msglen_);
if ( v14 != 1 )
{
if ( v14 == -1 )
{
LastError = GetLastError();
printf("[ERROR] DeviceIoControl failed: %lu\n", LastError);
}
continue;
}
printf(L"\n");
printf("=============================================\n");
printf(" ACCESS GRANTED - Credential extracted!\n");
printf("=============================================\n");
if ( msglen_ )
{
printf("[CREDENTIAL] %s\n", (const char *)msg);
}
else
{
printf("[*] You reached the exit, but the credential could not be decrypted.\n");
printf("[*] Hint: Only the shortest path unlocks the credential.\n");
}
printf("[*] Your input (%d ops sent): %s\n", mycount, myrecord);
printf("[*] Note: blocked ops are included above; the driver only counts successful ones.\n");
printf(L"\n");
memset(msg, 0, sizeof(msg));
printf("[*] Press any key to exit...\n");
getch();
LABEL_27:
Exit_Program();
if ( hObject != (HANDLE)-1LL )
CloseHandle(hObject);
printf("[*] Mission aborted. ACE HQ standing by.\n");
return 0;
case 0x37u:
case 0x57u:
DeviceIO_Reset();
mycount = 0;
CurrentStep = 0;
myrecord[0] = 0;
v6 = 0;
printf("[*] Reset to entry point.\n");
continue;
case 0x39u:
case 0x59u:
printf("[*] Operations sent: %d\n", mycount);
sequence = myrecord;
if ( mycount <= 0 )
sequence = "(none)";
printf("[*] Sequence: %s\n", sequence);
continue;
default:
printf("[?] Unknown command. 'h' for help.\n");
continue;
}
}
}
可以发现在正常流程中与驱动的交互都是通过DeviceIoControl函数实现的。

将三处交互可以代码化如下:
namespace constants {
// ...
constexpr auto kIOControlCodeDoMove = 0x80012004;
constexpr auto kIOControlCodeResetMazeState = 0x80012008;
constexpr auto kIOControlCodeFetchMazeInfo = 0x8001200C;
// ...
} // namespace constants
// ...
// WASD code - 0x10 0x20 0x30 0x40
bool DriverObject::Move(uint8_t code) {
struct {
int32_t move;
int32_t step;
int32_t magic;
} request;
static_assert(sizeof(request) == 12, "Move expects MoveRequest to be a 12-byte structure");
request.move = static_cast<uint8_t>((code ^ 0xFA) << 3 | (code ^ 0x40) >> 5);
request.step = step_++;
request.magic = request.move ^ request.step ^ constants::kMoveMagic;
struct {
uint32_t seed;
uint8_t randoms[56];
uint32_t magic;
char message[64];
uint32_t msg_length;
} response;
static_assert(sizeof(response) == 132, "Move expects MoveResponse to be a 132-byte structure");
DWORD bytes_returned = 0;
if (::DeviceIoControl(driver_.get(), constants::kIOControlCodeDoMove, &request, sizeof(request), &response,
sizeof(response), &bytes_returned, nullptr)) {
if (bytes_returned == sizeof(response)) {
if (response.magic == 'WIN!') {
if (response.msg_length > 63) {
win_message_ = "";
} else {
response.message[response.msg_length] = 0;
win_message_ = response.message;
}
}
return true;
}
}
return false;
}
bool DriverObject::FetchMazeInfo() {
struct {
int32_t width;
int32_t height;
int32_t start_x;
int32_t start_y;
int32_t end_x;
int32_t end_y;
} info;
static_assert(sizeof(info) == 24, "FetchMazeInfo expects to be a 24-byte structure");
DWORD bytes_returned = 0;
if (::DeviceIoControl(driver_.get(), constants::kIOControlCodeFetchMazeInfo, nullptr, 0, &info, sizeof(info),
&bytes_returned, nullptr)) {
maze_width_ = info.width;
maze_height_ = info.height;
start_x_ = info.start_x;
start_y_ = info.start_y;
end_x_ = info.end_x;
end_y_ = info.end_y;
return bytes_returned == sizeof(info);
}
return false;
}
bool DriverObject::ResetMaze() {
DWORD bytes_returned = 0;
if (::DeviceIoControl(driver_.get(), constants::kIOControlCodeResetMazeState, nullptr, 0, nullptr, 0, &bytes_returned,
nullptr)) {
return true;
}
return false;
}
接下来可以在R0驱动侧验证上面的分析是否正确,还是先从驱动入口点出发,可以看到其主要逻辑如下:

注册相关的部分可以用来还原结构体的部分信息,主要还是看IRP_MJ_DEVICE_CONTROL对应的处理:
同样只看其主要逻辑:
NTSTATUS __fastcall DispatchDeviceControlImpl(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
// [COLLAPSED LOCAL DECLARATIONS. PRESS NUMPAD "+" TO EXPAND]
v5 = __ROR2__(v4, 1);
LOBYTE(v5) = v2 - v5;
CurrentStackLocation = Irp->Tail.Overlay.CurrentStackLocation;
_DI = v5 - v3;
__asm { rcr di, 0AFh }
Status_1 = 0;
n132 = 0;
LowPart = CurrentStackLocation->Parameters.Read.ByteOffset.LowPart;
Options = CurrentStackLocation->Parameters.Create.Options;
Length = CurrentStackLocation->Parameters.Read.Length;
output = (Opt3Output *)Irp->AssociatedIrp.SystemBuffer;
if ( ::GlobalData )
{
switch ( LowPart )
{
case 0x80012004:
if ( output && Options >= 0xC && Length >= 0x84 )
{
Valid_Opt = *(_QWORD *)&output->a; // Valid Opt
if ( HIDWORD(output->b) == ((unsigned __int8)Valid_Opt ^ HIDWORD(Valid_Opt) ^ 0xDEAD1337) )
{
NewIrql = KeAcquireSpinLockRaiseToDpc(&::GlobalData->SpinLock);
GlobalData = ::GlobalData;
++::GlobalData->StepCount;
KeReleaseSpinLock(&GlobalData->SpinLock, NewIrql);
n2 = n2_1;
memset(output, 0, 0x84u);
NewIrql_4 = KeAcquireSpinLockRaiseToDpc(&::GlobalData->SpinLock);
GlobalData_1 = ::GlobalData;
NewIrql_1 = NewIrql_4;
CurrentThreadId = PsGetCurrentThreadId();
p_SpinLock = &::GlobalData->SpinLock;
GlobalData_1->CurrentThreadId = CurrentThreadId;
KeReleaseSpinLock(p_SpinLock, NewIrql_1);
((void (__fastcall *)(GlobalPool *, _QWORD))loc_14040305A)(::GlobalData, n2);
if ( n2 == 2 )
{
spinlock = &::GlobalData->SpinLock;
output[2].c = 'WIN!';
NewIrql_5 = KeAcquireSpinLockRaiseToDpc(spinlock);
_DI = _DI_1;
__asm { rcl dil, 0BEh }
NewIrql_2 = NewIrql_5;
LOBYTE(_R8D) = _DI;
__asm { rcr r8d, 22h }
RecordLength = (unsigned int)::GlobalData->RecordLength;
if ( (unsigned int)RecordLength > 0xFF )
RecordLength = 255;
sub_140003600(&buf_, ::GlobalData->Record, (unsigned int)RecordLength);
_mm_lfence();
SpinLock_1 = &::GlobalData->SpinLock;
*((_BYTE *)&buf_ + RecordLength) = 0;
KeReleaseSpinLock(SpinLock_1, NewIrql_2);
v36 = 0;
sub_1403F7C6D((__int64)&buf_, (unsigned int)RecordLength, (__int64)&output[2].d, (__int64)&v36);
HIDWORD(output[5].b) = v36;
memset(&buf_, 0, 0x100u);
}
GenerateIOResultWithDelay((Challenge *)output);
}
else
{
memset(output, 0, 0x84u); // Invalid opt
GenerateIOResult((DeviceIOResult *)output);
}
n132 = 132;
goto LABEL_23;
}
break;
case 0x80012008:
ResetPoolVars(::GlobalData);
ResetGlobalVar2s();
n132 = 0;
goto LABEL_23;
case 0x8001200C:
if ( output && Length >= 0x18 )
{
NewIrql_3 = KeAcquireSpinLockRaiseToDpc(&::GlobalData->SpinLock);
output->c = 0;
output->a = 13;
output->b = 13;
SpinLock_2 = &::GlobalData->SpinLock;
output->d = 0xC0000000CLL;
KeReleaseSpinLock(SpinLock_2, NewIrql_3);
n132 = 24;
goto LABEL_23;
}
break;
default:
Status_1 = STATUS_INVALID_DEVICE_REQUEST;
LABEL_23:
Status = Status_1;
return IrpCompleteIoRequest(Irp, Status, n132);
}
Status_1 = STATUS_BUFFER_TOO_SMALL;
goto LABEL_23;
}
Status = STATUS_DEVICE_NOT_READY;
return IrpCompleteIoRequest(Irp, Status, n132);
}
与我们在用户侧的分析是一致的。
即题目的主要交互是通过R0-R3的正常IRP_MJ_DEVICE_CONTROL通信完成实现的。
系统的隐匿通信手段
除了正常的IRP_MJ_DEVICE_CONTROL通信之外,题目还存在许多隐蔽的通信手段,可以发现在应用测140001670初始化了许多Windows相关的Event和Semaphore,下面分析创建了哪些以及驱动侧是如何对应通信的。
使用SystemInformer查看R3侧App的句柄可以发现其创建了许多额外的Handle,在正常逻辑中不应出现:

MazeMoveOK && MazeMoveWall
这两者在用户侧的创建非常容易找到,位于140001340:
void sub_140001340()
{
hEvent_MazeMoveOK = CreateEventW(nullptr, 1, 0, L"Global\\MazeMoveOK");
hEvent_MazeMoveWall = CreateEventW(nullptr, 1, 0, L"Global\\MazeMoveWall");
}
对应的驱动侧代码则可以通过ZwSetEvent的交叉引用找到:
NTSTATUS __fastcall sub_1400022B0(__int64 unused, int selector)
{
// [COLLAPSED LOCAL DECLARATIONS. PRESS NUMPAD "+" TO EXPAND]
if ( !selector || (event_name = L"\\BaseNamedObjects\\MazeMoveWall", selector == 2) )
event_name = L"\\BaseNamedObjects\\MazeMoveOK";
RtlInitUnicodeString(&DestinationString, event_name);
ObjectAttributes.Length = 48;
ObjectAttributes.ObjectName = &DestinationString;
ObjectAttributes.RootDirectory = nullptr;
ObjectAttributes.Attributes = 0x240; // OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE
handle = nullptr;
*(_OWORD *)&ObjectAttributes.SecurityDescriptor = 0;
result = ZwOpenEvent(&handle, EVENT_MODIFY_STATE, &ObjectAttributes);
if ( result >= 0 )
{
ZwSetEvent(handle, nullptr);
return ZwClose(handle);
}
return result;
}
LastErrorValue
除了上面提到的相关信息之外,驱动侧还存在一些可疑的导入函数,通过查看KeStackAttachProcess的交叉引用可以发现如下的一个处理:
void __fastcall sub_140316ADF(__int64 RCX, int selector)
{
_AX = 0;
__asm { rcl ax, cl }
_RCX_1 = RCX;
ThreadId = *(void **)(RCX + 464);
if ( ThreadId )
{
if ( *(_QWORD *)(_RCX_1 + 456) )
{
if ( PsGetThreadTeb )
{
Object = nullptr;
if ( PsLookupThreadByThreadId(ThreadId, (PETHREAD *)&Object) >= 0 )
{
Process = nullptr;
if ( PsLookupProcessByProcessId(*(HANDLE *)(_RCX_1 + 456), &Process) >= 0 )
{
KeStackAttachProcess(Process, &ApcState);
_TEB = (PTEB)PsGetThreadTeb();
if ( _TEB )
{
Address = &_TEB->LastErrorValue;
if ( selector )
{
v9 = 0xC0DE0002;
if ( selector != 2 )
v9 = 0xC0DE0000;
}
else
{
v9 = 0xC0DE0001;
}
ProbeForWrite(Address, 4u, 4u);
*Address = v9;
}
KeUnstackDetachProcess(&ApcState);
ObfDereferenceObject(Process);
}
ObfDereferenceObject(Object);
}
}
}
}
}
可以发现其根据传入的参数不同设置了不同的LastError值,用户侧可以通过GetLastError获取这一隐藏信息。
ZwVirtualProtectMemory
调试可以追踪到每一轮都会从1403179B3开始,经过对应的发送得到一个对应的隐匿消息。而140005004处对应值0134的都已经找到了,为此我们通过调试来寻找2对应的操作。通过调试发现R后连续第5个D会走到这个路径,再通过在1403179D7下断点确认操作就是在这个中间完成的。因此我们在第五次D之前在1403179B3下断点,并通过ta抓取中间的指令。过滤后可以发现这中间调用了如下的内核函数:

很明显ZwProtectVirtualMemory很有可能是隐匿通信的渠道,我们给该函数下断点,抓取其参数可以发现其修改了当前进程的.data段的可执行权限为RWX(默认为RW)

刚启动时:

第五次D后:

两个信号量
为了方便起见我们下面用A和B来分别代指这两个信号量,对应关系如下:
constexpr auto kSempahoreNameA = LR"(Global\{A7F3B2C1-9E4D-4C8A-B5D6-1F2E3A4B5C6D})";
constexpr auto kSempahoreNameB = LR"(Global\{B8E2C3D0-0F5A-5D9B-C6E7-2A3F4B5C6D7E})";
constexpr auto kSemaphoreMaxCount = 16;
constexpr auto kSemaphoreInitialCount = 0;
R3侧创建信号量的代码在14021B91F中,这个函数关键的XOR字符串解密部分并没有被混淆,只是整体被插入了垃圾代码,可以容易的通过XOR 0x4B解出上面的两个信号量名称。
R0驱动侧的对应调用可以通过KeReleaseSemaphore的交叉引用找到,位于140319A37函数之中,主要逻辑如下:
void __fastcall sub_140319A37(__int64 unused, int selector)
{
WCHAR buffer[57];
UNICODE_STRING ustr;
PVOID semaphoreObj = NULL;
POBJECT_TYPE *pType = ExSemaphoreObjectType;
if (pType == NULL || *pType == NULL)
return;
PVOID source;
if (selector == 0 || selector == 2)
source = unk_140004160; // 信号量名A
else
source = unk_1400041E0; // 信号量名B
for (int i = 0; i < 57; i++)
buffer[i] = ((WCHAR*)source)[i] ^ 0x004B;
RtlInitUnicodeString(&ustr, buffer);
NTSTATUS status = ObReferenceObjectByName(
&ustr, // ObjectName
OBJ_CASE_INSENSITIVE, // Attributes = 0x40
NULL, // AccessState
0, // DesiredAccess
*pType, // ObjectType (*ExSemaphoreObjectType)
KernelMode, // AccessMode = 0
NULL, // ParseContext
&semaphoreObj // → Object
);
memset(buffer, 0, 114); // 57 * 2 = 114 bytes
if (NT_SUCCESS(status) && semaphoreObj != NULL) {
KeReleaseSemaphore(
semaphoreObj,
0, // Increment (IO_NO_INCREMENT)
1, // Adjustment
FALSE // Wait
);
ObfDereferenceObject(semaphoreObj);
}
}
隐藏的Handle
除了上面发现的两个可以直接在代码中交叉引用发现的处理之外,我们前面的图上已经观察到了还存在一个Event:
\BaseNamedObjects\{E8D7C6B5-A4F3-2E1D-0C9B-8A7F6E5D4C3B}
用户态通过给CreateEventW下断点可以容易发现其是在其余的Event和信号量创建后被混淆的代码段中创建的,该函数返回到14021B901
用户态的销毁函数给了我们有关这个Event使用的一些线索,在14021BC88处有:
void sub_14021BC88()
{
HANDLE hHiddenHandle; // rcx
if ( ::hHiddenHandle )
{
hHiddenHandle = ::hHiddenHandle;
*(_QWORD *)&NtCurrentTeb()->ReservedPad1 = 0; // _TEB + 0x1748
SetHandleInformation(hHiddenHandle, HANDLE_FLAG_PROTECT_FROM_CLOSE, 0);// HANDLE_FLAG_PROTECT_FROM_CLOSE
CloseHandle(::hHiddenHandle);
::hHiddenHandle = nullptr;
}
}
我们在内核侧搜索这个偏移0x1748可以发现对应的调用:
void __fastcall sub_14031857E(int unused, int selector)
{
if (PsGetThreadTeb == NULL || ZwSetInformationObject == NULL)
return;
PKTHREAD thread = (PKTHREAD)__readgsqword(0x188); // KPCR->CurrentThread
PTEB teb = PsGetThreadTeb(thread);
if (teb == NULL)
return;
ProbeForRead((PVOID)teb + 0x1748, 8, 8); // 验证用户态地址可读
HANDLE handle = *(HANDLE*)((PBYTE)teb + 0x1748);
if (handle == NULL)
return;
BOOLEAN protectFromClose = TRUE;
if (selector != 0 && selector != 2)
protectFromClose = FALSE;
OBJECT_HANDLE_FLAG_INFORMATION info;
info.Inherit = FALSE;
info.ProtectFromClose = protectFromClose;
ZwSetInformationObject(
handle, // 来自 TEB+0x1748
ObjectHandleFlagInformation,
&info,
sizeof(info)
);
}
其与用户态的逻辑对应,是通过句柄的ProtectFromClose字段来传递信息的。
小结
每一个操作都由对应的selector来决定,该selector取值含义为:
| 值 | 含义 |
|---|---|
| 0 | 该移动合法 |
| 1 | 该移动不合法 |
| 2 | 该移动是最短路径达到终点 |
通过调试观察可以发现140005004处的int32在每次操作时对应了下发隐匿信号的渠道,对应的值如下表所示:
| 140005004的值 | 对应信道 | 返回地址 | 如果分发到该渠道的具体操作 |
|---|---|---|---|
| 0 | EventOK / EventWall | 1403179E3 | 0和2将设置OK;1将设置Wall |
| 1 | LastErrorValue | 1403179DD | 成功则设置为0xC0DE0001或0xC0DE0002,失败则为0xC0DE0000 |
| 2 | ZwVirtualProtectMemory 将.data段改为RWX | 1403179D7 | 0和2将设置为RWX(0x40);1将设置为RW(0x4) |
| 3 | 信号量 | 1403179BF | 0和2将Release信号量A,1将Release信号量B |
| 4 | 隐藏Event的PROTECT_FROM_CLOSE属性 | 1403179B9 | 0和2将设置PROTECT_FROM_CLOSE为true,1将设置为false |
根据上面的结果我们可以发现,在不知道迷宫布局的情况下可以通过读取E上面每个渠道正确的结果,在每轮移动开始前重置相关变量来比较移动是否合法或不合法(2和4的原因注定只能选择其一进行监控),如果合法则标记改地块为通路,否则为墙壁。
完整的迷宫墙壁布局
自动化探索
在有了前面的了解后容易写出自动化探索脚本,最后可以得到迷宫完整布局如下(左上角为(0, 0), 右下角为(12, 12))。其中.表示通路,#表示墙壁
运行效果如图:

.......#.....
######.###.#.
.....#.....#.
.###.#######.
.#.........#.
.#.#.#####.#.
.#.#.#...#.#.
.#.###.#.###.
.#.....#.#...
.#######.#.##
...#...#.#.#.
##.#.#.#.#.#.
.....#.#.....
代码详情见code文件夹下maze_explore.cpp。
通过调试的方法求解
实际上这个迷宫规模很小只有13x13,在弄清楚内核侧全局结构体含义后可以在WASD的交互分支下段,每轮dump对应的值来手工验证。
关键在于0x1400050B8这个GlobalData的结构内容,调试容易发现其结构大致如下:

实际上迷宫数据就放在gap1里面
只要观察RecordLength是能够正常增长的,结合X和Y就可以手工一步步调试得到完整迷宫,并获得flag
求解起点到终点的最短路径
随便找一个最短路算法即可,这里使用python3编写了SPFA的求解:
from collections import deque
maze = [
".......#.....",
"######.###.#.",
".....#.....#.",
".###.#######.",
".#.........#.",
".#.#.#####.#.",
".#.#.#...#.#.",
".#.###.#.###.",
".#.....#.#...",
".#######.#.##",
"...#...#.#.#.",
"##.#.#.#.#.#.",
".....#.#....."
]
n, m = len(maze), len(maze[0])
sx, sy = 0, 0
tx, ty = n - 1, m - 1
INF = 10**9
dist = [[INF] * m for _ in range(n)]
inq = [[False] * m for _ in range(n)]
pre = [[(-1, -1) for _ in range(m)] for _ in range(n)]
move_to = [[''] * m for _ in range(n)]
dirs = [(-1, 0, 'W'), (1, 0, 'S'), (0, -1, 'A'), (0, 1, 'D')]
q = deque()
dist[sx][sy] = 0
q.append((sx, sy))
inq[sx][sy] = True
while q:
x, y = q.popleft()
inq[x][y] = False
for dx, dy, ch in dirs:
nx, ny = x + dx, y + dy
if not (0 <= nx < n and 0 <= ny < m):
continue
if maze[nx][ny] == '#':
continue
nd = dist[x][y] + 1
if nd < dist[nx][ny]:
dist[nx][ny] = nd
pre[nx][ny] = (x, y)
move_to[nx][ny] = ch
if not inq[nx][ny]:
q.append((nx, ny))
inq[nx][ny] = True
if dist[tx][ty] == INF:
print("No path found")
else:
ans = []
x, y = tx, ty
while (x, y) != (sx, sy):
ans.append(move_to[x][y])
x, y = pre[x][y]
ans.reverse()
print("".join(ans))
得到结果:
DDDDDDSSDDDDWWDDSSSSSSSSAASSSSDD
Flag求解
根据前面得到的地图信息以及最短路径输入即可得到最终flag:

用我们自己还原的REPL也可以得到FLAG:

最终flag为:flag{SHAD0WNT_HYPERVMX}
代码见Github