Reversing a Roblox Cheat

Deobfuscating scripts for fun (but not profit)

As one aspect of educational outreach, I sometimes agree to mentor high school students in a security-related project. One of these turned out to require script deobfuscation to proceed with the project’s objective. Since dynamically unpacking an obfuscated script is a tall ask for a university-level student (let alone someone in high school), I spent some time doing just that and decided to write it up here.

Game Cheats

First off, some context. The student’s objective is to learn how game cheats work, and ideally to devise an “anti-cheat” – code that detects whether a cheat is in use in order to block cheaters. Of course, client-based anti-cheats are fundamentally evadable on untrusted clients. That is, given enough time and effort a motivated cheater with full control over the client system can detect and disable cheat prevention mechanisms implemented at the client. Nevertheless, gaining first-hand knowledge of the limits of client-based anti-cheats is a great pedagogic exercise! And, in practice, client-side detection in conjunction with anti-tamper defenses can be performed with some degree of success.

The nature of a cheat greatly depends on the game. In the abstract, the general idea is to modify the game client to relax or remove game restrictions in such a way as to make it trivial to achieve victory conditions in the game. The game in question for the project is Counter Blox, a clone of the well-known Counter-Strike FPS on the Roblox game platform. So, in this context a cheat might give a player capabilities like unreasonable speed and health points, flight, the ability to see and move through solid obstacles, or aim assistance.

Of course, games and game platforms are incentivized to detect and block cheaters. Cheat developers in turn obfuscate their code to make it more difficult to develop detection signatures or determine what the cheat actually does. Removing static obfuscation is often a necessary first step in recovering program semantics from cheats or general malware.

The cheat that the student decided to start with is published on GitHub. The repository contains an entry point that dispatches to a game-specific cheat. In the following, we will be reversing its Counter Blox cheat script.

Unpacking the Cheat

As is typical with script-based malware, the cheat is minimized. That is, unnecessary whitespace has been removed, and variable names have been shortened which has the side effect of removing otherwise helpful identifiers. Thus, the first step is to reformat or “beautify” the code so that we can visually parse it. I used an online Lua beautifier for that purpose, which immediately improves the readability situation as the following before and after snippets demonstrate.

