An introduction to code de-obfuscation with Roblox Lua

10th November, 2024 - A highly requested blog post from my Discord community

An introduction to code de-obfuscation with Roblox Lua, providing a detailed, step-by-step guide on unravelling obfuscated code which uses Lua’s features and some Roblox APIs.

Introduction

Recently, I engaged in a discussion within my community about de-obfuscating Lua code. This was requested after many were puzzled about how "random-looking" characters could possibly result in non-faulty code being executed. In this blog, we will examine a short code sample which will get the cogs in your brain working to potentially think about de-obfuscating larger scripts. Although this blog post uses Lua as an example, it is still very relevant to other scripting/programming languages as the same techniques of obfuscation are usually used.

So firstly, let's get the most basic thing out of the way:

What even is obfuscation?

In pure English, obfuscation is the action of making something obscure, unclear, or unintelligible. In the same way, when applied to code, obfuscation is used to prevent source code from being easily read or understood.
It is a common misconception that obfuscation completely hides source code from other people. In reality, obfuscation simply makes the code harder to read and understand, but with enough effort, it often can be de-obfuscated (as demonstrated by this blog post!).

Our starting point

local _ = ('t'..'a'..'s'..'k')['w'..'a'..'i'..'t']
local __ = (getfenv()[string.char(103,97,109,101)].LinkingService)
local ___ = (string[('r'..'e'..'v'..'e'..'r'..'s'..'e')])
while (0 == 0) do
    _((5^2)^-2)
    __:openUrl("\104\116\116\112\115\58\47\47"..___("\108\111\108\46\121\104\99\105\114"))
end
An image of a person being shocked by complicated-looking code.

At first glance, the code above may seem perplexing due to the string concatenations and delimiter inconsistencies, ASCII character codes, and seemingly random and confusing mathematical expressions. However, by breaking it down line by line, we can uncover its hidden functionality.

Line #1

local _ = ('t'..'a'..'s'..'k')['w'..'a'..'i'..'t']

To any experienced Lua programmer, especially one familiar with Roblox Lua, it’s clear that this line accesses the task.wait function - a function specific to Roblox.

Here's how it works: the expression ('t'..'a'..'s'..'k') concatenates individual characters to evaluate to "task", while ['w'..'a'..'i'..'t'] produces "wait". These strings are then combined to access task.wait.

In Lua, writing ("task")["wait"] is functionally equivalent to task.wait. Lua treats the first form as a lookup, where:
("task") is used as the key to access the Roblox global variable task
["wait"] specifies the wait method within task.

TL;DR:

local _ = ('t'..'a'..'s'..'k')['w'..'a'..'i'..'t']

becomes:

local _ = task.wait

Line #2

local __ = (getfenv()[string.char(103,97,109,101)].LinkingService)

In Lua, getfenv() retrieves the current environment of a function, which includes access to global variables. In Roblox, this environment includes Roblox-specific globals like game, Vector3, TweenInfo, and others.

string.char(103,97,109,101)

This part converts ASCII values into characters to form the word "game".
string.char is a standard Lua function that converts each numeric argument from ASCII to a character, concatenating them into a single string. Each number here represents an ASCII character:
103 = "g"
97 = "a"
109 = "m"
101 = "e"
Thus, string.char(103,97,109,101) returns "game".

By substituting our findings, the expression getfenv()[string.char(103,97,109,101)] simplifies to getfenv()["game"], which is equivalent to just game in this context.

Since the square brackets [ ] in Lua denote access to an element within a structure, and getfenv() essentially returns a table containing global variables (like game), we can simplify this expression by replacing getfenv()["game"] with just game. This makes the line easier to read as it becomes game.LinkingService.

So, the original line:

local __ = (getfenv()[string.char(103,97,109,101)].LinkingService)

can be simplified to:

local __ = game.LinkingService

This de-obfuscation shows how ASCII values and environment lookups can be used to obscure code, making it less readable but functionally equivalent.

