From 33f5ed9ef58f26c6b3b52cf605dd4311d658a60d Mon Sep 17 00:00:00 2001 From: salmonik Date: Fri, 6 Nov 2026 16:09:55 +0100 Subject: Fix audit findings: crash safety, correctness, rendering, performance Crash/UB: - gate every hook behind an init-stage flag until config/surface/input are ready - publish module maps as immutable atomic snapshots instead of reallocating shared vectors under unlocked readers - validate the %ebp walk in the RandomSeed/RandomFloat hooks against the stack - bounds-check every RecvTable/RecvProp/string dereference with module_range() - preserve the Itanium vtable header when cloning (client, CInput, model render, studio render) - reject negative usercmd sequence numbers; guard config_load against a null section target; cap the bone parent walk; check mprotect; drop PROT_EXEC on the GOT patch; absorb server.so .bss tail Correctness: - filter dormant entities across ESP, aimbot, triggerbot, melee, items and projectiles (slot self-tested against the local player) - reset the server-health latch per map and only latch on valid model indices - bone cache sized for the 128-target scan; set valid=false on every failure; head_position keeps searching past an invalid bone - vischeck steps past glass by probing until clear, and ignores entities owned by the target (held weapons, bone followers) - aimbot: maxs no longer disables it, AABB-aware FOV prefilter, lock hysteresis, lead point re-checked for FOV and visibility - spread: seed hook arms on the no-spread toggle alone; engine toggle only controls the RandomFloat zeroing - resolve m_hOwnerEntity/m_hOwner; NPC lead velocity from the server entity - vector_angles pitch range, point_ground normal test, underwater jump+duck no longer skips combat, semi-auto press/release edge for synthetic fire - item/projectile markers and blast rings refresh origin per frame while discovery stays at 10 Hz; trajectory uses the real m_DmgRadius and honours the blast-radius toggle Rendering/GUI: - one overlay draw per engine frame (FRAME_RENDER_START boundary), fixing alpha stacking and the speed-graph flicker - clipped lines/outlines and ellipsised text instead of drawing past the content pane or shifting over neighbours - dropdown dismissal consumes the click; hover ignores hidden controls - chams: reset AlphaModulate, reference shared materials once, restore prop tint colour; fonts created once per handle - projection uses Source's 4:3 aspect scaling and roll; screen size refreshed each frame; FOV circle gated on the aim toggles, not the bind Performance: - TTL caches for NPC relation, friendly/neutral and damage class - model names memoised per model index - entity pointer -> index cache for the triggerbot (was a 4096-slot walk/tick) Build: - real Makefile with -MMD dependency tracking, debug/release objects and an always-relink rule; build.sh kept as a thin wrapper --- src/hl2h.cpp | 214 ++++++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 159 insertions(+), 55 deletions(-) (limited to 'src/hl2h.cpp') diff --git a/src/hl2h.cpp b/src/hl2h.cpp index ddfc02a..6697778 100644 --- a/src/hl2h.cpp +++ b/src/hl2h.cpp @@ -1,11 +1,47 @@ // hl2h.cpp — core: logging, vector math, globals, feature logic, setup #include "hl2h.h" +#include #include #include #include #include #include +// --------------------------------------------------------------------------- +// init readiness +// --------------------------------------------------------------------------- + +static std::atomic g_init_stage{INIT_NONE}; + +void init_set_stage(int stage) { + g_init_stage.store(stage, std::memory_order_release); +} + +int init_stage() { return g_init_stage.load(std::memory_order_acquire); } + +// --------------------------------------------------------------------------- +// vtable cloning +// --------------------------------------------------------------------------- +// +// Under the Itanium C++ ABI the two words before slot 0 are offset-to-top and +// the type_info pointer. Copying only the slots and repointing the object's +// vptr leaves those words pointing at allocator metadata, which breaks any +// RTTI the host performs on the interface. Keep the header words with the +// clone and hand back slot 0. + +void **vtable_clone(void *iface, size_t slots) { + if (!iface || slots == 0) + return nullptr; + void **vt = *(void ***)iface; + if (!vt) + return nullptr; + void **out = new void *[slots + 2]; + out[0] = vt[-2]; + out[1] = vt[-1]; + memcpy(out + 2, vt, slots * sizeof(void *)); + return out + 2; +} + // --------------------------------------------------------------------------- // logging // --------------------------------------------------------------------------- @@ -67,7 +103,6 @@ void logd(const char *fmt, ...) { // globals // --------------------------------------------------------------------------- -Module g_client, g_engine, g_server; GameInterfaces g_iface; NetVars g_net; Vector g_cam_eye{0, 0, 0}; @@ -87,11 +122,11 @@ QAngle vector_angles(const Vector &forward) { a.y = atan2f(forward.y, forward.x) * 180.f / PI_F; if (a.y < 0) a.y += 360.f; + // Pitch stays in [-90, 90] like Source's QAngle; wrapping negatives to + // [270, 360) made every upward aim compare as a steeply negative angle. a.x = atan2f(-forward.z, sqrtf(forward.x * forward.x + forward.y * forward.y)) * 180.f / PI_F; - if (a.x < 0) - a.x += 360.f; } return a; } @@ -485,7 +520,31 @@ static bool g_seed_hook_installed = false; // Pellet-spread window armed by the player's FireBullets RandomSeed call. static volatile int g_spread_rand_left = 0; +// Identify the caller of a random-stream function by reading the caller's +// stack frame. The callers live in Valve's binaries, which are not built with +// the guarantee our own -fno-omit-frame-pointer gives us, so treat the walk as +// untrusted: it may only stay inside the current stack, and the object it +// yields is used for nothing but comparison against g_local_server_player. +static void *caller_self_arg() { + void *sp = nullptr, *bp = nullptr; + __asm__ volatile("movl %%esp, %0" : "=r"(sp)); + __asm__ volatile("movl %%ebp, %0" : "=r"(bp)); + uintptr_t spv = (uintptr_t)sp, bpv = (uintptr_t)bp; + constexpr uintptr_t MAX_DEPTH = 0x100000; // 1 MiB of stack frames + if (!bpv || (bpv & 3) || bpv <= spv || bpv - spv > MAX_DEPTH) + return nullptr; + uintptr_t caller = *(uintptr_t *)bpv; + if (!caller || (caller & 3) || caller < bpv || caller - spv > MAX_DEPTH) + return nullptr; + return *(void **)(caller + 8); // first argument slot of the caller +} + static void hkdSrvRandomSeed(int seed) { + if (!init_live()) { + if (g_real_random_seed) + g_real_random_seed(seed); + return; + } uintptr_t ret = (uintptr_t)__builtin_return_address(0); long off = (long)(ret - g_srv_base); static bool trace = getenv("HL2H_SEED_TRACE") != nullptr; @@ -494,16 +553,16 @@ static void hkdSrvRandomSeed(int seed) { // The caller's first argument is `this`. Only FireBullets is called on the // player entity; the CUniformRandomStream wrappers and the constructor-time // RandomSeed(0) reset have a different object there. - void *frame = nullptr; - __asm__ volatile("movl %%ebp, %0" : "=r"(frame)); - if (frame) - frame = *(void **)frame; // caller's ebp (CBaseEntity::FireBullets) - void *self = frame ? *(void **)((char *)frame + 8) : nullptr; + void *self = caller_self_arg(); bool local = self && g_local_server_player && (uintptr_t)self == g_local_server_player; - if (local && g_server.contains(ret) && feature_active(FEAT_SPREAD) && - spread_no_spread_enabled() && spread_engine_enabled()) { + // Seed selection and engine spread zeroing are independent toggles: "engine + // override" only decides whether RandomFloat is forced to 0, it must not + // disable the seed hook (without the seed hook the server ignores + // cmd->random_seed entirely). + if (local && mod_server().contains(ret) && feature_active(FEAT_SPREAD) && + spread_no_spread_enabled()) { g_spread_rand_left = 8; int pellets = g_spread_pellets; if (pellets < 1) @@ -533,12 +592,12 @@ static void hkdSrvRandomSeed(int seed) { static void install_seed_hook() { static bool failed = false; - if (g_seed_hook_installed || failed || !g_server.valid()) + if (g_seed_hook_installed || failed || !mod_server().valid()) return; - g_srv_base = g_server.ranges[0].lo; + g_srv_base = mod_server().ranges[0].lo; uintptr_t reloc = 0, bias = 0; uintptr_t slot = 0; - if (module_jump_slot(g_server.path.c_str(), "RandomSeed", &reloc, &bias)) { + if (module_jump_slot(mod_server().path.c_str(), "RandomSeed", &reloc, &bias)) { slot = g_srv_base + reloc - bias; logf("seed hook: RandomSeed GOT reloc=%#lx bias=%#lx -> %#lx", (unsigned long)reloc, (unsigned long)bias, (unsigned long)slot); @@ -547,13 +606,13 @@ static void install_seed_hook() { static const uintptr_t known[] = {0xce5348, 0xce1348}; for (size_t i = 0; i < sizeof(known) / sizeof(known[0]) && !slot; i++) { uintptr_t cand = g_srv_base + known[i]; - if (g_server.contains(cand)) + if (mod_server().contains(cand)) slot = cand; } logf("seed hook: ELF reloc scan failed, fallback slot=%#lx", (unsigned long)slot); } - if (!slot || !g_server.contains(slot)) { + if (!slot || !mod_server().contains(slot)) { failed = true; logf("seed hook: no writable RandomSeed slot"); return; @@ -568,8 +627,15 @@ static void install_seed_hook() { toastf(TOAST_WARN, "Spread seed hook unavailable"); return; } - mprotect((void *)(slot & ~(uintptr_t)0xFFF), 0x1000, - PROT_READ | PROT_WRITE | PROT_EXEC); + // The slot is 4-byte aligned, so a single page always covers the 4-byte + // write. No PROT_EXEC: this is a data slot. Permissions are intentionally + // not restored because .got.plt stays writable for lazy binding. + uintptr_t page = slot & ~(uintptr_t)0xFFF; + if (mprotect((void *)page, 0x1000, PROT_READ | PROT_WRITE) != 0) { + failed = true; + logf("seed hook: mprotect failed for page %#lx", (unsigned long)page); + return; + } *(uint32_t *)slot = (uint32_t)(uintptr_t)&hkdSrvRandomSeed; g_seed_hook_installed = true; logf("seed hook: server GOT %#lx -> %p (real %p)", @@ -598,19 +664,16 @@ static bool g_uniform_hook_installed = false; // (the compiler may outline it), so this window is the reliable signal. static float hkdUniformRandomFloat(void *self, float mn, float mx) { + if (!init_live()) + return g_orig_uniform_float ? g_orig_uniform_float(self, mn, mx) : mn; bool enabled = g_local_server_player && feature_active(FEAT_SPREAD) && spread_no_spread_enabled() && spread_engine_enabled(); bool hit = false; void *who = nullptr; if (enabled) { - void *frame = nullptr; - __asm__ volatile("movl %%ebp, %0" : "=r"(frame)); - if (frame) { - frame = *(void **)frame; // caller's ebp - who = frame ? *(void **)((char *)frame + 8) : nullptr; - if (who && (uintptr_t)who == g_local_server_player) - hit = true; - } + who = caller_self_arg(); + if (who && (uintptr_t)who == g_local_server_player) + hit = true; if (!hit && g_spread_rand_left > 0) { // The next few draws after the player's FireBullets RandomSeed are the // pellet spread, regardless of which function emits them. @@ -748,9 +811,26 @@ static bool edge_jump_apply(CUserCmd *cmd, void *ent, int flags, return true; } +// Everything tied to one map load / local player lifetime. Called when the +// local entity pointer changes (map load, respawn, disconnect). +static void session_reset() { + bones_clear(); + aimbot_reset_lock(); + silentaim_reset_lock(); + esp_reset_session(); + fullbright_invalidate(); + world_tint_invalidate(); + fog_invalidate(); + skybox_invalidate(); + logd("session: reset (local entity changed)"); +} + static void do_move(int seq, float frametime) { install_seed_hook(); install_uniform_random_hook(); + // The pellet window is armed inside FireBullets during this tick; it must + // never survive into the next one. + g_spread_rand_left = 0; static bool g_echo_sent = false; if (!g_input || !g_net.ok()) return; @@ -779,17 +859,13 @@ static void do_move(int seq, float frametime) { view_settings_apply(ent); - // New map / respawn: drop stale bone caches and target locks. + // New map / respawn: drop stale caches and locks. Any per-map state must be + // cleared here; caches that are not listed will leak across level changes + // (that is how the server-health latch and the fuse table broke before). static void *last_local_ent = nullptr; if (ent != last_local_ent) { last_local_ent = ent; - bones_clear(); - aimbot_reset_lock(); - silentaim_reset_lock(); - fullbright_invalidate(); - world_tint_invalidate(); - fog_invalidate(); - skybox_invalidate(); + session_reset(); } if (feature_active(FEAT_SPREAD)) { @@ -854,8 +930,9 @@ static void do_move(int seq, float frametime) { if ((bhop_on || abh_on) && (cmd->buttons & IN_JUMP)) { if (inwater && (cmd->buttons & IN_DUCK)) { + // Not a bhop tick, but combat and jump assist still have to run. lastground = onground; - return; + goto bhop_done; } float speed = sqrtf(vel[0] * vel[0] + vel[1] * vel[1] + vel[2] * vel[2]); bool moving = speed > 0.1f; @@ -899,6 +976,7 @@ static void do_move(int seq, float frametime) { } } +bhop_done: bool jump_assist = crouch_jump_apply(cmd, flags, vel); if (edge_jump_apply(cmd, ent, flags, vel)) jump_assist = true; @@ -975,7 +1053,12 @@ void set_silent_restore(const QAngle &angles) { static void hkdFrameStageNotify(void *self, int stage) { if (oFrameStageNotify) oFrameStageNotify(self, stage); + if (!init_live()) + return; if (stage == 5) { + // FRAME_RENDER_START is the real frame boundary: it lets the paint hooks + // below draw exactly once per frame instead of once per VGUI panel. + overlay_frame_mark(); // Skybox texture uploads happen here, not in the VGUI paint hook: the // material system only downloads procedural textures on a render-safe // frame stage. @@ -1006,14 +1089,15 @@ static bool g_in_draw = false; // our overlay draws are submitted through outer_draw() (menu.cpp), which // wraps them in PushMakeCurrent/PopMakeCurrent with the embedded panel. static void hkdDrawPrintText(void *self, const wchar_t *text, int len, int drawType) { - if (g_in_draw) { + if (g_in_draw || !init_live()) { oDrawPrintText(self, text, len, drawType); return; } g_in_draw = true; oDrawPrintText(self, text, len, drawType); - // fallback trigger: fires when the game draws text (menus). Primary draw - // path is the PaintTraverseEx hook (slot 114), which fires every frame. + // Fallback trigger for frames where nothing paints (menus). The per-frame + // budget inside outer_draw() makes this a no-op if PaintTraverseEx already + // drew this frame. outer_draw(); g_in_draw = false; } @@ -1024,7 +1108,7 @@ static void hkdDrawPrintText(void *self, const wchar_t *text, int len, int drawT static void hkdPaintTraverseEx(void *self, long panel, bool paintPopups) { using Fn = void (*)(void *, long, bool); ((Fn)oPaintTraverseEx)(self, panel, paintPopups); - if (!g_in_draw) { + if (!g_in_draw && init_live()) { g_in_draw = true; outer_draw(); g_in_draw = false; @@ -1032,6 +1116,8 @@ static void hkdPaintTraverseEx(void *self, long panel, bool paintPopups) { } static bool hkdCreateMove(void *self, int seq, float frametime, bool active) { + if (!init_live()) + return oCreateMove(self, seq, frametime, active); bool ret = oCreateMove(self, seq, frametime, active); do_move(seq, frametime); @@ -1041,8 +1127,12 @@ static bool hkdCreateMove(void *self, int seq, float frametime, bool active) { if (!isurface_hooked && g_surface) { void **svt = VTABLE(g_surface); constexpr size_t VT_SIZE = 256; - void **newsvt = new void *[VT_SIZE]; - memcpy(newsvt, svt, VT_SIZE * sizeof(void *)); + void **newsvt = vtable_clone(g_surface, VT_SIZE); + if (!newsvt) { + isurface_hooked = 1; // don't retry every tick + logf("ISurface hook: vtable unavailable"); + return ret; + } oDrawPrintText = (DrawPrintTextFn)svt[22]; newsvt[22] = (void *)&hkdDrawPrintText; oPaintTraverseEx = (PaintTraverseExFn)svt[114]; @@ -1059,8 +1149,11 @@ static bool hkdCreateMove(void *self, int seq, float frametime, bool active) { static void install_hook() { void **vt = VTABLE(g_iface.client); constexpr size_t SLOTS = 256; - void **newvt = new void *[SLOTS]; - memcpy(newvt, vt, SLOTS * sizeof(void *)); + void **newvt = vtable_clone(g_iface.client, SLOTS); + if (!newvt) { + logf("hook install: client vtable unavailable"); + return; + } oCreateMove = (CreateMoveFn)vt[21]; newvt[21] = (void *)&hkdCreateMove; oFrameStageNotify = (FrameStageFn)vt[35]; @@ -1097,26 +1190,28 @@ static bool is_hl2_process() { static void *setup_thread(void *) { for (int i = 0; i < 6000; i++) { find_modules(); - if (g_client.valid() && g_engine.valid()) + if (mod_client().valid() && mod_engine().valid()) break; usleep(200 * 1000); } - if (!g_client.valid() || !g_engine.valid()) { + if (!mod_client().valid() || !mod_engine().valid()) { + init_set_stage(INIT_FAILED); logf("client.so/engine.so never appeared"); toastf(TOAST_ERROR, "client.so/engine.so not found; hl2hake inactive"); return nullptr; } - logf("client.so: %d mapped range(s), %s", (int)g_client.ranges.size(), - g_client.path.c_str()); - logf("engine.so: %d mapped range(s), %s", (int)g_engine.ranges.size(), - g_engine.path.c_str()); + logf("client.so: %d mapped range(s), %s", (int)mod_client().ranges.size(), + mod_client().path.c_str()); + logf("engine.so: %d mapped range(s), %s", (int)mod_engine().ranges.size(), + mod_engine().path.c_str()); // resolve factories via dlopen handles - void *ch = dlopen(g_client.path.c_str(), RTLD_NOLOAD | RTLD_LAZY | RTLD_GLOBAL); - void *eh = dlopen(g_engine.path.c_str(), RTLD_NOLOAD | RTLD_LAZY | RTLD_GLOBAL); + void *ch = dlopen(mod_client().path.c_str(), RTLD_NOLOAD | RTLD_LAZY | RTLD_GLOBAL); + void *eh = dlopen(mod_engine().path.c_str(), RTLD_NOLOAD | RTLD_LAZY | RTLD_GLOBAL); auto cf = (CreateInterfaceFn)dlsym(ch, "CreateInterface"); auto ef = (CreateInterfaceFn)dlsym(eh, "CreateInterface"); if (!cf || !ef) { + init_set_stage(INIT_FAILED); logf("CreateInterface not found"); toastf(TOAST_ERROR, "CreateInterface not found; hl2hake inactive"); return nullptr; @@ -1134,6 +1229,7 @@ static void *setup_thread(void *) { usleep(100 * 1000); } if (!g_iface.client || !g_iface.entitylist || !g_iface.engine) { + init_set_stage(INIT_FAILED); logf("interfaces unresolved: VClient017=%p VClientEntityList003=%p " "VEngineClient014=%p", g_iface.client, g_iface.entitylist, g_iface.engine); @@ -1159,10 +1255,10 @@ static void *setup_thread(void *) { g_iface.cvar = cf(cvar_names[i], nullptr); } if (!g_iface.cvar) { - size_t slash = g_engine.path.find_last_of('/'); + size_t slash = mod_engine().path.find_last_of('/'); if (slash != std::string::npos) { std::string path = - g_engine.path.substr(0, slash + 1) + "libvstdlib.so"; + mod_engine().path.substr(0, slash + 1) + "libvstdlib.so"; void *h = dlopen(path.c_str(), RTLD_NOLOAD | RTLD_LAZY | RTLD_GLOBAL); if (!h) h = dlopen(path.c_str(), RTLD_LAZY | RTLD_GLOBAL); @@ -1195,15 +1291,23 @@ static void *setup_thread(void *) { toastf(TOAST_WARN, "Debug overlay missing; ESP projection uses fallback"); if (!g_iface.enginetrace) toastf(TOAST_WARN, "Engine trace missing; vischeck fails open"); + init_set_stage(INIT_INTERFACES); + + init_conmsg(); + // Config load and ISurface/font discovery must finish before any hook is + // allowed past the init_live() gate: the game thread keeps calling + // CreateMove/PaintTraverse while this thread runs. + install_menu(); + init_set_stage(INIT_READY); if (!find_input_global()) { + init_set_stage(INIT_FAILED); toastf(TOAST_ERROR, "Input hook failed; movement features disabled"); return nullptr; } - - init_conmsg(); install_hook(); - install_menu(); + init_set_stage(INIT_LIVE); + logf("init: live (surface %p)", (void *)g_surface); if (g_surface) toastf(TOAST_INFO, "hl2hake loaded - Insert opens the menu"); return nullptr; -- cgit v1.2.3