Back to writeups
FlagYardrevHardsolved

lunatic

12 Jul 2026 · by 0xIDA

Solvers

lunatic

Author: 0xIDA
Platform: FlagYard · Category: rev · Difficulty: Hard

Solvers

Summary

Custom Lua 5.4 interpreter ((((LUnAtic 1.0)))) based on commit c33b1728... with a backdoored luaV_equalobj for length-32 tables. The script mutates both tables through 0x1336 equality ops before a final check; invert the transform chain against the hardcoded XOR targets to recover the flag.

Challenge files

  • lua — patched ELF64 Lua 5.4 (not stripped)
  • lunatic.lua — flag checker

Description: Am I going insane? (lua base c33b1728aeb7dfeec4013562660e07d32697aa6b)

Recon

./lua -v
# (((LUnAtic 1.0)))

cat lunatic.lua

The script builds two tables of bytes from:

  • magic = "lua is a cool language right??!?" (length 32)
  • user flag

then:

for i = 1,0x1336 do
    test[i] = (k1 == k2)
end
if k1 == k2 then print('Correct :)') else print('Wrong :(') end

Stock Lua tables compare by reference, so same contents still fail. The patched binary is required.

Vulnerability / backdoor

In luaV_equalobj, case 5 (LUA_TTABLE):

  1. If both tables have luaH_getn == 0x20 (32), take the custom path; else pointer compare.
  2. Process array part in 8 groups of 4 TValues (low byte of each integer):
p1 = b0 | b1<<8 | b2<<16 | b3<<24
t1 = (p1 + 0x1337beef) ^ 0x12345678   # table1 / magic

p2 = ...
t2 = (p2 + 0x4444c0de) ^ 0xabbabaab   # table2 / flag

require t1 ^ t2 == TARGET[i]
  1. Write t1/t2 back into the four slots as separate integer bytes (mutating equality).

Targets (first hardcoded, then stack constants little-endian):

0x6978c552, 0xa1f4e69e,
0xe1148ded, 0xc9dde4b7,   # 0xc9dde4b7e1148ded
0x63875f42, 0x5554fc02,   # 0x5554fc0263875f42
0x0150b8f7, 0xfc60f536    # 0xfc60f5360150b8f7

The loop runs equality 0x1336 times (each applies f1/f2 once), then the final if applies one more transform and checks. Only the final check matters for the printed result.

Solution

Let f1(x)=(x+0x1337beef)^0x12345678, f2(x)=(x+0x4444c0de)^0xabbabaab, f2_inv(y)=(y^0xabbabaab)-0x4444c0de (mod 2^32).

For each 4-byte magic chunk a0:

t1 = f1^{0x1337}(a0)
t2 = t1 ^ TARGET[i]
b0 = f2_inv^{0x1337}(t2)

Solver

#!/usr/bin/env python3
magic = b"lua is a cool language right??!?"
targets = [
    0x6978c552, 0xa1f4e69e, 0xe1148ded, 0xc9dde4b7,
    0x63875f42, 0x5554fc02, 0x0150b8f7, 0xfc60f536,
]
C1, K1 = 0x1337beef, 0x12345678
C2, K2 = 0x4444c0de, 0xabbabaab
MASK = 0xFFFFFFFF

def f1(x): return ((x + C1) & MASK) ^ K1
def f2_inv(y): return ((y ^ K2) - C2) & MASK
def pack4(bs, i): return bs[i] | (bs[i+1]<<8) | (bs[i+2]<<16) | (bs[i+3]<<24)
def unpack4(x): return bytes([x&0xff,(x>>8)&0xff,(x>>16)&0xff,(x>>24)&0xff])

rounds = 0x1337
flag = bytearray(32)
for i in range(8):
    a = pack4(magic, i*4)
    for _ in range(rounds): a = f1(a)
    b = a ^ targets[i]
    for _ in range(rounds): b = f2_inv(b)
    flag[i*4:i*4+4] = unpack4(b)
print(bytes(flag).decode())

Live verification

$ printf 'FlagY{Lua_backd00r_1s_co0l3r_:)}\n' | ./lua lunatic.lua
Enter flag:
Checking...
Correct :)

Platform submit: success (isSuccess: true).

Flag

FlagY{Lua_backd00r_1s_co0l3r_:)}

Key takeaways

  • Diff / decompile equality paths when a “simple” Lua/scripting challenge ships a custom interpreter.
  • Mutating operators + loop count means the final comparison sees fully transformed state; solve for f^{n} not f^1.
  • Magic constants (0x1337beef, 0x4444c0de, 0xabbabaab) and version string LUnAtic are strong RE hints.

Tools used

  • Flagyard MCP (download/submit)
  • file, strings, nm, r2 (disasm luaV_equalobj)
  • Python 3 (transform inversion)
  • Local ./lua lunatic.lua for live check