Line #3

local ___ = (string[('r'..'e'..'v'..'e'..'r'..'s'..'e')])

In this line, we're working with the string library, specifically aiming to access the string.reverse function. This is achieved by concatenating individual characters to spell out the methods name.

('r'..'e'..'v'..'e'..'r'..'s'..'e')

The .. operarator in Lua concatenates strings, so ('r'..'e'..'v'..'e'..'r'..'s'..'e') evaluates to "reverse".

By placing string["reverse"] in square brackets, we’re accessing the reverse function within the string library. Using string["reverse"] is equivalent to calling string.reverse, but it adds an extra layer of obscurity.

Substituting our findings, the original line:

local ___ = (string[('r'..'e'..'v'..'e'..'r'..'s'..'e')])

simplifies to:

local ___ = string.reverse

Reflecting on Lines 1-3 (Variables)

local _ = task.wait
local __ = game.LinkingService
local ___ = string.reverse

If you have understood everything so far, give yourself a pat on the back - you have really got the hang of this! I'm genuinely glad that you have stuck with it; following along through detailed explanations of code de-obfuscation isn't always thrilling.

But look at where we are now! With the main variables and their values (functions) mapped out, we can recognise the purpose of each function or object in this code snippet. From hereon, we will dive into how these variables are used in the script.

Lines 4-7 - The "While" Loop

while (0 == 0) do
    _((5^2)^-2)
    __:openUrl("\104\116\116\112\115\58\47\47"..___("\108\111\108\46\121\104\99\105\114"))
end

Right away, we can start de-obfuscating lines 4 and 5, as they are quite straightforward. Let's go line-by-line.

Line #4

while (0 == 0) do

This condition here, (0 == 0), evaluates to true. Since 0 is always equal to 0, this expression is permanently true. In Lua, any condition that always evaluates to true creates an infinite loop, as we can see here. Therefore, we can simplify this to:

while true do

This infinite loop structure will keep running as long as the program executes, repeatedly running the code within it.

Line #5

_((5^2)^-2)

Here, we are passing a mathematical expression, (5^2)^-2, as an argument to _. Since we know that _ from Line 1 is task.wait, we are essentially calling task.wait() here.
Let's break down the math before substituting it in:

• In Lua, the ^ symbol represents the "to the power of operation".
• First, 5^2 calculates to 25 (since 5 to the power of 2 is the same as 5 * 5).
• Now we need to calculate 25^-2, which requires us to first find 25^-1 and then square that result.
• To find 25^-1, we take the reciprocal of 25. A "reciprocal" is 1 over x, where x is the number in question. Therefore, 25^-1 is 1/25 which is 0.04 in decimal. If you're very confused about reciprocals, read about them on Wikipedia.
• But remember, we are finding 25^-2, so we have to square the answer to 25^-1.
0.04^2 is equal to 0.0016.

So, our simplified math result is 0.0016. Knowing that _ is a function (indicated by the parentheses, ()), we are calling task.wait() with 0.0016 as the argument. Substituting this into our code gives us the following:

while true do
    task.wait(0.0016)
    __:openUrl("\104\116\116\112\115\58\47\47"..___("\108\111\108\46\121\104\99\105\114"))
end

This is already looking much more readable, thanks to our mental "de-obfuscator!"

Line #6 - Embedded Escape Codes

__:openUrl("\104\116\116\112\115\58\47\47"..___("\108\111\108\46\121\104\99\105\114"))

In this line, we meet ASCII encoding again. This time though, it is embedded within two strings using escape sequences. Each ASCII code follows a backslash, and this pattern makes it easy to convert the encoded values back into readable characters.

To tackle this section, we have two convenient methods for converting ASCII-encoded sequences into readable strings. First, let's use my string conversion tool, which provides an efficient and user-friendly way to decode these sequences. This tool shines here, as it allows us to instantly replace escape sequences with their corresponding characters without additional setup, making it faster and more accessible than alternative methods.

