Analysis of "Modern Anti Cheat"

explained with Garry's Mod's LUA engine


  1. Introduction
  2. What is an anti cheat?
  3. "Modern Anti Cheat"

Introduction

The following document will analyze the anti-cheat system “Modern Anti Cheat”. We will go over how it works and why it’s not good. The code will be appended at the end, so that you can simply scroll down and try to analyze it for yourself too.

The Script is written in LUA, which is a game-based programming language. Garry’s Mod uses a modified version, which is also known as glua. It adds extra functions for easy game programming, and also restricts some functions so that people can’t put viruses on your PC.

LUA itself is pretty similar to Javascript. You can overwrite global functions (like console.log = 0) and you can also load external modules via require / include. There are also 2 main things i will mention: Network Messages and Detours.

“What is a detour exactly?”
You can see that the code re-defines a lot of functions that already exist, like “math.random”. They re-define it with their own 3 “check” functions before running the function itself.

local math_random = math.random
function math.random(...)
    local m_run_info = debug_getinfo(2)
    check_external(m_run_info)
    is_bad_file_name(m_run_info)
    is_bad_function(m_run_info)
    return math_random(...)
end

This now checks your runlevel everytime you use the “math.random” function. They saved the original function and changed it globally, which is known as a “detour”. If you now try and run code from a non-existant file, or from a different source like DLL Injection, then it will detect that and send a network message to the server to ban you.

“What is a network message?”
A network message is a LUA defined TCP message that can be sent by using the “net” library. An easy example:
net.Receive("receive_net_name", function()
  net.Start("send_net_name")
    net.WriteString("Test123")
  net.SendToServer()
end)

This little clientside LUA codeblock sends a network message over the channel “send_net_name” if it receives a net message from the server from the net channel “receive_net_name”. You can only have one net.Receive for every channel, and these channels have to be declared on the server first before they can be used. You can however overwrite these net.Receive functions for a channel, which means if you declare another one after the first one, the first one will be deleted.

What is an anti cheat?

Anti-Cheat Systems, or AC for short, are programs who try to detect players who are using external programs (cheats) to give themselves an advantage over others. These can range from simple “Helpers” in the game to fully developed exploits and server crashers. They mainly ruin the fun for other players, but they can even give the players using them ingame currency and other items, ruining the economy of the game.

Garry’s Mod doesn’t have an inbuilt anti-cheat, so it’s up to the server owners to protect their servers. That’s where Modern Anti Cheat comes into play. They sell it on an “Addon” site named “gmodstore.com”, with the premise of protecting your players and server from “bad people”. The problem: MAC is not good. To analyse why, we have to first discuss the types of Anti-Cheats.

Types of anti cheat

There are 2 main types of anti-cheat: Serverside AC and Clientside AC.
Clientside Anti-Cheats can detect everything, but they are insecure, because the client should never be trusted.
Serverside ACs are save from that, as players can’t easily change server code, but the server can’t detect everything by itself.

For example: An Aimbot, where a player flicks and locks onto players heads to kill them quickly, can easily be detected by the server, because you can just check how quickly and precisely someone is aiming in a game. A wallhack, where you can see other players position through walls, can’t be detected by the server, because the client is simply displaying data on screen that it already has.



"Modern Anti Cheat"

Introduction

Modern Anti Cheat is a Clientside AC. It tries to stop the player from running their own code, and tells the server if they try to run it. It tries to achieve this by checking the runlevel and origin of the function you are trying to run. It also “detours” (saves locally) many functions, so that they can’t be changed later by a cheat.

If you now use a function that was “detoured”, then it checks your runlevel, origin and even variable name too. It checks the variable names for bad ones that, for example, include words like “hack”, “cheat” or “aim”. Even if you don’t have any bad names, if you run this function from a different context than the game ( DLL cheat for example), then it tells the server to ban you. They don’t check everything, for obvious performance reasons, but they check the most important functions you use for cheating. (for example the “Draw the GUI/HUD” function, because most Wallhacks are being added there)

The Problem

Some of you may already know this from the text above, but the main problem is the networking message. The whole Anti-Cheat consists purely of the clientside, and all the server does is ban you if he gets the corresponding message. This means: If we can just force the game to never send this ban message, then we can do whatever we want. Even if we get detected, even if the code screams “Ban this guy!”, we can simply mute the networking and we are good. This problem mainly arrives from using only one non-random network channel for only banning people. Either randomize the network channel or send something else with it instead of only using it for "detection".

