Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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 @@ -20,3 +20,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" }
23 changes: 23 additions & 0 deletions packages/autorun-env/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,29 @@
}
]
},
{
"name": "safeCall",
"description": "Calls a function in a spoofed state, such that it cannot access any Autorun call frames. This is both useful for calling untrusted code safely. Currently, it only is meant to be used internally by Autorun, but exposed for advanced use cases.",
"realm": "shared",
"parameters": [
{
"name": "func",
"type": "function",
"description": "The function to call safely"
},
{
"name": "args",
"type": "...any",
"description": "Arguments to pass to the function"
}
],
"returns": [
{
"type": "...any",
"description": "Return values from the function"
}
]
},
{
"name": "load",
"description": "Compiles a Lua string into a callable function without executing it. Similar to Lua's loadstring/load function. Use this to dynamically compile code at runtime. NOTE: The environment inside defaults to the global environment, NOT Autorun's environment.",
Expand Down
10 changes: 8 additions & 2 deletions packages/autorun-env/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,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 @@ -170,6 +170,12 @@ impl EnvHandle {
lua.push(state, as_env_lua_function!(crate::functions::is_proto_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 @@ -233,7 +239,7 @@ impl EnvHandle {
}
}

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
131 changes: 122 additions & 9 deletions packages/autorun-env/src/functions/auth.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
pub mod hooks;

use anyhow::Context;
use autorun_lua::{DebugInfo, LuaApi};
use autorun_luajit::{Frame, GCProto, LJ_TPROTO, LJState, get_gcobj, index2adr, push_tvalue};
use autorun_lua::{DebugInfo, LUA_MULTRET, LuaApi, LuaTypeId, RawLuaReturn};
use autorun_luajit::{Frame, FrameType, GCProto, GCfunc, LJState, get_gcobj, index2adr, push_frame_func, 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),
autorun_lua::LuaTypeId::Function | autorun_lua::LuaTypeId::Number
) {
if !matches!(lua.type_id(state, 1), LuaTypeId::Function | LuaTypeId::Number) {
anyhow::bail!("First argument must be a function or stack level.");
}

if lua.type_id(state, 1) == autorun_lua::LuaTypeId::Number {
if lua.type_id(state, 1) == LuaTypeId::Number {
// attempt to resolve the function at the given stack level
let mut debug_info = unsafe { std::mem::zeroed::<DebugInfo>() };
let stack_level = lua.to::<i32>(state, 1);
Expand All @@ -35,15 +36,127 @@ pub fn is_function_authorized(lua: &LuaApi, state: *mut LuaState, env: crate::En
.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) != LuaTypeId::Function {
anyhow::bail!("First argument must be a function to call.");
}

let is_error_fn = is_error_fn(state, 1)?;
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_lua_frame() {
// Typically our closure wrapper frame, although it is not necessarily marked as a C frame.
// Its some tail call magic, but we always want to remove it. It does leak in debug.traceback,
// for example.

return true;
}

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_frame_func(lj_state, frame).expect("Failed to push frame function onto stack");
// 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
autorun_frames
.iter_mut()
.for_each(|frame| frame.mark_as_dummy_frame(lj_state));

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
level -= 1; // adjust for closure wrapper

// 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() {
// We do not need to restore the frames, since LuaJIT will unwind the stack entirely on error.
// This has the added benefit of not exposing our Autorun frames in the stack trace.
return lua.error(state, potential_level.or(Some(0)), false);
}

// restore the frames
autorun_frames.iter_mut().for_each(|frame| frame.restore_from_dummy_frame());

Ok(RawLuaReturn(lua.get_top(state)))
}

fn is_error_fn(state: *mut LuaState, idx: i32) -> anyhow::Result<bool> {
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, idx)?;

gcfunc.is_fast_function() && gcfunc.header().ffid == ERROR_FFI_ID
};

Ok(is_error_fn)
}

pub fn is_proto_authorized(_lua: &LuaApi, state: *mut LuaState, env: crate::EnvHandle) -> anyhow::Result<bool> {
// Protos dont play nice with the usual public API types, so we just have to do it manually
let lj_state = state as *mut LJState;
let lj_state = unsafe { lj_state.as_ref().context("Failed to dereference LJState")? };
let proto_tv = index2adr(lj_state, 1).context("Failed to get TValue for given index.")?;
let proto_tv = unsafe { &*proto_tv };

// TODO: When the stack spoof PR is merged, replace this with the new type check helper
if proto_tv.itype() != LJ_TPROTO {
if !proto_tv.is_proto() {
anyhow::bail!("First argument must be a proto.");
}

Expand Down
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(())
}
122 changes: 122 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,122 @@
use anyhow::Context;
use autorun_luajit::{Frame, LJ_TFUNC, 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";

#[cfg(target_os = "windows")]
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";

#[cfg(target_os = "linux")]
pub const LJ_DEBUG_FUNCNAME_SIG: &str = "41 55 41 54 55 48 89 fd 53 48 83 ec 08 48 8b 47 38 48 83 c0 08 48 39 c6 0f ?? ?? ?? ?? ?? 48 8b 06 49 89 d5 48 89 c2 83 e2 07 48 83 fa 03";

/// Number of frames to stitch across for Autorun. This is computed based on the typical
/// frame structure for `Autorun.copyFastFunction(foo, function(...) Autorun.safeCall(...) end)`,
pub const STITCHED_AUTORUN_FRAMES: usize = 4;
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: *const u8 = unsafe { LJ_DEBUG_FUNCNAME_HOOK.get().unwrap().call(state, frame, name) };
if !first_ret.is_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")?;

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"))?;
}

Ok(())
}
2 changes: 1 addition & 1 deletion packages/autorun-env/src/functions/detour.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ pub fn detour(lua: &LuaApi, state: *mut LuaState, _env: crate::EnvHandle) -> any
})
}

pub fn copy_fast_function(lua: &LuaApi, state: *mut LuaState, _env: crate::EnvHandle) -> anyhow::Result<RawLuaReturn> {
pub fn copy_fast_function(lua: &LuaApi, state: *mut LuaState, env: crate::EnvHandle) -> anyhow::Result<RawLuaReturn> {
if lua.is_c_function(state, 1) == 0 {
anyhow::bail!("First argument must be a C function to copy.");
}
Expand Down
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
Loading