1return(function(B,L,e,E)local d=setmetatable;local D=unpack or table.unpack;local c=table.insert;--
1return (function(B, L, e, E)
2    local d = setmetatable
3    local D = unpack or table.unpack
4    local c = table.insert
5    local V = string.byte
6    local K = string.sub
7    local f = select
8    local Q = string.char
9    -- ...

Here’s a formatted version of the script in case you want to follow along.

With that out of the way, we start the real work. Beautifiers cannot restore variable names, so that information has been irrevocably lost. Also, as we can see above the obfuscator has assigned the Lua API functions it will use to nondescript variables to make it more difficult to recover the unpacking algorithm. Not shown here but used elsewhere in this script is reuse of these variable names in other scopes but for different purposes, which has the effect of further confusing reversing efforts. This is another typical tactic used by script obfuscators.

As an aside, I did make an effort to find the obfuscator used on this script in the hopes that a deobfuscator was available. There is actually a highly active community engaged in Lua obfuscator development (e.g., Perth Scripting Utilities, PY44N/Lua-Obfuscator), but it wasn’t immediately obvious which one had produced this particular example. And, it doesn’t appear that deobfuscators for existing engines are readily available, for somewhat obvious reasons.

At this point, we have two main options. We could try to statically recover the unpacking algorithm to reveal the obfuscated cheat payload. But, that would be a slog. The other option is to try to run the cheat and have it unpack itself. Ideally, it will simply decode the actual cheat code from an obfuscated string and then pass it to load for evaluation. Our goal would then be to interrupt execution at that point to dump the unobfuscated cheat in order to understand how it works.

This sort of dynamic analysis is the classic answer to static obfuscation, but it requires an interpreter at a minimum (and more, as we will see later on). I decided to take the (sane) dynamic approach. Roblox uses a custom interpreter for a typed variant of Lua called Luau, which will serve as the basis for our dynamic analysis.

So, let’s run the cheat in Luau and see what we get.

1$firejail --quiet --private=$(pwd) ./luau counterblox_formatted.lua
2counterblox_formatted.lua:1338: attempt to index nil with 'GetService'
3stacktrace:
4counterblox_formatted.lua:1338
5counterblox_formatted.lua:2286
6counterblox_formatted.lua:2287

Okay, interesting. We have a crash in the formatted script that appears to be trying to reference a Roblox API to fetch a service provider. Let’s take a look at that point in the script.

1329elseif C == 63 then
1330    local K
1331    local C
1332    n[e[B]] = A[e[5]]
1333    l = l + 1
1334    e = o[l]
1335    C = e[B]
1336    K = n[e[5]]
1337    n[C + 1] = K
1338    n[C] = K[e[1]]      -- <-- Crash!  e[1] = "GetService", but K is nil.

Uh oh. This is not the unpacked, unobfuscated cheat script we were hoping to see. Instead, the cheat appears to have been virtualized using a custom VM to hinder analysis. That is, the cheat code, originally written in Lua, has been compiled down to bytecode instructions for a custom virtual machine. This makes it much more difficult to understand how the cheat works since the original Lua code cannot be (automatically) recovered without writing a custom decompiler for the VM. (Somewhat ironically, this technique is exactly what anti-tamper defenses like VMProtect use.)

To be honest, I was not expecting to see this level of sophistication for a Roblox cheat. I’m far more familiar with these sorts of techniques in the binary and JavaScript malware settings. However, given the incentives involved on both sides (), it does make sense in retrospect that cheaters would deploy strong obfuscation to protect their code.

Reversing the Cheat “VM”

Now that we have a better idea of what we’re dealing with, the analysis strategy has changed. Instead of looking for a load call to evaluate a packed cheat payload, we should now be looking for an interpreter loop that executes the virtualized cheat’s bytecode instructions. And, with a short search, we find it:

217while true do
218    e = o[l]        -- Instruction (e) at o[l]
219    C = e[2]        -- Opcode (C) at e[2]
220    if C <= 64 then
221        if C <= 31 then
222            if C <= 15 then
223                if C <= 7 then
224                    if C <= B then
225                        if C <= 1 then
226                            if C == E then

This is clearly just a snippet, but examination of the surrounding code makes a few facts clear. C, which we also saw in the earlier snippet at line 1329, is an instruction opcode. That is, it’s used to dispatch to an instruction handler in a deeply-nested and obfuscated if-else structure. C is taken from e, a table that in turn is taken from o at index l. So, l is likely to be the virtual program counter, making o the instruction buffer.

Let’s try to dump the instruction buffer. It’s as simple as prepending the following code to the evaluation loop:

1for k, v in pairs(o) do         -- Iterate over each instruction
2    print(k, v)
3    for k, v in pairs(v) do     -- Dump every member of each instruction table
4        print("\t", k, v)
5    end
6end

Executing this will result in the following output:

 1$firejail --quiet --private=$(pwd) ./luau counterblox.lua
21       table: 0x000000001b6e7ef8
3                3       0
4                2       63
5                5       game
62       table: 0x000000001b6e7e88
7                3       0
8                2       0
9                5       0
10                1       GetService
113       table: 0x000000001b6e7e18
12                3       2
13                2       0
14                5       Players
154       table: 0x000000001b6e7da8
16                3       0
17                2       0
18                5       2
19                1       2
205       table: 0x000000001b6e7d38
21                3       1
22                2       0
23                5       0
24                1       LocalPlayer

This looks promising, and suggests that we do not have any integrity measurement defenses to worry about. Each of the numbered entries is an “instruction” in the custom VM, or at least they represent a stream of instructions and data.1 It raises further questions, however. If index 2 is the opcode, why do the following entries all have 0 values? And, what are indices 3 and 5 used for?

Regardless, with some Roblox API searches, we can surmise from the first few strings that the script first attempts to get a handle to the Players service by calling game:GetService("Players"). This highlights a problem for our analysis: we have an interpreter, but we do not have an implementation of the Roblox API! So, any references to that API will cause a crash, as we just saw when the script tried to resolve GetService. This is analogous to trying to execute JavaScript intended for a web browser in a bare interpreter – without access to browser APIs like the DOM, the script will similarly crash.

Synthesizing an Environment

Luckily, this is a known issue that has been studied in the context of web browsers and JavaScript malware by some friends of mine. In particular, Hulk, which was published some years ago at USENIX Security, addressed this problem by automatically adapting the browser execution environment to match what a malicious script expects. For example, if a malicious script tried to access a particular element in a DOM, Hulk would create it on-the-fly. In this way, the script’s execution could continue and, eventually, any malicious activity it would perform would be elicited for analysis.

Put another way, Hulk automatically synthesizes the environment a script expects to successfully execute. To my knowledge, a similar tool does not exist for Lua. Nevertheless, we can manually use the same technique to iteratively create the APIs and values that the cheat expects and uses in order to complete its execution.

For example, let’s consider our current roadblock. Our hypothesis is that the script tries to call GetService on the game table which fails because game is nil (i.e., it is undefined). So, let’s define it! If we add game = {} to the script and execute it, we get the following:

1$firejail --quiet --private=$(pwd) ./luau counterblox_formatted.lua
2counterblox_formatted.lua:1347: attempt to call a nil value
3stacktrace:
4counterblox_formatted.lua:1347
5counterblox_formatted.lua:2288
6counterblox_formatted.lua:2289

Success (of a kind)! We crashed at a different location, a few instructions past the initial crash due to game == nil. (Line numbers are slightly offset due our script modification.)

1331elseif C == 63 then
1332    local K
1333    local C
1334    n[e[B]] = A[e[5]]                   -- Get reference to game
1335    l = l + 1                           -- Increment the program counter
1336    e = o[l]                            -- Get the current bytecode instruction
1337    C = e[B]                            -- Get a memory store index
1338    K = n[e[5]]                         -- Get reference to GetService
1339    n[C + 1] = K                        -- Store reference in memory (n)
1340    n[C] = K[e[1]]                      -- Previous crash, fixed by adding game
1341    l = l + 1                           -- Increment the program counter
1342    e = o[l]                            -- Get the current bytecode instruction
1343    n[e[B]] = e[5]                      -- Store reference to "Players"
1344    l = l + 1                           -- Increment the program counter
1345    e = o[l]                            -- Get the current bytecode instruction
1346    C = e[B]                            -- Get a memory store index
1347    n[C] = n[C](D(n, C + 1, e[5]))      -- New crash!  n[C] is nil.

Looking at this, we see that the problem is that n[C] is nil. (D is set to refer to the unpack API function at the start of the script.) If we trace backwards, we find that n[C] is assigned K[e[1]] at line 1340. You might remember that we’ve seen this before: K[e[1]] is the reference to game:GetService that crashed earlier!

So, our game stub object worked, but now we need to define GetService. Let’s do so by adding something like the following:

1function game:GetService(id)
2    print("game:GetService(" .. tostring(id) .. ")")
3    if id == "Players" then
4        return {}
5    end
6    return nil
7end

Now, when we run the script, we get this output:

1$firejail --quiet --private=$(pwd) ./luau counterblox_formatted.lua
2game:GetService(Players)
3game:GetService(Workspace)
4counterblox_formatted.lua:1376: attempt to index nil with 'CurrentCamera'
5stacktrace:
6counterblox_formatted.lua:1376
7counterblox_formatted.lua:2295
8counterblox_formatted.lua:2296

More progress! With the debug statement we added, we can see that the script gets the Players and Workspace services. But, now it crashes when trying to reference CurrentCamera. With a quick search, we find that this is a property on a Workspace which aligns with our debug output.

At this point, we’ve shown that the stub object technique (or manual environment synthesis) works. However, it’s still slower going than we would like. Some crashes contain useful information that suggests a missing API or value, but others require examining the cheat bytecode around the crash to figure out what is missing. What would really help in those cases would be to automatically log accesses to table properties and methods.

Logging Table Accesses

In fact, thanks to the dynamic nature of Lua, we can easily do exactly this. To understand how, it helps to have some context for how Lua implements OOP. In Lua, classes and objects are implemented on top of tables (or dictionaries). Therefore, properties and methods on objects are simply syntactic sugar for keyed dictionary references.

This is important because Lua provides a built-in mechanism for interposing on table accesses via metatables and their __index and __newindex methods. Simply put, assigning a metatable to a table will cause the Lua interpreter to automatically invoke the metatable’s __index method for any non-existent key references on the associated table, and __newindex for any writes to non-existent keys. We can leverage this infrastructure by adding code like the following:

 1-- Wrap tables to track accesses
2function wrap_table(n, t)
3    local wrapper = {}
4    local mt = {
5        __index = function(_t, k)
6            v = t[k]
7            print("R " .. n .. "[" .. tostring(k) .. "] = " .. tostring(v))
8            return v
9        end,
10        __newindex = function(_t, k, v)
11            print("W " .. n .. "[" .. tostring(k) .. "] = " .. tostring(v))
12            t[k] = v
13        end
14    }
15    setmetatable(wrapper, mt)
16    return wrapper
17end
18
19game = wrap_table("game", {})

wrap_table has the effect of logging all reads of missing keys and writes to new keys. We can then apply it to game as at line 19. Let’s see how this helps by revisiting the second crash we encountered, where the script tried to execute a nil function since we had not yet defined GetService. Re-running the script with table access instrumentation, we get the following output:

1$firejail --quiet --private=$(pwd) ./luau counterblox_formatted.lua
2R game[GetService] = nil
3counterblox_formatted.lua:1372: attempt to call a nil value
4stacktrace:
5counterblox_formatted.lua:1372
6counterblox_formatted.lua:2313
7counterblox_formatted.lua:2314

Recall that before we had to go dig through the cheat bytecode to figure out which function needed to be defined to fix the problem. Now, the issue is apparent from the table access instrumentation at line 2, which helps to speed up the code-test-debug cycle that we will have to run for a few hundred bytecode instructions.

Recovering the Cheat Algorithm

Continuing the process, we can fairly efficiently coerce the script to continue executing. Without going into too much further detail, the script obtains references to a number of game services using GetService, and then issues an HttpGetAsync call back to the GitHub repository to fetch a UI library. This is evaluated and then used to instantiate a number of UI elements like buttons, sliders, and drop-downs that allow cheaters to execute various actions like aimbotting and wall banging.

Once the UI is in place, the script eventually evaluates the main cheat logic in a load call which is ironically what we were looking for in the first place! Lucky us.

 1local mt = getrawmetatable(game);
2local oldNewIndex = mt.__newindex;
3local oldNamecall = mt.__namecall;
5local namecallMethod = getnamecallmethod or get_namecall_method;
6local newClose = newcclosure or function(f) return f; end;
7
8mt.__newindex = newClose(function(t, k, v)
9    if thirdPerson then
10        if tostring(k) == "CameraMaxZoomDistance" then
11            return oldNewIndex(t, k, thirdPersonDistance);
12        end;
13        if tostring(k) == "CameraMinZoomDistance" then
14            return oldNewIndex(t, k, thirdPersonDistance);
15        end;
16    end;
17    if tostring(t) == "Humanoid" then
18        if tostring(k) == "JumpPower" and jump ~= 20 then
19            return oldNewIndex(t, k, jump);
20        end;
21        if tostring(k) == "WalkSpeed" and speed > 16 then
22            return oldNewIndex(t, k, speed);
23        end;
24    end;
25    if tostring(t) == "CurrentCamera" and tostring(k) == "FieldOfView" then
26        return oldNewIndex(t, k, fov);
27    end;
28
29    return oldNewIndex(t, k, v);
30end);
31
32mt.__namecall = newClose(function(...) [nonamecall]
33    local method = namecallMethod();
34    local args = {...};
35
36    if tostring(method) == "FindPartOnRayWithIgnoreList" and tostring(args[3][3]) == "Ray_Ignore" then
37        if wallbang then
38            table.insert(args[3], workspace.Map);
39        end;
40        local closest = closest;
41        if silentAim and closest then
42            closest = (aimPart == "Torso" and closest.Character.HumanoidRootPart or closest.Character[aimPart]);
43            local origin = args[2].Origin;
44            return oldNamecall(workspace, Ray.new(origin, (closest.Position - origin).Unit * 1000), args[3]);
45        end;
46    end;
47
48    return oldNamecall(...);
49end);
50
51if setreadonly then setreadonly(mt, true); else make_writeable(mt, false); end;

As you can see, the cheat uses some of the same techniques we used to interpose on table accesses. In this case, it intercepts updates to the camera or player attributes to implement third-person field of vision or enable fast movement. It also intercepts the FindPartOnRayWithIgnoreList method to implement an aimbot.

Conclusion

So, there you have it. Now that cheat algorithm has been unpacked, hopefully the student project can continue as planned. There are a few potential avenues for detecting and blocking a script like this one can imagine. I’m looking forward to seeing what the student comes up with!

1. In fact, the opcode implementations range from trivial (1 Lua statement) to hundreds of statements. Thus, the VM is not a classic VM, or at the least it can be extremelyCISCy.”↩︎