This sounds easier said than done, because the game doesn’t let you overwrite local variables. At the beginning of the code they save every function locally, which means you can’t simply overwrite it. They also save their own “net.Start” locally, which means they always have access to the network channel. The main method to overcome anticheats is by loading before them. Many check if you load before them, but “MODERN Anti Cheat” doesn’t do that. This means we can simply change the net.Start message to ignore the ban messages. An easy code example (which works) is the following:

local net_xyet = net.Start
function net.Start(o,p)
if o == "backup_data_transfer" then return end
  net_xyet(o,p)
end

Explanation: The first line saves the net.Start function locally, so that we can re-use it later. The second re-defines the net.Start message, and the third line checks if the network-channel-name is the “ban message” one, and if yes, then it just returns without starting a network message. If it’s not the ban network channel, then it just uses the normal net.Start method we saved earlier. We now have a re-written net.Start function which works normally except if it tries to ban us, then it doesn’t do anything.

But there is another problem. If you analyzed the Code, then you may have seen a “Backup” function, that forces the user to say “backup_data_transfer” in chat. This is a poor attempt to expose players who are simply blocking the network channel, because they will regularly spam the ingame chat with that word, which will alert admins and make them ban you manually for “Anti-Cheat Bypass”. We can simply fix this by adding the following code to the above one:
local rccf = RunConsoleCommand
function RunConsoleCommand(o,p)
  if o == "say" then return end
  rccf(o,p)
end

This is nearly the same as the above example, but instead of blocking the “ban” network channel, it blocks the “say” command from being executed, which means you won’t spam the chat if you cheat.

Now you just have to run these 2 codeblocks before your Clientside LUA files (like the anticheat) load, and you are good to go. You can execute whatever you want, and the server will never know it. I tested this on a local copy and on a real server online, and both times it worked like a charm. I won’t go over “how to actually load before everything else”, because that would be too complicated for this documents purpose.

Other Problems

Near the end of the code is a net.Receive function that sets recieved_mac_data true:

net_recieve("m_network_data", function()
    recieved_mac_data = true
    [...]
end)

If you now look for this recieved_mac_data variable you will see that this controls if the Anti-Cheat is enabled or not. That means: If you just not receive this net message named “m_network_data”, then the whole Anti-Cheat won’t work. This is because in the run_complete_checks() function it checks if the variable is true, and if not then it just won’t run the checks.

Another problem appears if we test it out with specific cheats that let you set a “fake source”, which means instead of the function being created from “outside” we just give them a fake path to an existing LUA file and tell the function internally “that’s where im from”. This way the Anti-Cheat can’t know if it’s a good or bad function. If we now set all the Cheat variables to (for example) “wayne” instead of “hack” then it won’t detect those “bad functions” and “bad convars” either. This way we can simply circumvent the whole mechanism by simply pretending to be a good script instead of a cheat one.

MAC Code

Der ganze Code ist auch schöner mit Syntax Highlighting formatiert auf Pastebin zu finden: https://pastebin.com/56xZx1Jr

--[[
File Path:   addons/modern-anti-cheat/lua/autorun/client/cl_mac.lua                          
--]]
 
