The code I wrote for this article can be found here.
When detecting cheats in the game Garry’s Mod you have countless options. Suppose we want to detect a known Lua file (containing a cheat) that a client is executing. Among various possibilities there are the following options:
- Checking for global variables (e.g.
if CHEAT_CONFIG then ... end
) - Checking for global functions (e.g.
if isfunction(OpenCheat) then ... end
) - Checking for hooks (e.g. if
hook.GetTable()["Think"]["Aimbot"] then ... end
) - Detour functions (like
RunString()
) and check the content of the arguments
Cheat developers are not stupid and localize their variables and functions in most cases:
-- cheat (executed first)
local CHEAT_CONFIG = {...}
local runstring = RunString
runstring([[
-- more cheat code
]])
-- anticheat
-- global variable is not accessible any more
if CHEAT_CONFIG then
BanPlayer(...)
end
-- function won't get called
local runstring = RunString
function RunString(code, ...)
if code == "any cheat code" then
BanPlayer(...)
end
end
If done correctly, there are hardly any possibilities to recognize this cheat clientside with conventional methods.
In Garry’s Mod scripts are JIT compiled using LuaJIT 2.1.0-beta3. If new Lua code is executed, it is compiled before execution. However, the compilation process can be hooked. You can attach callbacks to a number of compiler events with jit.attach
.
In combination with debug.getinfo
, details such as the name of the function can be retrieved for each individual function that has been translated into bytecode:
local function byte_code(proto)
debug.sethook(function(event, line)
-- get the name fields and location fields
local dbg = debug.getinfo(2, "nS")
if not dbg then return end
PrintTable(dbg)
end, "c")
end
jit.attach(byte_code,"bc")
We execute the following code:
RunString([[
-- useless code for demonstration
local string_len = string.len
local i_pairs = ipairs
local secret = {"a", "b"}
local new_secret = ""
for i, v in i_pairs(secret) do
new_secret = new_secret .. v
end
local len = string_len(new_secret)
]])
Every function and variable is localized. In a realistic scenario, the code would not be executed via RunString, but would be injected by an external program. However, our method would still work:
["lastlinedefined"] = 9 |
["lastlinedefined"] = -1 |
["lastlinedefined"] = -1 |
["lastlinedefined"] = -1 |
["lastlinedefined"] = -1 |
["lastlinedefined"] = -1 |
Now we can define a table that contains the values of the “name” key. This allows us to create a kind of signature for cheat code and assign the name of the cheat to it. If all these names are executed by a client in a sequence, we can identify the cheat:
local calls = {["i_pairs"] = {
functions = {
"(for generator)",
"(for generator)",
"(for generator)",
"string_len"
},
count = 4,
cheat_name = "Super Epic Cheat V2 Ultra Pro++"
}}
Now we adjust the function:
local function byte_code(proto)
local current_check = {}
local cntr = 1
local nm = ""
local fin = 1
debug.sethook(function(event, line)
-- get the name fields and location fields
local dbg = debug.getinfo(2, "nS")
if not dbg then return end
local name = dbg.name
if not name then return end
local call = calls[name]
-- we found the first call that might fit the cheat pattern
if call then
current_check = call.functions
cntr = 1
fin = call.count
nm = call.cheat_name
return
end
-- check if name matched the next expected function
if current_check[cntr] == name then
-- increment the counter to the next expected function name
cntr = cntr + 1
-- if we reached the end of the function list, print the cheat
if cntr > fin then
debug.sethook()
print(string.format("Detected cheat %q", nm))
end
else
current_check = {}
cntr = 1
fin = 1
end
end, "c")
end
Output: Detected cheat "Super Epic Cheat V2 Ultra Pro++"
Optimization
The above code is inefficient, especially with real-time applications and the expectation of a high frame rate. A debug hook should only be active for as short a time as possible. If a player does not use this particular cheat, the hook will continue to run from the first bytecode compilation until the player exits the game. We therefore expect a match with the cheat signature in the first 30 function calls. If there is still no signature match by then, we abort the debugging:
local function byte_code(proto)
-- ...
local cntr_total = 0
debug.sethook(function(event, line)
cntr_total = cntr_total + 1
-- only check first 30 calls
if cntr_total > 30 then debug.sethook() end
-- ...
This was a quick rundown of an idea I had. If you are interested, you can find my anticheat on GitHub and Steam Workshop.