How I Bypassed Script Enforcer
Recently I had the urge to figure out how I could get scripts to run on servers in Garry’s Mod with sv_allowcslua
set to 0, meaning clientside script execution is not allowed. In the process I spent roughly a day diving through assembly in Ghidra/Cheat Engine trying to do this and succeeded. Script enforcer bypasses are pretty commonplace and have been a thing for the last 10ish years but I wanted to make one myself.
Overview
Script enforcer is a thing in the game Garry’s Mod that doesn’t let you execute LUA scripts while the variable sv_allowcslua
is set (also known as a “convar”). The idea is that server owners would want to set this flag to 0 because players if it’s not, players can execute scripts that allow them to use stuff like wallhacks or aimbots since LUA scripts act as an interactable API for game data.
The idea behind getting past script enforcer in essence is to bypass any checks and get lua scripts running on the clientside entirely without looking to any checks on certain convars. There’s ways to get around script enforcer by simply just accessing the lua_state*
object representing the current lua stack state, calling loadstring
which executes a string buffer as code. When I tried this in 2017 (and ultimately gave up) scripts would execute but run into race conditions since this requires code execution in another thread. Finding the main thread where lua code executes to hook into it and avoid running into race conditions is also too much work.
This time around I decided to try to reverse engineer from the ground up how LUA is ran and see if I could get a firmer grasp on how I could achieve the above with the end goal of having my scripts ran even on servers that block them. Ideally we just want to call an openscript command like lua_openscript_cl
and have our code be executed from a file.
Garry’s Mod isn’t open source but a lot of the half-life 2 engine has already been made open source (source engine). This is why most source games are hacked very easily, especially since they’re compiled with RTTI information in tact. Valve doesn’t try to make it hard, they just rely on Valve-Anti-Cheat. Contrary to popular belief Garry’s Mod is not protected by VAC.
Most of the game’s code can be found in client.dll
, lua_shared.dll
and filesystem.dll
. The client dll is essentially the Garry’s Mod root code coupled with the source engine, and lua_shared
is this little wrapper interface around LUA. Filesystem is the dll the game uses to access files from the cache on disk, or anything file related.
How I did it
Initially I tried figuring out where the root lua_state
pointer is from all of the lua_
load calls in lua_shared
. I set breakpoints on all of the exported functions and eventually found lua_loadbuffer
calls. I climbed up the stack a little bit and then found loadbufferx
is the one we’re looking for. I won’t bore you with the details, but this sort of led nowhere. I found where it was being called code-wise, but there were too many arguments for me to make sense of them and the branching got a little crazy. Ultimately I spent a lot of time reversing the call structure around this area and found that the lua_state
pointer came from a CLuaInterface
object where the first 4 bytes were the vtable and the next 4 bytes were lua_state*
. The vtable can be found statically in memory, and in Ghidra it looks like the following (all these functions did not have names, those were added by me)
The nice thing about lua_shared
is that CreateInterface
simply returns a pointer to a location that is always the same no matter how many times you call it. So basically it returns a singleton. I found out through some research that the intent behind CreateInterface
calls in the source engine is to do exactly this: make it so that your dll has a simple interface so it can be interacted with in memory, but confirmed it with the assembly. I don’t really know how practically this would work with a class like CLuaShared though, because it seems like interacting with LUA execution on the same object runs into race conditions. I’m assuming Garry’s Mod uses locks or accesses in a single thread. Practically, this information is mildly useful if we ever want to get code to execute directly through our own function calls to CLuaShared.
Starting off
TLDR; I basically ended up finding the following function call after searching for sv_allowcslua
in memory and found that it was one of the two calls being compared against at [ecx + 30]
meaning we have ecx
as some sort of convar data structure.
bool idk_what_this_is(void) {
int local_8;
get_convar("sv_cheats");
if (((*(int *)(PTR_DAT_106239c4 + 0x14) != 1) && (*(int *)(local_8 + 0x30) == 0)) &&
(cvar_table->sv_allow_lua == 0)) {
return true;
}
return false;
}
When we cause this function to return false we get a “running script” output, but the game can’t find our lua script file. If it did, we’d be done here since we could just patch this instruction. So now we have to figure out why our file can’t be found!
Initially my theory was that you had to have the server know what file you’re going to execute, or something, so if it wasn’t on the server it wouldn’t run.
Functions 75, 76, 77 in CLuaInterface
are the ones that turned out to be really interesting. In assembly it just checks an offset from ecx
(aka param_1 aka this
) if a byte is set. This byte changes from 0x0
, 0x1
, 0x2
depending on the “locality” of the script execution it turns out. The code disassembles to
undefined __fastcall another_search_type(int param_1) {
return *(char *)(param_1 + 0xb8) == '\x01';
}
I found this function after I found out that changing the control flow of a block resulted in a comparison against this exact memory location. Function 97 is the actual call that gets executed when a lua script is opened, called from client.dll
which does something related to a console command being executed
10035fa0 68 10 c6 PUSH FUN_1007c610
07 10
10035fa5 6a 00 PUSH 0x0
10035fa7 68 70 d5 PUSH s_Open_a_Lua_script_104bd570 = "Open a Lua script"
4b 10
10035fac 68 80 c5 PUSH FUN_1007c580
07 10
10035fb1 68 50 d5 PUSH s_lua_openscript_cl_104bd550 = "lua_openscript_cl"
4b 10
10035fb6 b9 5c 59 MOV ECX,DAT_1065595c = ??
65 10
10035fbb e8 20 73 CALL FUN_103fd2e0 undefined FUN_103fd2e0(undefined
3c 00
10035fc0 68 20 b0 PUSH LAB_104ab020
4a 10
10035fc5 e8 d3 af CALL FUN_10470f9d undefined FUN_10470f9d(undefined
43 00
10035fca 59 POP ECX
10035fcb c3 RET
The code above pushes a function pointer FUN_1007c580
aka function 97, code that is executed when a lua script is trying to be loaded. It seems to be associating console commands with a function call. I would like to point out how easy it is to find console commands in the binary, they’re literally not packed strings at all (LOL!)
From here we get the following disassembled function call at FUN_1007c580
. All of the labels given to the data including the function is stuff I figured out through cross references.
Interestingly enough, this code literally just runs scripts and the print call from tier0.dll
call is visible which is the output we get when we run as script. So the only way this code doesn’t run, and something I found from debugging is if (*gmod_data_pack + 0x14)()
returns 0. That function is the one we saw earlier, so idk_what_this_is
is does_not_allow_scripts_to_run
. Notice that we don’t care what it does exactly, since we know returning false just lets us run scripts. A nice property of this function is that it’s called only once and when lua_openscript_cl
is ran which means it’s a good patch target.
Also, I found that the data reference is actually the name of the script from debugging.
So far we know that if we can get our files to be found and just patch this branch with NOPs
we will get script execution.
The next interesting little nugget is PTR_1065594c
which is a function call to CLuaInterface
function 97. The string that’s passed in is related to the types of scripts that can be executed, and “MODULE” is passed in sometimes for server modules.
From this function we can see where the “cannot include” comes from when it can’t find the script
Note that the function can return early if uVar8
is a valid address to a file.
This is sort of a jump in reasoning, but safe to say I had already reversed a lot of CLuaInterface
before getting to this point so I saw a familiar function call and found out that this was the find_script
call. This is great, and if we look at uVar7
it is referencing the search type function 76 which looks at [this + 0xB8]
aka the search type byte.
We know this is the “search type” because of the find_script
function call which I had found earlier. Equivalently you can think of this as execution type.
In a debugger when I flipped the block comparison against param_3
so it executed the param_3 == 0
flow then our script is found. This is great news, so now our goal is to flip uVar7
to 0 by making the following comparison not set AL
to 1.
We can accomplish this by setting it to anything that isn’t 0. In practice 1 is fine.
The patch
TLDR; the patch involves adding the following bytes C680B800000000
before the call to tries_to_load_lua_file_root
in client.dll
and NOP
ing the does_not_allow_scripts_to_run
check. This is equivalent to mov byte ptr [ecx + B8], 1
. I did this right before the function returned so the full patch is C680B800000000
.
When we do this and save client.dll
then run the game again we get script execution.
Tips
If you don’t want your scripts detected through static analysis (though I’m not sure if the game sends your scripts to the server) you can base64 encode them and then decode them like such
local b='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
function dec(data)
data = string.gsub(data, '[^'..b..'=]', '')
return (data:gsub('.', function(x)
if (x == '=') then return '' end
local r,f='',(b:find(x)-1)
for i=6,1,-1 do r=r..(f%2^i-f%2^(i-1)>0 and '1' or '0') end
return r;
end):gsub('%d%d%d?%d?%d?%d?%d?%d?', function(x)
if (#x ~= 8) then return '' end
local c=0
for i=1,8 do c=c+(x:sub(i,i)=='1' and 2^(8-i) or 0) end
return string.char(c)
end))
end
RunString(dec("encoded data here"))
This is useful when servers use static analysis, but I think they only check convar strings that your script registers.
Fun fact: CLuaShared
holds three references to CLuaInterface
interfaces at offset 0x5c
.