-- == LOCALIZING
local m_saved_os = jit.os
local convar_meta = FindMetaTable("ConVar")
local cusercmd_meta = FindMetaTable("CUserCmd")
local convar_get_string = convar_meta.GetString
local cusercmd_set_view_angles = cusercmd_meta.SetViewAngles
local net_recieve = net.Receive
local net_start = net.Start
local net_writebool = net.WriteBool
local net_writetable = net.WriteTable
local net_writestring = net.WriteString
local net_sendtoserver = net.SendToServer
local net_readtable = net.ReadTable
local net_readbool = net.ReadBool
local net_readstring = net.ReadString
local create_client_convar = CreateClientConVar
local concommand_gettable = concommand.GetTable
local get_convar = GetConVar
local debug_getinfo = debug.getinfo
local debug_getupvalue = debug.getupvalue
local vgui_create = vgui.Create
local hook_add = hook.Add
local module_require = require
local run_string = RunString
local timer_simple = timer.Simple
local timer_create = timer.Create
local timer_remove = timer.Remove
local print_console = print
local chat_addtext = chat.AddText
local render_capture = render.Capture
local render_capture_pixels = render.CapturePixels
local render_read_pixel = render.ReadPixel
local jit_util_funck = jit.util.funck
local loop_pairs = pairs
local string_find = string.find
local string_lower = string.lower
local string_char = string.char
local table_insert = table.insert
local table_copy = table.Copy
local safe_pcall = pcall
local math_random = math.random
local math_clamp = math.Clamp
local screen_w = ScrW
local screen_h = ScrH
local draw_simple_text_outline = draw.SimpleTextOutlined
local draw_simple_text = draw.SimpleText
local run_console_command = RunConsoleCommand
-- == LOCALIZING
-- == LOCAL DATA
local m_check_tbl = {pcall, error, jit.util.funck, net.Start, net.SendToServer, net.ReadHeader, net.WriteString, util.NetworkIDToString, TypeID, render.Capture, render.CapturePixels, render.ReadPixel, debug.getinfo}
local bad_cheat_strings = {"aimbot", "aimware", "hvh", "snixzz", "antiaim", "memeware", "hlscripts", "exploit city", "odium", "backdoor"}
local bad_file_names = {"smeg", "bypass", "aimbot", "aimware", "hvh", "snixzz", "antiaim", "memeware", "hlscripts", "exploitcity", "gmodhack", "scripthook", "ampris", "skidsmasher", "gdaap", "swag_hack", "pasteware", "unknowncheats", "mpgh", "defqon", "idiotbox", "ravehack", "murderhack", "cathack"}
local bad_function_names = {"smeg", "bypass", "aimbot", "antiaim", "hvh", "autostrafe", "fakelag", "snixzz", "ValidNetString", "addExploit", "cathack"}
local bad_global_variables = {"bSendPacket", "ValidNetString", "totalExploits", "addExploit", "AutoReload", "CircleStrafe", "toomanysploits", "Sploit", "R8"}
local bad_module_names = {"dickwrap", "aaa", "enginepred", "bsendpacket", "fhook", "cvar3", "cv3", "nyx", "amplify", "hi", "mega", "pa4", "pspeed", "snixzz2", "spreadthebutter", "stringtables", "svm", "swag", "external"}
local bad_cvar_names = {"smeg", "wallhack", "nospread", "antiaim", "hvh", "autostrafe", "circlestrafe", "spinbot", "odium", "ragebot", "legitbot", "fakeangles", "anticac", "antiscreenshot", "fakeduck", "lagexploit", "exploits_open", "gmodhack", "cathack"}
local synced_cvar_names = {"sv_allowcslua", "sv_cheats", "r_drawothermodels"}
local m_check_file = true
local m_check_function = true
local m_check_globals = true
local m_check_modules = true
local m_check_cvars = true
local m_check_synced_cvars = true
local m_check_external = true
local m_check_dhtml = true
local m_check_cleaning_screen = true
local m_check_detoured_functions = true
local m_check_backup_kick = true
local m_key = "backup_data_transfer"
-- == LOCAL DATA
-- == TEMPORARY DATA
local m_current_file = "empty"
local recieved_mac_data = false
local timer_name = ""
local requested_ban = false
 
-- == TEMPORARY DATA
-- == UTIL FUNCS
local function unsafe_player_ban(b_reason, b_info)
    if (requested_ban) then return end
    net_start(m_key)
    net_writebool(true)
    net_writestring(b_reason)
    net_writestring(b_info or "No Data")
    net_sendtoserver()
    requested_ban = true
end
 
local function send_backup_message()
    if (not m_check_backup_kick) then return end
    run_console_command("say", m_key)
end
 
local function get_log_information(m_dbg_tbl)
    local m_info = ""
 
    if (m_dbg_tbl.short_src) then
        m_info = "Source: " .. m_dbg_tbl.short_src
    end
 
    if (m_dbg_tbl.name) then
        m_info = m_info .. " Function: " .. m_dbg_tbl.name
    end
 
    return m_info
end
 
local function generate_string(string_length)
    local output_str = ""
 
    for i = 1, string_length do
        output_str = output_str .. string_char(math_random(97, 122))
    end
 
    return output_str
