TRX CTF 25 - lost
Challenge Description
I once had a license for this script, but now all I have left is myself; just me and only me. I won’t get lost.. …
Lost Overview
We are given a lost.lua
script. A quick glance reveals that the script is obfuscated, and the input flag is defined as a global variable at the top (e.g., FLAG=TRX{goodluck_...}
). According to the challenge description, this flag represents our license key.
To ensure the script works with various Lua versions, I tested it using different interpreters, including luau and luajit 2.1.0:
$$\ $$\
$$ | $$ |
$$ | $$$$$$\ $$$$$$$\ $$$$$$\
$$ |$$ __$$\ $$ _____|\_$$ _|
$$ |$$ / $$ |\$$$$$$\ $$ |
$$ |$$ | $$ | \____$$\ $$ |$$\
$$ |\$$$$$$ |$$$$$$$ | \$$$$ |
\__| \______/ \_______/ \____/
this flag is weird, try again!
[never exit]
The script runs correctly on these interpreters, so we can proceed with examining its output.
It seems that the script does not like the default flag. I even tried running it without setting a flag:
...
Make sure to set the FLAG variable before running the challenge!
Ex: FLAG = 'TRX{goodluck}';
At the end of the script, there is a long encoded string that appears to be compressed data used during execution. For now, we treat it as bytecode
Lost First Analysis
Before diving into the complexities of lost
, we set up a proper environment for analysis. The first step is to beautify the script.
There are many tools available online or on GitHub
for example: lua-beautifier.
Now when making sure that the script still work we will notice that executing the beautified version doesn’t give us the expected output, at this point we need to figure out wheter is the beautified output problem or script integrity check…
I tried a simple modification by extending the second line (for example, to 200
). Running the script still produced the same behavior, confirming that an integrity check is indeed in place.
Returning to the beautified script..
Next, we set up a hook library to trace which library functions are used and where:
local dbg_getinfo = debug.getinfo;
local str_format = string.format;
local tbl_foreach = table.foreach;
local function hook_library(library_name, meta_methods)
local old_library = _G[library_name];
local new_mt = {};
new_mt.__old = old_library;
new_mt.__name = library_name;
for k, v in pairs(meta_methods) do
new_mt[k] = v;
end;
_G[library_name] = setmetatable({}, new_mt)
end;
local general_meta_logger = {
__index = function(self, idx)
local mt = getmetatable(self);
local index_line = dbg_getinfo(2).currentline;
print(str_format("__index: %s; index: %s from line %d", mt.__name, idx, index_line));
local value = mt.__old[idx];
return value;
end;
__newindex = function(self, idx, value)
local mt = getmetatable(self);
print(str_format(
"__newindex: %s; index: %s; old value: %s, value: %s from line %d",
mt.__name, idx, mt.__old[idx], value, dbg_getinfo(2).currentline
));
mt.__old[idx] = value;
end;
}
-- hooks
hook_library("debug", general_meta_logger)
hook_library("string", general_meta_logger)
hook_library("math", general_meta_logger)
hook_library("table", general_meta_logger)
hook_library("io", general_meta_logger)
hook_library("os", general_meta_logger)
hook_library("coroutine", general_meta_logger)
Note: This hook method works on LuaJIT but not on Luau, which enforces strict sandbox rules that prevent overwriting libraries.
The hook output reveals interesting logs:
__index: table; index: concat from line 59
__index: math; index: ldexp from line 60
__index: table; index: insert from line 68
__index: debug; index: getinfo from line 457
__index: debug; index: getinfo from line 1887
It appears the script uses debug.getinfo
to retrieve context information. Although we could further hook debug.getinfo to see which fields are accessed, but only currentline, lastlinedefined, and linedefined are relevant for the integrity check.
By removing debug.getinfo
via our hook, we notice the script attempting to use debug.traceback
. At this point, we undefine debug
library to see how the script behaves:
-- FLAG = "...
-- one of those two methods is fine
_G["debug"] = nil;
debug = nil
-- script...
Running the beautified script now works flawlessly.
Note: The script doesn’t crash if the debug library is missing, that’s for env compatibility reasons.
Beautifier Check Logic
Here is the reconstructed integrity check logic:
if debug then
if debug.getinfo(1).currentline > 100 or tonumber(debug.traceback():match(':(%d+)')); then
-- CRASH();
end;
end
At this point, I searched for a while true do
loop and, unsurprisingly, found one. This strongly suggests that the script implements a VM cycle that executes instructions:
while true do
c = l[e]
t = c.H
if t <= 101 then
if t <= 50 then
if t <= 24 then
if t <= 11 then
if t <= 5 then
if t <= 2 then
if t <= 0 then
local i
local t
-- mov operation on stack
S[c.c] = S[c.S]
-- increasing pc
e = e + 1
-- changing current instruction
c = l[e]
Instructions seem to be located using a binary search, and there are two types of program counters: one for retrieving instruction data (instruction pc) and one for locating the instruction operation (instruction vmpc).
Lost VM Analysis
Based on our observations, we expect the script to deserialize bytecode and load constants and instructions.
Scrolling up in the main VM cycle, we find the following logic:
for l = 1, c do
local e = i()
local c
-- deserialize constants based on types
if (e == 0) then
c = (i() ~= 0)
elseif (e == 3) then
c = r()
elseif (e == 2) then
c = h()
end
-- store constants
S[l] = c
end
for c = 1, e() do
-- recursively deserialize protos inside the current proto
t[c - 1] = Q()
end
l.d = i()
for l = 1, e() do
local S = i()
local c = {H = n(), c = n(), nil, nil}
-- deserialize instructions based on instruction type
-- like: iABC, iABx, iAsBx, etc
if (S == 0) then
c.S = n()
c.N = n()
elseif (S == 1) then
c.S = e()
elseif (S == 2) then
c.S = e() - (2 ^ 16)
elseif (S == 3) then
c.S = e() - (2 ^ 16)
c.N = n()
end
-- store instruction
o[l] = c
end
Lost VM First Check
Our hook system revealed an interesting log from the VM cycle:
__index: table; index: insert from line 459
At this point we improve the hook system to also hook library functions by doing so:
-- ...
__index = function(self, idx)
local mt = getmetatable(self);
local index_line = dbg_getinfo(2).currentline;
print(str_format("__index: %s; index: %s from line %d", mt.__name, idx, index_line));
local value = mt.__old[idx];
-- target VM Instructions
if index_line > 277 then
if type(value) == "function" then
return function(...)
local call_line = dbg_getinfo(2).currentline;
local func_name = mt.__name .. "." .. idx;
-- we ignore string.sub and string.char
if func_name == "string.sub" or func_name == "string.char" then
return value(...);
end
print("-----------VM CALL-----------")
print(str_format("%s called from %d args: ", func_name, call_line))
print(...)
print("-----------------------------")
return value(...);
end;
end
end;
return value;
end;
-- ...
By running the beautified script again we get the following logs:
-----------VM CALL-----------
table.insert called from 3725 args:
table: 0x010e2258 goodluck
-----------------------------
__index: table; index: insert from line 478
-----------VM CALL-----------
table.insert called from 3725 args:
table: 0x010e2258 aHR0cHM6Ly9wYXN0ZWJpbi5jb20vcmF3L3BSSEw0V1dF
-----------------------------
It seem like the script is inserting flag parts splitted by _
inside a table, we will call this table flag_parts
Let’s hook flag_parts to figure out where it is getting used
if t == 183 then
-- vmpc 183
local e = c.c
local args = {o(S, e + 1, c.S)}
local arg1, arg2 = args[1], args[2]
if type(arg1) == 'table' and type(arg2) == "string" and getmetatable(arg1) == nil then
setmetatable(arg1, {
__storage = {},
__index = function (self, idx)
local idx_line = dbg_getinfo(2).currentline
print(
str_format("access to flag_parts[%d] from %d", idx, idx_line)
)
local mt = getmetatable(self)
local st = mt.__storage;
return st[idx];
end,
__newindex = function(self, idx, value)
local mt = getmetatable(self)
local st = mt.__storage;
st[idx] = value;
end,
__len = function(self)
local mt = getmetatable(self)
local st = mt.__storage;
local idx_line = dbg_getinfo(2).currentline
print(
str_format("#flag_parts detected from %d", idx_line)
)
return #st;
end
})
end
local mt = getmetatable(arg1)
if mt then
-- aka table.insert on our storage
S[e](mt.__storage, arg2)
else
S[e](o(S, e + 1, c.S))
end
end
With this new hook in place we can find where operations on flag_parts are coming:
log: #flag_parts detected from 1214
Going to line 1214
in my beautified script I find the following instruction:
if t <= 44 then
-- vmpc 44
S[c.c] = #S[c.S]
end
We can look for the next instruction vmpc by incrementing the pc
we expect it to be a check on the len(flag_parts)
if t <= 44 then
-- vmpc 44
S[c.c] = #S[c.S]
local next_inst = l[e + 1]
local next_vmpc = next_inst.H
print("#flag_parts next vmpc " .. tostring(next_vmpc))
end
log: #flag_parts next vmpc 35
Now let’s look at vmpc 35
else
-- vmpc 35
if (S[c.c] == n[c.N]) then
e = e + 1
else
e = c.S
end
end
Wow we discovered an equality check: S[c.c] == n[c.N]
. Let’s examine the operand values:
S[c.c] = 2
;n[c.N] = 4
;`
Now to confirm this check we could patch vmpc 35 or just add two more parts to our flag..
Lost VM Part 1
By changing FLAG
to 'TRX{good_luck_test_aHR0cHM6Ly9wYXN0ZWJpbi5jb20vcmF3L3BSSEw0V1dF}';
, We notice a new output from the script, which indicates that we have successfully moved on to another check!
access to flag_parts[1] from 478
...
3 characters, 3 bytes... easy right?
which we can approach as the same way we used:
elseif t == 10 then
-- vmpc 10
S[c.c] = S[c.S][n[c.N]]
local next_inst = l[e + 1]
local next_vmpc = next_inst.H
print("flag_parts[1] next vmpc " .. next_vmpc) -- vmpc 44
else
We notice vmpc 10 is getting the first flag part and then getting its len from vmpc 44 and then move to vmpc 132
else
-- vmpc 132
print("vmpc 132", S[c.c], "~=", n[c.N]) -- vmpc 132 4 ~= 3
if (S[c.c] ~= n[c.N]) then
e = e + 1
else
e = c.S
end
end
Thanks to this check we can confirm the first flag part need to be 3 bytes long
New output:
...
do you like xor?
It seem we are not getting any interesting log and we need a new approach to figure out what is going on with the first flag part
Let’s log all unique vmpc that are getting executed when a special flag is toggled on
local log_current_function = false;
local logged_instructions = {}
while true do
c = l[e]
t = c.H
if log_current_function and logged_instructions[t] == nil then
print(str_format("%s: pc %d\tvmpc %d", dbg_getinfo(1).func, e, t))
logged_instructions[t] = true;
end
-- ...
Let’s toggle log_current_function
from vmpc 132
if (S[c.c] ~= n[c.N]) then
e = e + 1
else
e = c.S
log_current_function = true;
end
vmpc 132 3 ~= 3
function: 0x010c61d0: pc 1436 vmpc 99
function: 0x010c61d0: pc 1437 vmpc 73
function: 0x010c61d0: pc 1439 vmpc 152
function: 0x010c61d0: pc 1445 vmpc 32
function: 0x010c61d0: pc 1447 vmpc 1
function: 0x010c61d0: pc 1454 vmpc 2
function: 0x010c61d0: pc 1466 vmpc 54
function: 0x010c61d0: pc 1467 vmpc 0
function: 0x010c61d0: pc 1478 vmpc 56
__index: string; index: sub from line 1369
__index: string; index: char from line 1375
function: 0x010c61d0: pc 1495 vmpc 121
...
function: 0x010c61d0: pc 1574 vmpc 114
do you like xor?
From this log output we can notice how string.index
and string.sub
are used just before the script freezes. For this reason we inspect vmpc 0 and 56, the vmpcs likely causing the VM to jump to the crash function.
-- ...
if t <= 0 then
-- vmpc 0
-- ...
c = l[e]
t = c.c
S[t] = S[t](o(S, t + 1, c.S)) -- xor8(flag_parts[1][1], 42)
e = e + 1
c = l[e]
S[c.c] = n[c.S]
e = e + 1
c = l[e]
t = c.c
S[t] = S[t](o(S, t + 1, c.S)) -- bit32_test(flag_parts[1][1] ^ 42, 0x46)
-- x ^ 42 = 70 -> x = 'l' flag_parts[1][1] == 'l'
e = e + 1
c = l[e]
-- we can also change this to check if we bypass the check
if not S[c.c] then
e = e + 1
else
e = c.S
end
elseif t == 1 then
-- ...
We can now change the first part character to l
and from the new output we can see we moved to another check:
function: 0x010c6ea8: pc 1578 vmpc 79
function: 0x010c6ea8: pc 1591 vmpc 56
__index: string; index: sub from line 1370
__index: string; index: char from line 1376
....
bitwise operations are fun!
At this point we can go check vmpc 79
-- vmpc 79
-- ...
e = e + 1
c = l[e]
t = c.c
S[t] = S[t](o(S, t + 1, i)) -- check if flag_parts[1][2] == "a"
-- print(o(S, t + 1, i)) -- 97 => "a"
-- this will make the vm jmp to the next check
S[t] = true
New output after changing first part second character to a
:
...
function: 0x00ec6e68: pc 1692 vmpc 204
function: 0x00ec6e68: pc 1700 vmpc 56
__index: string; index: sub from line 1370
__index: string; index: char from line 1376
...
xor $r1, $r1 for the win!
-- vmpc 204
-- ...
S[t] = S[t](o(S, t + 1, c.S))
e = e + 1
c = l[e]
S[c.c] = n[c.S]
e = e + 1
c = l[e]
t = c.c
S[t] = S[t](o(S, t + 1, c.S))
-- print(o(S, t + 1, c.S)) -- flag_parts[1][3] == '4'
e = e + 1
c = l[e]
if (S[c.c] ~= n[c.N]) then -- flag_parts[1][3] check
-- ...
The first flag part is lu4
!
Lost VM Part 2
With the updated flag, the crash message changes to:
...
#flag_parts next vmpc 116
function: 0x010b6dd8: pc 1815 vmpc 116
function: 0x010b6dd8: pc 1818 vmpc 56
__index: string; index: sub from line 1370
__index: string; index: char from line 1376
...
sorry, you can't spell magic
This corresponds to vmpc 116:
Remember vmpc 116 ;)
elseif t > 115 then
-- vmpc 116
if e == 1815 then
-- 1815 => check if #flag_parts[2] == 8
print("vmpc 116", S[c.c], "~=", S[c.N], e)
end
if (S[c.c] ~= S[c.N]) then
e = e + 1
else
e = c.S
end
else
Since vmpc is frequently used we need to filter its logs based on pc
Thanks to #flag_parts
log and vmpc 116
we know we are checking #flag_parts[2] == 8
The new output:
function: 0x00eb6b28: pc 1923 vmpc 93
function: 0x00eb6b28: pc 1936 vmpc 56
__index: string; index: sub from line 1370
__index: string; index: char from line 1376
...
#flag_parts next vmpc 163
-----------VM CALL-----------
string.format called from -1 args:
0x%04X 1
-----------------------------
you are not a wizard 0x0001
Let’s look at vmpc 93
elseif t == 93 then
-- vmpc 93
local is_1933 = e == 1933
local i
local t
t = c.c
i = S[c.S]
S[t + 1] = i
S[t] = i[n[c.N]]
e = e + 1
c = l[e]
S[c.c] = S[c.S]
e = e + 1
c = l[e]
t = c.c
S[t] = S[t](o(S, t + 1, c.S))
-- return value is the char at index arg2 inside flag_parts[2]
-- we notice flag_parts[2] getting printed with a value that seem an index
-- print(S[t], o(S, t + 1, c.S))
e = e + 1
c = l[e]
S[c.c] = n[c.S]
e = e + 1
c = l[e]
S[c.c] = S[c.S] - S[c.N] -- (idx-1)
e = e + 1
c = l[e]
S[c.c] = n[c.S]
e = e + 1
c = l[e]
S[c.c] = S[c.S] % S[c.N] -- (idx-1) % 3
e = e + 1
c = l[e]
S[c.c] = S[c.S][S[c.N]] -- key[(idx-1) % 3]
-- dump xor key
local part2_key = ""
for i=0, #S[c.S] do
part2_key = part2_key .. tostring(S[c.S][i]) .. ", "
end
print(part2_key)
e = e + 1
c = l[e]
t = c.c
S[t] = S[t](o(S, t + 1, c.S)) -- xor(flag_parts[2][idx] ^ key)
e = e + 1
c = l[e]
S[c.c] = S[c.S][S[c.N]]
-- dump the xor result
local part2_xored = "";
for i=1, #S[c.S] do
part2_xored = part2_xored .. tostring(S[c.S][i]) .. ", "
end
print(part2_xored)
else
After dumping part2_xored
and part2_key
we can write a simple solve script
xored = [189, 19, 122, 254, 80, 100, 184, 91]
key = [202, 34, 0]
for i in range(len(xored)):
xored[i] ^= key[i % len(key)]
print(
('').join(map(chr, xored))
)
output:
w1z4rdry
Lost VM Part 3
By using the new flag the crash message change to:
...
#flag_parts next vmpc 163
function: 0x010c6820: pc 2048 vmpc 56
__index: string; index: sub from line 1370
__index: string; index: char from line 1376
...
you know what to do now
It seem like that after retriving #flag_parts[3]
we are not logging the vmpc responsible to perform the check, this is because logged_instructions
saturated and we need to reset it in order to log again reused vmpcs
Let’s check vmpc 116
elseif t > 115 then
-- vmpc 116
if e == 1815 then
-- 1815 => check if #flag_parts[2] == 8
print("vmpc 116", S[c.c], "~=", S[c.N], e)
end
print(e, S[c.c], "~=", S[c.N])
From the new output we can notice vmpc 116 begin used at pc 2045 to check #flag_parts[3] == 8
access to flag_parts[3] from 2131
#flag_parts next vmpc 163
2045 4 ~= 8
function: 0x010b6a28: pc 2048 vmpc 56
elseif t > 115 then
-- vmpc 116
if e == 1815 or e == 2045 then
-- 1815 => check if #flag_parts[2] == 8
-- 2045 => check if #flag_parts[3] == 8
print("vmpc 116", S[c.c], "~=", S[c.N], e)
logged_instructions = {}; -- reset log blacklist
end
#flag_parts next vmpc 163
function: 0x00ec66c0: pc 2733 vmpc 197
__index: string; index: sub from line 1370
__index: string; index: char from line 1376
#flag_parts next vmpc 163
function: 0x00ec66c0: pc 2836 vmpc 114
tables metamethods are fun!
By looking at 197 we approach this part by inspecting the stack:
-- vmpc 197
-- ...
S[t] = S[t](o(S, t + 1, c.S)) -- flag_parts[3]:sub(7, 8)
e = e + 1
c = l[e]
-- print(S[c.N]) -- flag_parts_3:sub(7, 8)
-- flag table must be on stack with a metatable
for i, v in next, S do
if type(v) == 'table' and getmetatable(v) then
print("---------TALBE_WITH_METAMETHODS---------")
tbl_foreach(v, print) -- we can reconstruct part3 easily
print("----------------------------------------")
-- m4573r3d
logged_instructions = {}
end
end
if (S[c.c] ~= S[c.N]) then
-- ...
From output:
---------TALBE_WITH_METAMETHODS---------
1 57
2 m4
3 3d
4 3r
----------------------------------------
reconstructed:
m4573r3d
YOU MADE IT! HERE IS YOUR FLAG: TRX{lu4_w1z4rdry_m4573r3d_aHR0cHM6Ly9wYXN0ZWJpbi5jb20vcmF3L3BSSEw0V1dF}