Using Richard Ziupsnys' tool to convert ASCII encoded strings to regular text
Using Richard Ziupsnys' string conversion tool to convert ASCII encoded strings to regular text

Using my tool, we can see in the screenshots that the two strings decode to "https://" and "lol.yhcir".

Alternatively, if you prefer a hands-on approach or want to test this in a local Lua runtime, you can use a Lua REPL (Read-Eval-Print-Loop). While both pure Lua and Luau can be used, Luvit is particularly convenient due to its pretty-print feature, which displays strings in a clear and readable format.

To demonstrate, I've opened up a terminal and spun up a Luvit REPL. Here's an example of how you can input ASCII-encoded strings and have Luvit display the decoded output:

A screenshot of the Luvit Lua Runtime decoding the ASCII encoded strings

Ultimately, both my tool and Luvit yielded the same results, but using my string conversion tool was much faster and more straightforward without additional commands or setup to get "https://" and "lol.yhcir".

Continuing from where we left off, we know from our earlier de-obfuscation of ___ that it actually refers to string.reverse. So, in this code, the function ___ is simply reversing "lol.yhcir" back to "richy.lol".

My tool makes this straightforward, with a convenient reverse button that transforms "lol.yhcir" into "richy.lol" with a single click:

A screenshot of Richard Ziupsny's tool easily reversing a decoded ASCII string into more recogniseable text.

With that done, we can concatenate the two parts, "https://" and "richy.lol", forming the complete URL: "https://richy.lol".
Substituting back into our line of code, we get:

__:openUrl("https://richy.lol")

And remember, from Line #2, we previously discovered that __ is a reference to game.LinkingService. So, by substituting this as well, we reach the final, fully de-obfuscated line:

game.LinkingService:openUrl("https://richy.lol")

This line instructs the Roblox client to open my website in an external browser using a hidden service which is usually only visible to Roblox Core Scripts and exploits/executors. This shows how obfuscated code can obscure even the simplest of operations!

Final Reflection

Congratulations on making it through this laborious process! While we tackled a relatively simple script, the journey of de-obfuscating should get your brain working constructively, encouraging you to think critically and dig deeper.

If you're up for a challenge, see if you can de-obfuscate something even more complex. How about diving into scripts that go beyond ASCII, like UTF encoding?
If that sounds intriguing, feel free to reach out on my socials (listed on this site). I'm always happy to see people expanding their knowledge!

As a side note and a shortcut, we could have used my string converter from the very start to simplify the ASCII conversions. My tool would have quickly translated the ASCII escape sequences into their characters and is designed to streamline de-obfuscation. More features are coming soon, making the process even easier - keep your eyes peeled!

A screenshot showcasing how we could have deobfuscated most of the code initially by just using my string conversion tool. I am Richard Ziupsnys.

In summary, we successfully de-obfuscated the following obfuscated code:

local _ = ('t'..'a'..'s'..'k')['w'..'a'..'i'..'t']
local __ = (getfenv()[string.char(103,97,109,101)].LinkingService)
local ___ = (string[('r'..'e'..'v'..'e'..'r'..'s'..'e')])
while (0 == 0) do
    _((5^2)^-2)
    __:openUrl("\104\116\116\112\115\58\47\47"..___("\108\111\108\46\121\104\99\105\114"))
end

into this more readable version:

while true do
    task.wait(0.0016)
    game.LinkingService:openUrl("https://richy.lol")
end

This process proves that obfuscation, while it makes code harder to understand, does not truly protect source code.

In this blog, you learned:
• The basics of Lua obfuscation
• How to use ASCII and escape sequences
• Environment lookups and how getfenv() functions in Lua
• How concatenation and string manipulation can disguise code functionality

I hope you enjoyed this walkthrough!
This blog took me 2 days to create due to its intense length, explanations, and the messy HTML code behind this blog haha.

Thank you for reading, and happy hacking!