end
 
local function m_cur()
    local m_debug_info = debug_getinfo(2)
 
    return m_debug_info.short_src or "Unknown"
end
 
local function m_check(func)
    local s, e = safe_pcall(function()
        jit_util_funck(func, -1)
    end)
 
    if (not s) then return true end
    if (debug_getinfo(func).short_src and m_current_file == debug_getinfo(func).short_src) then return true end
 
    return false
end
 
local function is_string_bad(b_string, b_table)
    for k, v in loop_pairs(b_table) do
        if (not v) then continue end
        if (string_find(string_lower(b_string), string_lower(v))) then return true end
    end
 
    return false
end
 
local function get_screen_capture()
    render_capture_pixels()
    local render_pixel_r, render_pixel_g, render_pixel_b = render_read_pixel(screen_w() / 2, screen_h() / 2)
 
    return render_pixel_r + render_pixel_g + render_pixel_b
end
 
local function check_bad_concommands()
    if (not m_check_cvars) then return end
 
    for k, v in loop_pairs(concommand_gettable()) do
        if (is_string_bad(k, bad_cvar_names)) then
            unsafe_player_ban("Bad Console Command " .. k)
        end
    end
end
 
local function check_synced_convars()
    if (not m_check_synced_cvars) then return end
    local convar_values = {}
 
    for k, v in loop_pairs(synced_cvar_names) do
        table_insert(convar_values, {
            ["convar"] = v,
            ["value"] = convar_get_string(get_convar(v))
        })
    end
 
    net_start("m_check_synced_data")
    net_writetable(convar_values)
    net_sendtoserver()
end
 
local function check_global_variables()
    if (not m_check_globals) then return end
 
    for k, v in loop_pairs(bad_global_variables) do
        if (_G[v]) then
            unsafe_player_ban("Bad Function/Variable " .. v)
        end
    end
end
 
local function check_external(m_dbg_tbl)
    if (not m_check_external) then return end
    if (not m_dbg_tbl or not m_dbg_tbl.short_src) then return end
 
    if (m_dbg_tbl.short_src == "external") then
        unsafe_player_ban("External bypass ", get_log_information(m_dbg_tbl))
    end
end
 
local function is_bad_function(m_dbg_tbl)
    if (not m_check_function) then return end
    if (not m_dbg_tbl or not m_dbg_tbl.name) then return end
 
    if is_string_bad(m_dbg_tbl.name, bad_function_names) then
        unsafe_player_ban("Bad function name " .. m_dbg_tbl.name, get_log_information(m_dbg_tbl))
    end
end
 
local function is_bad_file_name(m_dbg_tbl)
    if (not m_check_file) then return end
    if (not m_dbg_tbl or not m_dbg_tbl.short_src) then return end
 
    if is_string_bad(m_dbg_tbl.short_src, bad_function_names) then
        unsafe_player_ban("Bad file name " .. m_dbg_tbl.short_src, get_log_information(m_dbg_tbl))
    end
end
 
local function check_screen_cleaner()
    if (not m_check_cleaning_screen or m_saved_os == "OSX" or m_saved_os == "Linux") then return end
 
    if (get_screen_capture() ~= 0) then
        unsafe_player_ban("Screen capture returned invalid results")
    end
end
 
local function check_detoured_functions()
    if (not m_check_detoured_functions) then return end
 
    for k, v in loop_pairs(m_check_tbl) do
        if not m_check(v) then
            unsafe_player_ban("Detoured a function located at " .. debug_getinfo(v).short_src)
        end
    end
end
 
local function run_complete_checks()
    if (not recieved_mac_data) then return end
 
    if (requested_ban) then
        send_backup_message()
    end
 
    safe_pcall(function()
        check_bad_concommands()
        check_synced_convars()
        check_global_variables()
        check_screen_cleaner()
        check_detoured_functions()
    end)
end
 
-- == UTIL FUNCS
-- == STARTUP CHECK
m_current_file = m_cur()
 
for k, v in loop_pairs(m_check_tbl) do
    if not m_check(v) then
        unsafe_player_ban("Detoured backend functions before autorun")
    end
end
 
