Jan 28, 2026·7 min read·9 visits
The `debug.getinfo` function in xray-monolith's LuaJIT implementation relied on a development-only assertion (`api_check`) to validate input types. In production builds, this check vanished, allowing attackers to pass non-function objects (like strings) to a function that expected a function pointer. This type confusion allows for memory corruption and RCE.
A critical type confusion vulnerability in the embedded LuaJIT component of xray-monolith allows attackers to treat arbitrary data as function pointers. By exploiting a disabled debug assertion, malicious actors can bypass type safety, leading to arbitrary memory read/write and potential Remote Code Execution.
In the world of C programming, there is a deadly assumption that many developers make: that assert() protects them. It doesn't. Assertions are for debugging logic during development; they are ghosts that vanish when the compiler is set to Release mode. CVE-2026-24874 is a textbook example of what happens when security-critical logic relies on a ghost.
The target here is themrdemonized/xray-monolith, a robust system that, like many monoliths, swallowed a smaller, more complex creature whole: the LuaJIT engine. LuaJIT is famous for its speed, but speed often comes with the motto 'move fast and break things.' In this case, the xray-monolith team integrated a version of LuaJIT that contained a lurking flaw in its debug API—a flaw that turns the seemingly harmless act of inspecting a function into a weapon of mass destruction for memory safety.
Why does this matter? Because xray-monolith is often deployed in environments where user-supplied scripts or configurations might interact with the Lua state. If you can trick the engine into confusing a piece of text you wrote for a piece of executable code, you own the process. It's the binary exploitation equivalent of telling a lie so convincing that reality rearranges itself to match it.
The vulnerability resides in src/3rd party/luajit-2/src/lj_debug.c, specifically within the lj_debug_getinfo function. This function is the C-side implementation of Lua's debug.getinfo. It allows developers to retrieve metadata about a function: its name, where it was defined, its current line number, etc. One specific feature of this API is the > mode, which tells the function: "I have put the function I want you to inspect on top of the stack. Please pop it off and tell me about it."
Here is where the logic breaks down. The code needs to verify that the item on top of the stack is actually a function before it starts treating it like one. The original developer wrote this check using a macro called api_check.
> [!WARNING]
> The Fatal Flaw: api_check(L, tvisfunc(func)) expands to ((void)0) (essentially nothing) in standard release builds.
Because the check is compiled out, the code proceeds blindly. It takes whatever TValue (Lua's tagged value structure) is at L->top - 1 and forcibly casts it to a GCfunc pointer using the funcV macro. If an attacker puts a string, a table, or a number there, the engine doesn't care. It grabs the raw bits of that value and dereferences them as if they were a function structure.
Let's look at the C code to see exactly how this manifests. The use of macros in LuaJIT is heavy, intended to keep the JIT compiler blazing fast, but here it obscures a critical safety failure.
int lj_debug_getinfo(lua_State *L, const char *what, lj_Debug *ar, int ext)
{
GCfunc *fn;
if (*what == '>') {
TValue *func = L->top - 1;
// INSECURE: This check effectively deletes itself in production
api_check(L, tvisfunc(func));
// TYPE CONFUSION: 'func' is cast to GCfunc* blindly
fn = funcV(func);
L->top--;
what++;
}
// ... subsequent code dereferences 'fn' ...
}When api_check is optimized away, funcV(func) just takes the payload of the TValue and returns it. If the TValue was a Lua String, fn now points to the string's data. If the TValue was a number, fn points to whatever memory address that number represents.
The patch in Pull Request #399 is simple but vital. It replaces the macro with a hard, runtime conditional check that survives compiler optimization.
--- a/src/3rd party/luajit-2/src/lj_debug.c
+++ b/src/3rd party/luajit-2/src/lj_debug.c
@@ -440,7 +440,8 @@ int lj_debug_getinfo(lua_State *L, const char *what, lj_Debug *ar, int ext)
GCfunc *fn;
if (*what == '>') {
TValue *func = L->top - 1;
- api_check(L, tvisfunc(func));
+ if (!tvisfunc(func)) // FIX: Explicit runtime check
+ return 0;
fn = funcV(func);
L->top--;
what++;By returning 0 (failure) when the type is incorrect, the function fails gracefully instead of crashing or corrupting memory. This is the difference between a "Lua Error" and a "Segfault" (or worse).
Exploiting this requires an attacker to control the Lua stack before calling debug.getinfo. This is trivial if the attacker can execute arbitrary Lua scripts (e.g., via a plugin system or modding API). The goal is to create a "fake" function object in memory that gives us a powerful primitive, like an arbitrary read.
FFI if available) to create a block of memory that mimics the structure of a GCfunc object. This fake structure contains pointers to other fake objects (like a GCproto).debug.getinfo('>', 'n'). The 'n' flag asks for the function's name.lj_debug_getinfo pops the string but thinks it's a GCfunc.GCfunc to the fake GCproto.GCproto.-- CONCEPTUAL EXPLOIT (Simplified)
-- Assume we have a way to get the address of a string via FFI or unintended leak
local fake_struct = "\x00\x00\x00..." -- Crafted bytes mimicking GCfunc
local exploit_target = 0xdeadbeef -- Address we want to read
-- Push the fake object (which is just a string/number to Lua)
-- Trigger the bug
local success, info = pcall(debug.getinfo, '>', fake_struct)
-- If successful, 'info' might contain data leaked from 0xdeadbeefThis gives the attacker an Arbitrary Read primitive. By reading pointers, they can defeat ASLR. Once ASLR is defeated, they can craft a more complex fake object that, when debug.getinfo attempts to write to it (e.g., caching line numbers), triggers an Arbitrary Write or execution flow hijack.
Why is this rated CVSS 9.1? Because in modern exploitation, a type confusion bug in a script engine is often the "Holy Grail." It completely breaks the sandbox.
Confidentiality: High. An attacker can read any memory in the xray-monolith process. This includes API keys, database credentials, configuration secrets, and the source code of other scripts.
Integrity: High. By upgrading the read primitive to a write primitive (which is often possible by tricking the garbage collector or using other debug flags that write to the function struct), an attacker can overwrite function pointers. They could overwrite the print function's native pointer to point to system(), and then simply call print("rm -rf /").
Availability: While often overlooked, the simplest outcome of this bug is a crash. If the type confusion causes the engine to dereference an invalid pointer, the entire xray-monolith service goes down. However, sophisticated attackers will prioritize stealthy persistence over a loud crash.
If you are running xray-monolith, you need to update immediately. The vulnerability is patched in versions released after December 30, 2025.
xray-monolith. Verify the version is >= 2025.12.30.debug library within your Lua environments. In many Lua configurations, you can sandbox the environment to remove debug entirely:
local sandbox_env = {
print = print,
pairs = pairs,
-- Do explicitly NOT include 'debug'
}debug.getinfo calls with the > argument if you have visibility into the scripts being submitted.This vulnerability serves as a stark reminder: Never trust macros for security boundaries. Explicit checks save lives—or at least, they save your uptime.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
xray-monolith themrdemonized | < 2025.12.30 | 2025.12.30 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-843 |
| Attack Vector | Network |
| CVSS | 9.1 (Critical) |
| Impact | RCE / Memory Disclosure |
| Vulnerability Type | Type Confusion |
| Component | LuaJIT (lj_debug.c) |
The program allocates or initializes a resource such as a pointer, object, or variable using one type, but it later accesses that resource using a type that is incompatible with the original type.