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.
In the examples that follow, I use firejail to sandbox interpreter executions. While it’s unlikely that this particular script is going to actively attack users, it’s still a good practice to deploy a strong sandboxing technology like SECCOMP-BPF or a VM (which I am incidentally also using in this case) when working with untrusted code.
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;
4if setreadonly then setreadonly(mt, false); else make_writeable(mt, true); end;
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!