-- == STARTUP CHECK
-- == DETOURED FUNCTIONS
function table.Copy(...)
    local m_run_info = debug_getinfo(2)
    check_external(m_run_info)
    is_bad_file_name(m_run_info)
    is_bad_function(m_run_info)
 
    return table_copy(...)
end
 
function require(args)
    local m_run_info = debug_getinfo(2)
    check_external(m_run_info)
 
    if (is_string_bad(args, bad_module_names) and m_check_modules) then
        unsafe_player_ban("Bad module " .. args, get_log_information(m_run_info))
    end
 
    is_bad_file_name(m_run_info)
    is_bad_function(m_run_info)
    module_require(args)
end
 
function vgui.Create(...)
    local m_run_info = debug_getinfo(2)
    check_external(m_run_info)
    is_bad_file_name(m_run_info)
    is_bad_function(m_run_info)
 
    return vgui_create(...)
end
 
function hook.Add(...)
    local m_run_info = debug_getinfo(2)
    check_external(m_run_info)
    is_bad_file_name(m_run_info)
    is_bad_function(m_run_info)
    hook_add(...)
end
 
function RunString(code, identifier, HandleError)
    local m_run_info = debug_getinfo(2)
 
    if (m_run_info.short_src and m_run_info.short_src == "lua/vgui/dhtml.lua" and m_check_dhtml) then
        if (is_string_bad(code, bad_function_names)) then
            unsafe_player_ban("Bad RunString from DHTML")
        end
    end
 
    check_external(m_run_info)
    is_bad_file_name(m_run_info)
    is_bad_function(m_run_info)
 
    return run_string(code, identifier, HandleError)
end
 
function CreateClientConVar(name, default, shouldsave, userdata, helptext)
    local m_run_info = debug_getinfo(2)
 
    if (is_string_bad(name, bad_cvar_names) and m_check_cvars) then
        unsafe_player_ban("Bad cvar " .. name, get_log_information(m_run_info))
    end
 
    check_external(m_run_info)
    is_bad_file_name(m_run_info)
    is_bad_function(m_run_info)
 
    return create_client_convar(name, default, shouldsave, userdata, helptext)
end
 
function debug.getinfo(...)
    local m_run_info = debug_getinfo(2)
    check_external(m_run_info)
    is_bad_file_name(m_run_info)
    is_bad_function(m_run_info)
 
    return debug_getinfo(...)
end
 
function timer.Remove(id_str)
    local m_run_info = debug_getinfo(2)
 
    if (id_str == timer_name) then
        unsafe_player_ban("Tried to remove timer " .. id_str, get_log_information(m_run_info))
    end
 
    check_external(m_run_info)
    is_bad_file_name(m_run_info)
    is_bad_function(m_run_info)
 
    return timer_remove(id_str)
end
 
function math.random(...)
    local m_run_info = debug_getinfo(2)
    check_external(m_run_info)
    is_bad_file_name(m_run_info)
    is_bad_function(m_run_info)
 
    return math_random(...)
end
 
function math.Clamp(...)
    local m_run_info = debug_getinfo(2)
    check_external(m_run_info)
    is_bad_file_name(m_run_info)
    is_bad_function(m_run_info)
 
    return math_clamp(...)
end
 
-- == DETOURED FUNCTIONS
-- == NETWORK RECIEVERS
net_recieve("m_validate_player", function()
    net_start("m_validate_player")
    net_sendtoserver()
end)
 
net_recieve("m_network_data", function()
    recieved_mac_data = true
    m_check_file = net_readbool()
    m_check_function = net_readbool()
    m_check_globals = net_readbool()
    m_check_modules = net_readbool()
    m_check_cvars = net_readbool()
    m_check_synced_cvars = net_readbool()
    m_check_external = net_readbool()
    m_check_dhtml = net_readbool()
    m_check_cleaning_screen = net_readbool()
    m_check_detoured_functions = net_readbool()
    m_check_backup_kick = net_readbool()
    m_key = net_readstring()
end)
 
-- == NETWORK RECIEVERS
-- == TIMERS
timer_name = generate_string(18)
timer_create(timer_name, 45, 0, run_complete_checks)
 
-- == TIMERS
-- == SETUP
timer_simple(0, function()
    net_start("m_loaded")
    net_sendtoserver()
end)
-- == SETUP