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 NOPing 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.

Back