Skip to content
Draft
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
5d12e19
Implement frame walking (supports FR2 delta/Lua frames)
yogwoggf Oct 31, 2025
afd87b1
Add Debug to TValue and dummy frame marking
yogwoggf Oct 31, 2025
9207e7d
Add initial implementation of safe call
yogwoggf Oct 31, 2025
0eb14d2
Implement frame walking (supports FR2 delta/Lua frames)
yogwoggf Oct 31, 2025
e7b7452
Add Debug to TValue and dummy frame marking
yogwoggf Oct 31, 2025
c28bcc2
Add initial implementation of safe call
yogwoggf Oct 31, 2025
e2b605a
Merge branch 'NONE-stack-spoof' of https://github.com/thevurv/Autorun…
yogwoggf Oct 31, 2025
24945a1
Fix how errors are returned
yogwoggf Oct 31, 2025
07f17dc
Merge branch 'master' into NONE-stack-spoof
yogwoggf Nov 1, 2025
7005bee
Remove debug frames and tighten the safe_call self-check
yogwoggf Nov 1, 2025
b271053
Add forwarding pcall to forward errors to GMod
yogwoggf Nov 1, 2025
93cbddd
Merge remote-tracking branch 'origin/master' into NONE-stack-spoof
yogwoggf Nov 1, 2025
9851ea1
Merge master
yogwoggf Nov 2, 2025
7f37321
Fix a Windows-specific loading bug
yogwoggf Nov 2, 2025
bbc3646
Merge master
yogwoggf Nov 2, 2025
d421d62
Merge branch 'master' into NONE-stack-spoof
yogwoggf Nov 2, 2025
b9b9510
Merge branch 'master' into NONE-stack-spoof
yogwoggf Nov 2, 2025
d568816
Highly experimental frame stitching implementation
yogwoggf Nov 2, 2025
25b2618
Clean up funcname hook, add proper erroring to reduce chance of detec…
yogwoggf Nov 2, 2025
6054dc0
Refactor errors to match GMod's behavior 1:1
yogwoggf Nov 3, 2025
d22d6af
Add some default protections to std
yogwoggf Nov 3, 2025
ab506cd
Block TCO from removing an Autorun call frame
yogwoggf Nov 3, 2025
2427363
Wrap assert too
yogwoggf Nov 3, 2025
2f49193
Fix the funcname stitch constant
yogwoggf Nov 3, 2025
1187bba
Account for new closure wrapper
yogwoggf Nov 3, 2025
2b57726
Fix closure wrapper leaking to functions
yogwoggf Nov 3, 2025
4a5b8de
Wrap pcall in a safe call to handle certain situations where the fram…
yogwoggf Nov 3, 2025
1906519
Improve code quality
yogwoggf Nov 3, 2025
5409c4f
Remove unnecessary hook enabling
yogwoggf Nov 3, 2025
a2e9acf
Use helper
yogwoggf Nov 3, 2025
9cd20c1
Remove debug code
yogwoggf Nov 3, 2025
056db2a
Add linux signature
yogwoggf Nov 3, 2025
2f63e63
Merge master
yogwoggf Nov 5, 2025
6162b9b
Fix bad merge
yogwoggf Nov 5, 2025
4677541
Add docs
yogwoggf Nov 5, 2025
64e1cbd
Clean up code
yogwoggf Nov 5, 2025
2a4b75a
Improve code
yogwoggf Nov 5, 2025
288ce2b
Remove unused fields on frames and simplify code
yogwoggf Nov 5, 2025
2cadb62
Add get_proto to GCfuncL
yogwoggf Nov 5, 2025
eebcd72
Fix major oversight relating to error forwarding
yogwoggf Nov 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/autorun-env/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ autorun-log = { path = "../autorun-log" }
autorun-jit = { path = "../autorun-jit" }
autorun-luajit = { path = "../autorun-luajit" }
autorun-interfaces = { path = "../autorun-interfaces" }
autorun-scan = { path = "../autorun-scan" }
10 changes: 8 additions & 2 deletions packages/autorun-env/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ macro_rules! as_env_lua_function {
// todo: potentially add a silenterror type so we can return that and it'll return a nil.
// right now this would kind of leak the fact that it's an autorun function.
lua.push(state, c"");
lua.error(state);
lua.error(state, None, false);
} else {
$func(lua, state, env)
}
Expand Down Expand Up @@ -165,6 +165,12 @@ impl EnvHandle {
lua.push(state, as_env_lua_function!(crate::functions::is_function_authorized));
lua.set_table(state, -3);

lua.push(state, c"safeCall");
lua.push(state, as_env_lua_function!(crate::functions::safe_call));
lua.set_table(state, -3);

crate::functions::hooks::install_auth_hooks().expect("Failed to install auth hooks");

lua.push(state, c"VERSION");
lua.push(state, env!("CARGO_PKG_VERSION").to_string());
lua.set_table(state, -3);
Expand Down Expand Up @@ -209,7 +215,7 @@ impl EnvHandle {
Ok(Self { realm, env_gcr, handle })
}

fn push_autorun_table(&self, lua: &LuaApi, state: *mut LuaState) {
pub fn push_autorun_table(&self, lua: &LuaApi, state: *mut LuaState) {
self.push(lua, state);
lua.get_field(state, -1, c"Autorun".as_ptr());
lua.remove(state, -2);
Expand Down
120 changes: 118 additions & 2 deletions packages/autorun-env/src/functions/auth.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
pub mod hooks;

use anyhow::Context;
use autorun_lua::{DebugInfo, LuaApi};
use autorun_luajit::{Frame, LJState, push_tvalue};
use autorun_lua::{DebugInfo, LUA_MULTRET, LuaApi, LuaTypeId, RawLuaReturn};
use autorun_luajit::{Frame, GCfunc, LJState, get_gcobj, push_tvalue};
use autorun_types::LuaState;

pub const ERROR_FFI_ID: u8 = 19;

pub fn is_function_authorized(lua: &LuaApi, state: *mut LuaState, env: crate::EnvHandle) -> anyhow::Result<bool> {
if !matches!(
lua.type_id(state, 1),
Expand All @@ -11,6 +15,8 @@ pub fn is_function_authorized(lua: &LuaApi, state: *mut LuaState, env: crate::En
anyhow::bail!("First argument must be a function or stack level.");
}

let frames = Frame::walk_stack(state as *mut LJState);
dbg!(frames);
if lua.type_id(state, 1) == autorun_lua::LuaTypeId::Number {
// attempt to resolve the function at the given stack level
let mut debug_info = unsafe { std::mem::zeroed::<DebugInfo>() };
Expand All @@ -34,3 +40,113 @@ pub fn is_function_authorized(lua: &LuaApi, state: *mut LuaState, env: crate::En
env.is_function_authorized(lua, state, None)
.context("Failed to check function authorization.")
}

/// Like pcall, but spoofs the frames so that Autorun is no where to be seen in the call stack.
pub fn safe_call(lua: &LuaApi, state: *mut LuaState, env: crate::EnvHandle) -> anyhow::Result<RawLuaReturn> {
let nargs = lua.get_top(state);
if nargs < 1 {
anyhow::bail!("At least one argument (the function to call) is required.");
}

if lua.type_id(state, 1) != autorun_lua::LuaTypeId::Function {
anyhow::bail!("First argument must be a function to call.");
}

let is_error_fn = unsafe {
let lj_state = state as *mut LJState;
let lj_state = lj_state.as_ref().context("Failed to dereference LJState")?;
let gcfunc = get_gcobj::<GCfunc>(lj_state, 1)?;

autorun_log::debug!("GC func ffid: {:?}", gcfunc.header().ffid);
gcfunc.is_fast_function() && gcfunc.header().ffid == ERROR_FFI_ID
};

let nargs = lua.get_top(state) - 1; // exclude the function itself

let frames = Frame::walk_stack(state as *mut LJState);
let lj_state = state as *mut LJState;
let lj_state = unsafe { lj_state.as_mut().context("Failed to dereference LJState")? };

let mut autorun_frames: Vec<Frame> = frames
.into_iter()
.enumerate()
.filter(|(index, frame)| {
if *index == 0 && frame.is_c_frame() {
let gc_func = match frame.get_gc_func() {
Ok(func) => func,
Err(_) => return false,
};

if gc_func.is_c() {
let cfunc = gc_func.as_c().unwrap();
let func_ptr = cfunc.c as usize;

// check if it's safe_call, although we need to push safe call since its not directly accessible here
env.push_autorun_table(lua, state);
lua.get_field(state, -1, c"safeCall".as_ptr());
let safe_call_ptr = lua.to_function(state, -1).unwrap() as usize;
lua.pop(state, 2); // pop both the function and the env table

return func_ptr == safe_call_ptr;
}
}

// Push frame's function onto the stack
let tv = frame.get_func_tv();
unsafe {
if !(*tv).is_func() {
false
} else {
push_tvalue(lj_state, &*tv);
// ask env if this function is authorized
let authorized = env.is_function_authorized(lua, state, None).unwrap_or(false);
// pop the function off the stack
lua.pop(state, 1);

authorized
}
}
})
.map(|(_index, frame)| frame)
.collect();

// mark each autorun frame as a dummy
for frame in autorun_frames.iter_mut() {
frame.mark_as_dummy_frame(state as *mut LJState);
}

let potential_level = if is_error_fn && lua.type_id(state, 3) == LuaTypeId::Number {
// get the level from the stack
let mut level = lua.to::<i32>(state, 3); // first arg is func, second is message, third is level

if level > 1 {
// Not sure why, but if we don't offset by one, it doesn't line up correctly.
level += 1;
}

autorun_log::debug!("level: {}", level);
// replace it on the stack with 1, since we've removed our frames
lua.push(state, level);
lua.replace(state, 3);
Some(level)
} else {
None
};

let result = lua.pcall_forward(state, nargs, LUA_MULTRET, 0);
if result.is_err() {
// enable error hook to get proper stack trace
hooks::lj_debug_funcname::enable()?;
// before we forward this error, check if it's from an error ff, and if so,
// pass the level as well.
return lua.error(state, potential_level, false);
}

// restore the frames
for frame in autorun_frames.iter_mut() {
frame.restore_from_dummy_frame();
}

let nresults = lua.get_top(state); // number of results on the stack
Ok(RawLuaReturn(nresults))
}
7 changes: 7 additions & 0 deletions packages/autorun-env/src/functions/auth/hooks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
pub mod lj_debug_funcname;

pub fn install_auth_hooks() -> anyhow::Result<()> {
lj_debug_funcname::init()?;

Ok(())
}
118 changes: 118 additions & 0 deletions packages/autorun-env/src/functions/auth/hooks/lj_debug_funcname.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
use anyhow::Context;
use autorun_luajit::{Frame, LJState, TValue};

#[cfg(target_os = "windows")]
pub const TARGET_MODULE: &str = "lua_shared.dll";

#[cfg(target_os = "linux")]
pub const TARGET_MODULE: &str = "lua_shared_client.so";

pub const LJ_DEBUG_FUNCNAME_SIG: &str =
"48 89 5c 24 08 48 89 74 24 10 57 48 83 ec 20 48 8b 41 38 49 8b f0 48 83 c0 08 48 8b d9 48 3b d0";

pub const STITCHED_AUTORUN_FRAMES: usize = 2;
pub const MINIMUM_STACK_FRAMES: usize = 4;

type LjDebugFuncnameFn = unsafe extern "C" fn(state: *mut LJState, frame: *mut TValue, name: *const *const u8) -> *const u8;

static LJ_DEBUG_FUNCNAME_HOOK: std::sync::OnceLock<retour::GenericDetour<LjDebugFuncnameFn>> = std::sync::OnceLock::new();

/// Hook for `lj_debug_funcname` to stitch across Autorun frames,
/// enabling LuaJIT to properly identify function names in the call stack.
///
/// This is particularly useful for avoiding detection by anti-cheat systems that monitor the call stack for unauthorized code.
/// Detection can occur if a function name is missing and replaced with `?` when Autorun is in use, as opposed to
/// a proper function name when Autorun is not present.
///
/// This hook attempts to find the correct frame for `lj_debug_funcname` to use by walking the stack, locating the original frame,
/// and stitching across the two Autorun frames that are typically present.
///
/// It only activates in the situation where there are at least `MINIMUM_STACK_FRAMES` frames in the stack, and
/// the original function name was not found (i.e., the first call to the original `lj_debug_funcname` returned null).
///
/// This helps ensure that legitimate function names are returned in any legitimate scenarios, while still providing the stitching functionality
/// for Autorun-involved calls. Ideally, we could be more precise about when to stitch, but somehow we would need to identify
/// which frames belong to Autorun specifically, which is non-trivial at this point because we don't have reliable metadata about the frames.
///
/// We also cannot set a flag or anything because we forward errors which longjmps back to LuaJIT code, which means we can not control the ending state
extern "C" fn lj_debug_funcname_hook(state: *mut LJState, frame: *mut TValue, name: *const *const u8) -> *const u8 {
let first_ret = unsafe { LJ_DEBUG_FUNCNAME_HOOK.get().unwrap().call(state, frame, name) };
if first_ret != std::ptr::null() {
// Never attempt to stitch if we already have a valid name.
return first_ret;
}

let frames = Frame::walk_stack(state);
if frames.len() < MINIMUM_STACK_FRAMES {
return first_ret;
}

let mut matched_frame_index: Option<usize> = None;

for (i, f) in frames.iter().enumerate() {
if f.tvalue.eq(&frame) {
matched_frame_index = Some(i);
}
}

let mut target_frame = frame;
if let Some(matched_index) = matched_frame_index {
let new_index = matched_index + STITCHED_AUTORUN_FRAMES;
target_frame = frames.get(new_index).map_or(frame, |f| f.tvalue);
}

let ret = unsafe { LJ_DEBUG_FUNCNAME_HOOK.get().unwrap().call(state, target_frame, name) };
ret
}

pub fn enable() -> anyhow::Result<()> {
let hook = LJ_DEBUG_FUNCNAME_HOOK
.get()
.context("lj_debug_funcname hook is not initialized")?;
unsafe {
hook.enable().context("Failed to enable lj_debug_funcname hook")?;
}

Ok(())
}

pub fn disable() -> anyhow::Result<()> {
let hook = LJ_DEBUG_FUNCNAME_HOOK
.get()
.context("lj_debug_funcname hook is not initialized")?;
unsafe {
hook.disable().context("Failed to disable lj_debug_funcname hook")?;
}

Ok(())
}

pub fn init() -> anyhow::Result<()> {
if LJ_DEBUG_FUNCNAME_HOOK.get().is_some() {
return Ok(());
}

let lj_debug_funcname_addr = autorun_scan::scan(autorun_scan::sig_byte_string(LJ_DEBUG_FUNCNAME_SIG), Some(TARGET_MODULE))
.context("Failed to find lj_debug_funcname signature")?;
let lj_debug_funcname_addr = lj_debug_funcname_addr.context("lj_debug_funcname address not found")?;

autorun_log::info!("Found lj_debug_funcname at address: {:x}", lj_debug_funcname_addr);
unsafe {
let hook = retour::GenericDetour::<LjDebugFuncnameFn>::new(
std::mem::transmute(lj_debug_funcname_addr as *const ()),
lj_debug_funcname_hook,
)
.context("Failed to create lj_debug_funcname detour")?;

unsafe {
hook.enable().context("Failed to enable lj_debug_funcname hook")?;
}

LJ_DEBUG_FUNCNAME_HOOK
.set(hook)
.map_err(|_| anyhow::anyhow!("Failed to set LJ_DEBUG_FUNCNAME_HOOK"))?;
autorun_log::info!("lj_debug_funcname hook installed successfully");
}

Ok(())
}
5 changes: 2 additions & 3 deletions packages/autorun-env/src/functions/detour/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,8 @@ pub extern "C-unwind" fn detour_handler(

let base = lua.get_top(state) - num_arguments;

if let Err(why) = lua.pcall(state, num_arguments, LUA_MULTRET, 0) {
error!("Error calling detour callback: {why}");
return 0;
if let Err(()) = lua.pcall_forward(state, num_arguments, LUA_MULTRET, 0) {
return lua.error(state, None, true);
}

lua.get_top(state) - base + 1
Expand Down
52 changes: 50 additions & 2 deletions packages/autorun-lua/src/lua.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ define_lua_api! {
#[name = "lua_equal"]
fn _equal(state: *mut LuaState, index1: c_int, index2: c_int) -> c_int;
#[name = "lua_error"]
pub fn error(state: *mut LuaState) -> !;
pub fn _error(state: *mut LuaState) -> !;
#[name = "lua_gc"]
pub fn gc(state: *mut LuaState, what: c_int, data: c_int) -> c_int;
#[name = "lua_settop"]
Expand All @@ -149,6 +149,8 @@ define_lua_api! {
pub fn get_top(state: *mut LuaState) -> c_int;
#[name = "lua_remove"]
pub fn remove(state: *mut LuaState, index: c_int);
#[name = "lua_replace"]
pub fn replace(state: *mut LuaState, index: c_int);
#[name = "lua_status"]
pub fn status(state: *mut LuaState) -> c_int;
#[name = "lua_type"]
Expand Down Expand Up @@ -221,6 +223,12 @@ define_lua_api! {
#[name = "lua_setmetatable"]
pub fn set_metatable(state: *mut LuaState, index: c_int) -> c_int;

#[name = "luaL_where"]
pub fn where_(state: *mut LuaState, level: c_int);

#[name = "lua_concat"]
pub fn concat(state: *mut LuaState, n: c_int);

#[name = "lua_newuserdata"]
fn _new_userdata(state: *mut LuaState, size: usize) -> *mut c_void;
}
Expand Down Expand Up @@ -435,6 +443,46 @@ impl LuaApi {
}
}

pub fn pcall_forward(&self, state: *mut LuaState, n_args: c_int, n_results: c_int, err_func: c_int) -> Result<(), ()> {
match self._pcall(state, n_args, n_results, err_func) {
LUA_OK | LUA_YIELD => Ok(()),

err_code => Err(()),
}
}

pub fn error(&self, state: *mut LuaState, level: Option<i32>, forward: bool) -> ! {
// GMod concatenates the source info to the error message automatically.
// If we do not mimic this behavior, the error messages will be different from
// the ones by GMod, which leads to easy detection.
if (forward) {
self._error(state);
}

#[cfg(feature = "gmod")]
{
// Error message is already on top of the stack
self.where_(state, level.unwrap_or(1));
// check if we're duplicating the error message (common if error is also detoured)
let where_string = self.to_string(state, -1).unwrap_or_default();
let error_string = self.to_string(state, -2).unwrap_or_default();

if error_string.starts_with(where_string.as_ref()) {
self.remove(state, -1); // remove where info and keep original error message
return self._error(state);
}

self.push_value(state, -2); // where + ": " + error message
self.remove(state, -3); // remove original error message
self.concat(state, 2); // concatenate
self._error(state)
}

#[cfg(not(feature = "gmod"))]
{
self._error(state)
}
}
pub fn new_userdata<T: Sized>(&self, state: *mut LuaState, init: T) -> *mut T {
let ptr = self._new_userdata(state, core::mem::size_of::<T>()) as *mut T;
unsafe {
Expand Down Expand Up @@ -522,7 +570,7 @@ macro_rules! as_lua_function {
Ok(ret) => $crate::LuaReturn::into_lua_return(ret, lua, state),
Err(e) => {
lua.push(state, e.to_string());
lua.error(state);
lua.error(state, None, false);
}
}
}
Expand Down
Loading