{"article":{"slug":"deobfuscating-luraph-lifting-lua-to-llvm-then-letting-ai-try","title":"Deobfuscating Luraph: Lifting Lua to LLVM, Then Letting AI Try","subtitle":null,"summary":"Naci lifts Luraph-protected Lua to LLVM IR, explains why dynamic table operations survive optimization, and tests how far AI-assisted tooling and partial evaluation get at stripping the VM — and where AI just produces plausible-looking output.","content_type":"blog_post","language":"en","canonical_url":"https://nac-l.github.io/2026/10/12/deobfuscating-luraph.html","author":{"name":"Naci","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Naci’s blog","url":"https://nac-l.github.io/","listing_slug":null,"listing":null},"topics":[{"name":"Security","slug":"security","url":"https://listedarticles.com/topics/security"},{"name":"AI","slug":"ai","url":"https://listedarticles.com/topics/ai"},{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":3827,"reading_minutes":17,"published_at":"2026-10-12T00:00:00.000Z","added_at":"2026-10-14T11:13:25.906Z","updated_at":"2026-10-14T11:13:25.906Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/deobfuscating-luraph-lifting-lua-to-llvm-then-letting-ai-try","markdown_url":"https://listedarticles.com/articles/deobfuscating-luraph-lifting-lua-to-llvm-then-letting-ai-try.md","example":false,"citation":"Naci, Naci’s blog. \"Deobfuscating Luraph: Lifting Lua to LLVM, Then Letting AI Try.\" 12 Oct 2026. https://nac-l.github.io/2026/10/12/deobfuscating-luraph.html (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://nac-l.github.io/2026/10/12/deobfuscating-luraph.html"},"body_markdown":"# Deobfuscating Luraph: Lifting Lua to LLVM, Then Letting AI Try\n\n# Intro\n\nA while back, I was playing World of Warcraft with a friend. We used [Hekili](https://github.com/Hekili/hekili), a Lua addon that recommends which ability to use next. Recommending a spell and automatically casting it are two very different things, though.\n\nOn [June 17, 2025](https://worldofwarcraft.blizzard.com/en-us/news/24208341), Blizzard introduced Assisted Highlight and the Single-Button Assistant: built-in ability recommendations and a button that casts the next recommended ability, with an additional global-cooldown penalty. Separately, on [October 23, 2025](https://worldofwarcraft.blizzard.com/en-us/news/24244638), Blizzard explained its planned Midnight restrictions on addons using real-time combat information to make decisions for players.\n\nAround the time I stopped playing, I came across a paid tool claiming to automate rotations through a macro-related workaround. Naturally, I wanted to know how it worked.\n\nThe script was protected by Luraph. That rabbit hole is how I ended up messing with Lua deobfuscation, and in this post I’ll go over what I tried.\n\n# The Lua Lifter — luau-rewrite\n\n[Luau](https://luau.org/compatibility/) is Roblox’s fork of Lua 5.1: faster, sandboxed, optional types. Most Lua 5.1 code works, but not all of it.\n\nMy idea was to get Luau’s compiler to emit LLVM IR instead of Luau bytecode, then use LLVM’s optimizations to simplify the obfuscated logic. Luau already handles parsing and compiling the source, so I’d just need to replace the backend, right?\n\nNope. Emitting LLVM IR is only part of the job: that IR also needs to preserve dynamic operations such as table lookups, function calls, and runtime type checks. And compiling those operations doesn’t necessarily make them easier to understand. (In the end, `luauc` doesn’t even reuse Luau’s frontend: it has its own small handwritten parser for a subset of Lua.)\n\nLua is a dynamically typed language, but that doesn’t mean you can’t lift it. Types belong to values rather than fixed variable declarations; a compiler can still infer types in some places. Where it can’t, you can represent values with tags and delegate dynamic operations to helper functions.\n\n```\n-- human-hallucinated code below\nlocal Paladin = {}\nfunction Paladin:recomendspell()\n    -- super duper calculations\n    -- idk idk\n    return \"something in paladin kit\"\nend\nlocal Warlock = {}\nfunction Warlock:recomendspell()\n    -- super duper calculations\n    -- idk idk\n    return \"something in warlock kit\"\nend\nfunction CastRecommendedSpell(class)\n    CastSpell(class:recomendspell())\nend\nCastRecommendedSpell(Paladin)\nCastRecommendedSpell(Warlock)\n```\nIf we compile this with `luauc` (default `-O0`) and open `CastRecommendedSpell` in IDA, it looks something like this:\n\n```\nvoid __fastcall sub_14000D190(\n    LuaState *a1,    // Runtime state.\n    int a2,          // Argument count.\n    LuaValue *a3,    // Argument array.\n    LuaResults *a4)  // Output results; unused in this listing.\n{\n  LuaValue v5;\n  LuaValue result;\n  LuaValue v7;\n  LuaResults v8;\n  LuaValue v9, v10, v11, v12, v13, v14, v15;\n  // Default the first argument to an all-zero value: tag 0 is nil.\n  v5 = (LuaValue)0LL;\n  if ( a2 > 0 )\n    v5 = *a3; // class = arguments[0].\n  // Resolve the global function CastSpell at runtime.\n  j_luau_getglobal(&result, a1, \"CastSpell\");\n  v7 = result;\n  // Prepare storage for the recommendation function's return values.\n  j_luau_resultsinit(&v8);\n  // Construct the string key \"recomendspell\" (13 bytes).\n  j_luau_string(&v9, a1, \"recomendspell\", 0xDu);\n  // Resolve class[\"recomendspell\"] at runtime.\n  v10 = v5;\n  v11 = v9;\n  j_luau_tableget(&v12, a1, &v10, &v11);\n  // class:recomendspell() passes class as the implicit self argument.\n  v13 = v12; // Function to call.\n  v14 = v5;  // self.\n  j_luau_callresults(a1, &v13, 1, &v14, &v8);\n  // Forward the recommendation's results as arguments to CastSpell.\n  // A final-position Lua call can contribute multiple arguments.\n  j_luau_call(&v15, a1, &v7, v8.count, v8.values);\n  // Release the temporary result storage.\n  j_luau_resultsfree(&v8);\n}\n```\nWhich is still a little bit hard to understand, because you need to figure out what the `luau_*` helpers do. Let’s try compiling with `-O3`:\n\n```\nvoid __fastcall sub_14000BB70(\n    LuaState *a1,\n    int a2,\n    LuaValue *a3,\n    LuaResults *a4)\n{\n  unsigned __int8 type;\n  char v6, v7, v8, v9, v10;\n  int v11;\n  __int64 boolean;\n  char v13;\n  LuaValue v14, v15;\n  int v16;\n  LuaValue result, v18, v19, v20, v21, v22, v23;\n  LuaResults results;\n  // The first argument's LuaValue is represented as separate fields.\n  if ( a2 <= 0 )\n  {\n    // Construct the same all-zero default value.\n    boolean = 0;\n    v13 = 0;\n    v11 = 0;\n    v10 = 0;\n    v9 = 0;\n    v8 = 0;\n    v7 = 0;\n    v6 = 0;\n    type = 0;\n  }\n  else\n  {\n    // Copy the first argument's tag, padding, and payload.\n    // These are not separate Lua variables or class-specific calculations.\n    type = a3->type;\n    v6 = a3->_pad[0];\n    v7 = a3->_pad[1];\n    v8 = a3->_pad[2];\n    v9 = a3->_pad[3];\n    v10 = a3->_pad[4];\n    v11 = (unsigned __int8)a3->_pad[5];\n    v13 = a3->_pad[6];\n    // \"boolean\" is the displayed member name, not proof of a Boolean value.\n    // This copies the payload bits of the tagged LuaValue.\n    boolean = a3->boolean;\n  }\n  v16 = v11;\n  // The global lookup remains.\n  j_luau_getglobal(&result, a1, \"CastSpell\");\n  v18 = result;\n  j_luau_resultsinit(&results);\n  j_luau_string(&v19, a1, \"recomendspell\", 0xDu);\n  // Reassemble class into an addressable LuaValue for the lookup helper.\n  v14.type = type;\n  v14._pad[0] = v6;\n  v14._pad[1] = v7;\n  v14._pad[2] = v8;\n  v14._pad[3] = v9;\n  v14._pad[4] = v10;\n  v14._pad[5] = v11;\n  v14._pad[6] = v13;\n  v14.boolean = boolean;\n  // The dynamic method lookup also remains.\n  v20 = v19;\n  j_luau_tableget(&v21, a1, &v14, &v20);\n  v22 = v21;\n  // Reassemble class again, this time as the self argument.\n  v15.type = type;\n  v15._pad[0] = v6;\n  v15._pad[1] = v7;\n  v15._pad[2] = v8;\n  v15._pad[3] = v9;\n  v15._pad[4] = v10;\n  v15._pad[5] = v16;\n  v15._pad[6] = v13;\n  v15.boolean = boolean;\n  // Call the dynamically resolved method, then forward its results.\n  j_luau_callresults(a1, &v22, 1, &v15, &results);\n  j_luau_call(&v23, a1, &v18, results.count, results.values);\n  j_luau_resultsfree(&results);\n}\n```\nSo… what is the issue?\n\nIn this build, `-O3` didn’t remove any of the seven `luau_*` helper calls in this function, or change their order. It split the 16-byte `LuaValue` into separate fields and reconstructed values around the calls; the function’s LLVM IR grew from 47 to 192 lines.\n\nCompiling to native code didn’t change that: the table accesses and calls happen inside runtime helpers. LLVM just sees calls to functions it knows nothing about, so it can’t figure out what’s in the table or what `CastSpell` is. Faster code, not more readable code.\n\nThe same limit shows up on obfuscated code. On my hand-built fixtures at `-O2`, LLVM removed a simple arithmetic dispatcher, but a table-driven VM kept its interpreter loop and table operations. Luraph is a table-driven VM, so I needed a different approach.\n\n# Using AI to deobfuscate it\n\n#### AI Rewriting\n\nNow, I don’t remember which AI model I used when I first tried this, but to summarize my experience: it was pure AI slop. “Recovered” strings that got completely changed, completely new classes that the AI hallucinated, etc. It looked right. It just wasn’t the original script.\n\nInstead, you should make the AI write scripts for you. The way you guide your clanker is the same way you’d deobfuscate scripts yourself:\n\n1. Understand what it actually does, conceptually\n2. Write rules\n3. Check the transformations against the original behavior, and know what you didn’t test\n\n#### AI-assisted deobfuscation\n\nI had three samples: a VM-obfuscated script from https://luaobfuscator.com/ (thank u for creating it, Mr. Ferib), saved as `example.txt`, and two Luraph-protected scripts, which I’ll call Luraph #1 (`breakfastEngine.txt`) and Luraph #2 (`breakfastPaladin.txt`).\n\n### Recovering luaobfuscator\n\nI selected OBFUSCATE V1 on the default script luaobfuscator provides. The output is a ~30 KB VM script: the real program is an encoded blob, run by a custom interpreter.\n\nHere’s what the AI mapped out:\n\n- A wrapper that aliases library functions to `v0` –`v14` .\n- An encoded byte stream after a `LOL!` marker: hex pairs, where a pair ending in`Q` means “repeat the next byte”.\n- A deserializer (`v28` ) that turns those bytes into prototypes: a root with 157 instructions and two children with 38 and 54. The payload uses 46 distinct opcodes, and several opcode numbers do the same thing.\n- An interpreter (`v29` ) that runs them: fetch op/A/B/C, pick a handler through a nested if-tree, bump the pc.\n- Strings XOR-encrypted with a cyclic per-string key (which, for extra fun, starts at the key’s second byte).\n\nHowever; the AI didn’t write its own deserializer at this point. It swapped the final `return v29(v28(),{},v17)(...)` for `return v28()` and ran the wrapper in Lupa to dump the prototypes. That never runs the VM payload, but it does run the obfuscator’s code, and restricted globals in Lupa are not a sandbox. If you don’t trust the script, do this in a VM.\n\nFrom there: disassemble, lift to pseudo-Lua, decrypt the six strings, and manually restructure the control flow in the AI session. That interactive recovery took about 11 minutes, and the result is 47 lines. I was steering it the whole time, it’s not a one-click thing. Anyway, the interesting part:\n\n```\nfunction sieve_of_eratosthenes(n)\n    local is_prime = {}\n    for i = 1, n do\n        is_prime[i] = i ~= 1\n    end\n    for i = 2, math.floor(math.sqrt(n)) do\n        if is_prime[i] then\n            for j = i * i, n, i do\n                is_prime[j] = false\n            end\n        end\n    end\n    return is_prime\nend\nlocal primes = _G[\"sieve_of_eratosthenes\"](420)\nfor number, prime in pairs(primes) do\n    if prime then\n        print(\"Prime found: \" .. number)\n    end\nend\n```\nTo check it: the original and the recovered script printed the same 85 lines (four messages plus 81 primes up to 420) on both Lua 5.1 and LuaJIT, and the sieve matched an independent primality check at ten bounds from 0 to 1000. That checks the payload on those inputs, not whole-program equivalence: the wrapper’s own side effects (the `FlatIdent_455BF` and `Descriptor` globals) are left out on purpose, and Lua doesn’t generally guarantee `pairs` iteration order.\n\nThis only works for this one sample though. When I asked the AI to turn it into a real deobfuscator (`luaudeobf`), it got scalar dispatchers and junk arithmetic working, but still couldn’t recover `example.txt` by itself.\n\nIt also gave me my favorite example of why rule 3 matters. Lua 5.1 pools `0` and `-0` as the same constant, and whichever appears first in the source wins. The recovery tool reordered statements:\n\n```\nlocal pc=1\nlocal a,b\nwhile pc~=9 do\n    if pc==2 then b=-0; pc=9\n    elseif pc==1 then a=0; pc=2 end\nend\nprint(1/a,1/b) -- -inf -inf\n```\n“Deobfuscated” into `a=0; b=-0`, it prints `inf inf`. The tool’s 404-execution test suite passed anyway. The fix was to leave zero-sensitive dispatchers alone rather than change behavior.\n\n### Recovering Luraph #1\n\nLuraph is a whole different league. Same idea (decoder, VM, prototypes), but:\n\n- The payload is an ASCII85 `LPH` blob behind a decompressor, loaded with`loadstring(..., \"Luraph\")` . Adding newlines to the decoded layer trips anti-tamper.\n- Instructions are spread across parallel tables: opcode `B[pc]` , operands`G[pc]` ,`T[pc]` ,`S[pc]` .\n- It modifies itself. Constants are decoded lazily through `__index` , and decoding them clears operand cells: the analysis recorded 1,055 materialization triggers, 4,367 cleared cells, and 19,971 decoy references that just error. Handlers also rewrite opcodes and operands at runtime. The instruction tables you dump at load time are not the program that runs.\n\nWhat worked was partial evaluation: take Luraph’s own interpreter and specialize it against each prototype, carrying the patched instruction tables along. We also tried a handwritten opcode lifter. It got deleted.\n\nThe wall was state explosion. The entrypoint hit 691,725 states before the 3,600-second deadline. Raising the limits didn’t help. What helped was summarizing parts that were proven side-effect free, so it didn’t walk every state inside them. CFG structuring then took the root from 3,810 labels down to 212, and later to zero gotos.\n\nZero gotos still isn’t deobfuscated, though: the frame slots, child factories and operand patching were all still there.\n\nThe VM only disappeared when I asked the AI port the recovered code to Rust, and then to plain Lua 5.1: six modules (`decoder`, `helpers`, `protector`, `frames`, `session`, `init`) with no dispatcher.\n\nSo is it correct? Kinda. Under four sandbox setups, the original and the rewrite did the same host lookups, in the same order, with the same results, until the original itself stopped at a frame-template check with error `e1x1g`. Changing one error code in the rewrite made the comparison fail, so it’s not a check that always passes. Everything after that (rest of decoder/protector, auth/session) was only checked against test vectors made from the source. If the AI misread something, its tests probably misread it the same way.\n\nWhen I asked whether it was “schizogenerated”, the honest answer was: “It isn’t made up, but it hasn’t been proven the same as `breakfastEngine.txt` either.” `(◕‿◕✿)`\n\n### Recovering Luraph #2\n\nSame markers and loader shape as Luraph #1, but the fields are shuffled (the opcode lives in field 8 instead of 4, and so on), so most of the pipeline carried over. 53 prototypes decoded fine; the root blew up again, this time at 200,100 labels after 886 seconds. Raising the label limit from 30,000 to 200,000 only moved where it died. What fixed it was the same summary trick, plus throwing away VM state that nothing reads later.\n\nAfter that came a structured root (164/164 sandbox comparisons against the original) and `do ... end` cleanup. But that’s still a VM, just a prettier one.\n\n#### Actually removing the VM\n\nThe trick was splitting everything into two buckets: stuff only the VM cares about (registers, frames, the patched operand tables) and stuff the script actually does (calls, their order and arguments, return values, errors). The first bucket can go. The second has to stay exactly the same.\n\n1. **Get the bodies out of Luraph’s own interpreter.**`vmfree_recover.py` specialized each child prototype into a normal function body. When a body didn’t match,`vmfree_effect_equivalence.py` checked whether the difference was only VM internals (renamed variables, copies). If an actual call changed, it got rejected. No “eh, this one looks nicer”.\n2. **Self-modifying operands.** Dumping the tables once isn’t enough, because Luraph patches them while running. So the recovery traced which write ends up at which read, e.g.`T[203] = 3` ,`S[1333] = 7` ,`G[1744] = 1` . That’s only true at those spots; the arrays aren’t constant.\n3. **Turn the VM frame into normal locals.** Child functions became plain Lua closures. Root helpers only got the variables they actually use, otherwise you hit Lua 5.1’s upvalue/register limits (and stuffing the old frame into a table would just be the VM again).\n4. **Kill the string decoder, but only where it’s safe.** Four root calls to the decoder had constant arguments, and the decoder (plus its helpers) was proven pure for those. So the calls got replaced with their results, and the decoder had nothing left to do. That doesn’t mean every decoder is pure.\n5. **Don’t copy everything.** Child 26 blew up with duplicated tails. Instead of raising limits again, it got rebuilt with shared tails. No falling back to the original VM either.\n\nThis produced `breakfastPaladin_vmfree.lua`, but it was still 350,353 lines. Turns out removing the VM doesn’t remove the copy-paste that structuring added.\n\n#### VM-free isn’t readable\n\nThe cleanup ran on the AST; I didn’t ask the AI to rewrite the addon from memory. It removed junk and dead stores, folded constants and copies, then merged duplicated paths back into shared branches and loops. Host lookups stayed where they were. After naming, it was 9,563 lines (`breakfastPaladin_clean.lua`). Then I asked about the empty argument-count checks:\n\n```\nif (select(\"#\", ...) >= 14) then end\n```\nWith genuine standard `select`, that condition only counts arguments, so it can go. That removed 1,169 empty checks and 167 unused argument-count assignments, down to 8,223 lines (`breakfastPaladin_clean_05_varargs.lua`).\n\nBut deleting the checks that *assign* captured variables would be wrong. If an argument is absent, the original can retain a previous value; a normal parameter would receive `nil`. Explicit `nil`, `false`, and an absent argument aren’t interchangeable here. That’s still in v2.\n\nI still told the AI, “this still looks pretty obfuscated”. Its first readability attempt passed the application checks, syntax audit and differential fixtures, but the saved `breakfastPaladin_readable.lua` was 8,233 lines and the frame-setup example still looked register-based. Renaming those registers hadn’t solved the problem.\n\nThe problem was shared continuations: helper functions for code reached from several branches. They captured the recycled variables, so simple copy propagation couldn’t go through them. The next attempt merged those continuations into one control-flow graph, restructured it, and split recycled variables by where they’re defined and used. Captured variables had to keep their scope though: moving a declaration out of a loop would make callbacks share one variable instead of getting a fresh one each iteration.\n\nTwo things broke along the way: splitting every register into its own local hit Lua 5.1’s local limit (so locals needed tight scopes, but too many scopes bloated the output), and a `Dots` vs `Varargs` mixup in the generated AST broke continuation collapsing until it got fixed.\n\nFrom there, the AST passes collapsed safe copies, recovered method calls and string expressions, folded table stores into constructors, recovered arguments where the tuple behavior allowed it, simplified predicates, and assigned usage-based names. For example, this saved frame-setup fragment:\n\n```\nvalue_24_7 = captured_14_607_2[selectResult7]\ntemporary_24_loop1v7 = value_24_7\ntemporary_24_loop1v14 = temporary_24_loop1v7\nvalue_24_8 = temporary_24_loop1v14\ntemporary_24_loop1v14 = temporary_24_loop1v7\ntemporary_24_loop1v14 = temporary_24_loop1v14.SetAttribute\nvalue_24_7 = temporary_24_loop1v14\nvalue_24_10 = (\" \" .. value_24_6)\nvalue_24_10 = (selectResult8 .. value_24_10)\nvalue_24_7(value_24_8, \"CurrentAttribute\", value_24_10)\n```\nbecame:\n\n```\nframes3[frameKey2]:SetAttribute(\"CurrentAttribute\", (actionValue .. \" \" .. unit2))\n```\nThe neighboring `SetFrameRef` and `_onshow` calls became direct methods too, but they deliberately repeat the frame-table lookup. A preceding host method could mutate that table; caching the receiver across calls would be a different program. Concatenation association, multiple returns, shadowing, and effect order also got tests that make sure the tool *doesn’t* simplify them.\n\nThe accepted readability pipeline produced `breakfastPaladin_readable_v2.lua`. These are the completion report’s measurements, compared with the argument-count-cleaned baseline:\n\n| Measure | Before v2 | v2 | \n|---|---|---|\n| Lines | 8,223 | 6,792 | \n| Bytes | 403,650 | 349,495 | \n| Statements | 6,476 | 4,549 | \n| Plain copy statements | 979 | 820 | \n| Functions | 248 | 224 | \n\nThat still isn’t clean original source. Shared tails, `repeat ... break until true` blocks, numbered names and argument-retention checks remain. The report even shows more single-use private temporaries at the end: 80 versus 48. Splitting recycled registers exposes separate locals, and some cannot be inlined across an effect or capture-lifetime barrier.\n\nOne suspicious numeric helper also stayed. Running it on its own returns `1751328000`, July 1, 2025 UTC, which the preserved `GetServerTime` expiry guard uses. It’s application logic, not a leftover dispatcher; the cleanup didn’t remove the guard. The reason why is, the script is based on a subscription. When July 1, 2025 UTC passes, the users need to renew the script. \n\n#### So does v2 actually work?\n\nEvery step had to pass three checks before I kept it: same app behavior, no VM left + valid syntax, and the transformation fixtures. 43 outputs passed, and 31 of them form the chain that leads to the final file. 12 failed attempts are kept separately and don’t count. The first “readable” version passed everything too; I rejected it because it was still ugly.\n\nFor the final file: same app behavior in 68 sandbox scenarios, and the check caught all 4 deliberately broken versions (dropped trailing `nil`, swapped API order, wrong return count, broken shared variables). There are also 3,077 passing fixture comparisons, but those are small transformation tests, not 3,077 app scenarios. It compiles on Lua 5.1 with no VM leftovers, and running the cleanup again doesn’t change a byte (which doesn’t mean it can’t be simplified further).\n\nThe catch: this assumes nothing pokes at VM internals, no hooks or environment tricks, the real Lua 5.1 libraries, and the main chunk only runs once per load. Luraph’s anti-tamper isn’t checked; the script’s own guards are. I never ran it in actual WoW, and 68 scenarios aren’t every input.\n\n#### Still not done\n\nI asked it to “deduplicate the control flow and get rid of register-style variables”. This time it counted what was left before declaring victory: `repeat ... until true` blocks, repeated argument-count ladders, copy-pasted chunks and numbered locals.\n\nThe plan was four passes:\n\n- **Control flow:** rebuild and restructure the CFG; keep a big shared tail as a local function instead of copying it everywhere; only merge conditions if`and` /`or` still run in the same order.\n- **Argument ladders:** turn them into`local a, b, ... = ...` , but only if those variables are definitely`nil` beforehand. Otherwise a missing argument has to keep the old value.\n- **Repeated code:** roll it back into loops/helpers only if call order and captured variables stay the same.\n- **Names:** merge aliases and reuse names without causing shadowing.\n\nv3 is still in progress, so I can’t say it worked yet.\n\n## What I learned about using AI for this\n\n- **Define “deobfuscated” up front.** I had to correct the AI at every step: “why its still incomplete?” (the wrapper was unpacked, but the VM was still there), “it doesnt look impressive” (named VM code isn’t recovered source), “remove the VM stuff as well” (goto-free isn’t VM-free). Unpacked, structured, VM-free and proven equivalent are four different results. A better prompt would be “write the shortest, understandable and semanticly equivalent script, use or create tools to analyze the existing code, restructure control flow, deduplicate code and use variables efficently”\n- **More tokens won’t fix it.** Raising the limits just moved where it crashed; summaries and dropping dead state fixed it. Don’t throw more tokens, instead, use your tokens efficently.\n- **Ask what the evidence actually is.** “Tests pass” can mean “matches the original”, or it can mean “matches my own reading of the original”. The second one is the AI grading its own homework. Better yet, create the tests yourself if you can.\n- **Use subagents efficently.** AI will hallucinate with especially if you have a big context, and the point of obfuscation is having big context. A better way of using AI to deobfuscate is dividing big work into smaller pieces, utilizing agents to verify the work.\n- **If you want to use ai, use harnesses** An AI harness gives tools to AI to interact with the environment. You want to create a loop so AI and harness takes care of most of the work for you. And for extra fun, use plugins to keep you accompany, like[omp-uwu](https://github.com/NaC-L/omp-uwu)`σ(≧ε≦σ) ♡`\n\n*Usage snapshot: October 12, 2026. Ten sessions and 94 workers; recorded estimates, not final project totals since we still dont understand the logic.*\n\nI never figured out exactly how the original tool worked. The workaround has since been patched, and I don’t have WoW anymore, so I can’t finish checking it against the game. At some point I stopped caring what the script does and started caring how far AI could get with it.\n\n# Closing\n\nI didn’t get the answer I originally came for. I did find out where AI actually helps with RE, and where it just makes stuff that looks right.","body_html":"<h1 id=\"deobfuscating-luraph-lifting-lua-to-llvm-then-letting-ai-try\">Deobfuscating Luraph: Lifting Lua to LLVM, Then Letting AI Try</h1>\n<h1 id=\"intro\">Intro</h1>\n<p>A while back, I was playing World of Warcraft with a friend. We used <a href=\"https://github.com/Hekili/hekili\" rel=\"nofollow ugc noopener\">Hekili</a>, a Lua addon that recommends which ability to use next. Recommending a spell and automatically casting it are two very different things, though.</p>\n<p>On <a href=\"https://worldofwarcraft.blizzard.com/en-us/news/24208341\" rel=\"nofollow ugc noopener\">June 17, 2025</a>, Blizzard introduced Assisted Highlight and the Single-Button Assistant: built-in ability recommendations and a button that casts the next recommended ability, with an additional global-cooldown penalty. Separately, on <a href=\"https://worldofwarcraft.blizzard.com/en-us/news/24244638\" rel=\"nofollow ugc noopener\">October 23, 2025</a>, Blizzard explained its planned Midnight restrictions on addons using real-time combat information to make decisions for players.</p>\n<p>Around the time I stopped playing, I came across a paid tool claiming to automate rotations through a macro-related workaround. Naturally, I wanted to know how it worked.</p>\n<p>The script was protected by Luraph. That rabbit hole is how I ended up messing with Lua deobfuscation, and in this post I’ll go over what I tried.</p>\n<h1 id=\"the-lua-lifter-luau-rewrite\">The Lua Lifter — luau-rewrite</h1>\n<p><a href=\"https://luau.org/compatibility/\" rel=\"nofollow ugc noopener\">Luau</a> is Roblox’s fork of Lua 5.1: faster, sandboxed, optional types. Most Lua 5.1 code works, but not all of it.</p>\n<p>My idea was to get Luau’s compiler to emit LLVM IR instead of Luau bytecode, then use LLVM’s optimizations to simplify the obfuscated logic. Luau already handles parsing and compiling the source, so I’d just need to replace the backend, right?</p>\n<p>Nope. Emitting LLVM IR is only part of the job: that IR also needs to preserve dynamic operations such as table lookups, function calls, and runtime type checks. And compiling those operations doesn’t necessarily make them easier to understand. (In the end, <code>luauc</code> doesn’t even reuse Luau’s frontend: it has its own small handwritten parser for a subset of Lua.)</p>\n<p>Lua is a dynamically typed language, but that doesn’t mean you can’t lift it. Types belong to values rather than fixed variable declarations; a compiler can still infer types in some places. Where it can’t, you can represent values with tags and delegate dynamic operations to helper functions.</p>\n<pre><code>-- human-hallucinated code below\nlocal Paladin = {}\nfunction Paladin:recomendspell()\n    -- super duper calculations\n    -- idk idk\n    return &quot;something in paladin kit&quot;\nend\nlocal Warlock = {}\nfunction Warlock:recomendspell()\n    -- super duper calculations\n    -- idk idk\n    return &quot;something in warlock kit&quot;\nend\nfunction CastRecommendedSpell(class)\n    CastSpell(class:recomendspell())\nend\nCastRecommendedSpell(Paladin)\nCastRecommendedSpell(Warlock)</code></pre>\n<p>If we compile this with <code>luauc</code> (default <code>-O0</code>) and open <code>CastRecommendedSpell</code> in IDA, it looks something like this:</p>\n<pre><code>void __fastcall sub_14000D190(\n    LuaState *a1,    // Runtime state.\n    int a2,          // Argument count.\n    LuaValue *a3,    // Argument array.\n    LuaResults *a4)  // Output results; unused in this listing.\n{\n  LuaValue v5;\n  LuaValue result;\n  LuaValue v7;\n  LuaResults v8;\n  LuaValue v9, v10, v11, v12, v13, v14, v15;\n  // Default the first argument to an all-zero value: tag 0 is nil.\n  v5 = (LuaValue)0LL;\n  if ( a2 &gt; 0 )\n    v5 = *a3; // class = arguments[0].\n  // Resolve the global function CastSpell at runtime.\n  j_luau_getglobal(&amp;result, a1, &quot;CastSpell&quot;);\n  v7 = result;\n  // Prepare storage for the recommendation function&#39;s return values.\n  j_luau_resultsinit(&amp;v8);\n  // Construct the string key &quot;recomendspell&quot; (13 bytes).\n  j_luau_string(&amp;v9, a1, &quot;recomendspell&quot;, 0xDu);\n  // Resolve class[&quot;recomendspell&quot;] at runtime.\n  v10 = v5;\n  v11 = v9;\n  j_luau_tableget(&amp;v12, a1, &amp;v10, &amp;v11);\n  // class:recomendspell() passes class as the implicit self argument.\n  v13 = v12; // Function to call.\n  v14 = v5;  // self.\n  j_luau_callresults(a1, &amp;v13, 1, &amp;v14, &amp;v8);\n  // Forward the recommendation&#39;s results as arguments to CastSpell.\n  // A final-position Lua call can contribute multiple arguments.\n  j_luau_call(&amp;v15, a1, &amp;v7, v8.count, v8.values);\n  // Release the temporary result storage.\n  j_luau_resultsfree(&amp;v8);\n}</code></pre>\n<p>Which is still a little bit hard to understand, because you need to figure out what the <code>luau_*</code> helpers do. Let’s try compiling with <code>-O3</code>:</p>\n<pre><code>void __fastcall sub_14000BB70(\n    LuaState *a1,\n    int a2,\n    LuaValue *a3,\n    LuaResults *a4)\n{\n  unsigned __int8 type;\n  char v6, v7, v8, v9, v10;\n  int v11;\n  __int64 boolean;\n  char v13;\n  LuaValue v14, v15;\n  int v16;\n  LuaValue result, v18, v19, v20, v21, v22, v23;\n  LuaResults results;\n  // The first argument&#39;s LuaValue is represented as separate fields.\n  if ( a2 &lt;= 0 )\n  {\n    // Construct the same all-zero default value.\n    boolean = 0;\n    v13 = 0;\n    v11 = 0;\n    v10 = 0;\n    v9 = 0;\n    v8 = 0;\n    v7 = 0;\n    v6 = 0;\n    type = 0;\n  }\n  else\n  {\n    // Copy the first argument&#39;s tag, padding, and payload.\n    // These are not separate Lua variables or class-specific calculations.\n    type = a3-&gt;type;\n    v6 = a3-&gt;_pad[0];\n    v7 = a3-&gt;_pad[1];\n    v8 = a3-&gt;_pad[2];\n    v9 = a3-&gt;_pad[3];\n    v10 = a3-&gt;_pad[4];\n    v11 = (unsigned __int8)a3-&gt;_pad[5];\n    v13 = a3-&gt;_pad[6];\n    // &quot;boolean&quot; is the displayed member name, not proof of a Boolean value.\n    // This copies the payload bits of the tagged LuaValue.\n    boolean = a3-&gt;boolean;\n  }\n  v16 = v11;\n  // The global lookup remains.\n  j_luau_getglobal(&amp;result, a1, &quot;CastSpell&quot;);\n  v18 = result;\n  j_luau_resultsinit(&amp;results);\n  j_luau_string(&amp;v19, a1, &quot;recomendspell&quot;, 0xDu);\n  // Reassemble class into an addressable LuaValue for the lookup helper.\n  v14.type = type;\n  v14._pad[0] = v6;\n  v14._pad[1] = v7;\n  v14._pad[2] = v8;\n  v14._pad[3] = v9;\n  v14._pad[4] = v10;\n  v14._pad[5] = v11;\n  v14._pad[6] = v13;\n  v14.boolean = boolean;\n  // The dynamic method lookup also remains.\n  v20 = v19;\n  j_luau_tableget(&amp;v21, a1, &amp;v14, &amp;v20);\n  v22 = v21;\n  // Reassemble class again, this time as the self argument.\n  v15.type = type;\n  v15._pad[0] = v6;\n  v15._pad[1] = v7;\n  v15._pad[2] = v8;\n  v15._pad[3] = v9;\n  v15._pad[4] = v10;\n  v15._pad[5] = v16;\n  v15._pad[6] = v13;\n  v15.boolean = boolean;\n  // Call the dynamically resolved method, then forward its results.\n  j_luau_callresults(a1, &amp;v22, 1, &amp;v15, &amp;results);\n  j_luau_call(&amp;v23, a1, &amp;v18, results.count, results.values);\n  j_luau_resultsfree(&amp;results);\n}</code></pre>\n<p>So… what is the issue?</p>\n<p>In this build, <code>-O3</code> didn’t remove any of the seven <code>luau_*</code> helper calls in this function, or change their order. It split the 16-byte <code>LuaValue</code> into separate fields and reconstructed values around the calls; the function’s LLVM IR grew from 47 to 192 lines.</p>\n<p>Compiling to native code didn’t change that: the table accesses and calls happen inside runtime helpers. LLVM just sees calls to functions it knows nothing about, so it can’t figure out what’s in the table or what <code>CastSpell</code> is. Faster code, not more readable code.</p>\n<p>The same limit shows up on obfuscated code. On my hand-built fixtures at <code>-O2</code>, LLVM removed a simple arithmetic dispatcher, but a table-driven VM kept its interpreter loop and table operations. Luraph is a table-driven VM, so I needed a different approach.</p>\n<h1 id=\"using-ai-to-deobfuscate-it\">Using AI to deobfuscate it</h1>\n<h4 id=\"ai-rewriting\">AI Rewriting</h4>\n<p>Now, I don’t remember which AI model I used when I first tried this, but to summarize my experience: it was pure AI slop. “Recovered” strings that got completely changed, completely new classes that the AI hallucinated, etc. It looked right. It just wasn’t the original script.</p>\n<p>Instead, you should make the AI write scripts for you. The way you guide your clanker is the same way you’d deobfuscate scripts yourself:</p>\n<ol><li>Understand what it actually does, conceptually</li><li>Write rules</li><li>Check the transformations against the original behavior, and know what you didn’t test</li></ol>\n<h4 id=\"ai-assisted-deobfuscation\">AI-assisted deobfuscation</h4>\n<p>I had three samples: a VM-obfuscated script from <a href=\"https://luaobfuscator.com/\" rel=\"nofollow ugc noopener\">https://luaobfuscator.com/</a> (thank u for creating it, Mr. Ferib), saved as <code>example.txt</code>, and two Luraph-protected scripts, which I’ll call Luraph #1 (<code>breakfastEngine.txt</code>) and Luraph #2 (<code>breakfastPaladin.txt</code>).</p>\n<h3 id=\"recovering-luaobfuscator\">Recovering luaobfuscator</h3>\n<p>I selected OBFUSCATE V1 on the default script luaobfuscator provides. The output is a ~30 KB VM script: the real program is an encoded blob, run by a custom interpreter.</p>\n<p>Here’s what the AI mapped out:</p>\n<ul><li>A wrapper that aliases library functions to <code>v0</code> –<code>v14</code> .</li><li>An encoded byte stream after a <code>LOL!</code> marker: hex pairs, where a pair ending in<code>Q</code> means “repeat the next byte”.</li><li>A deserializer (<code>v28</code> ) that turns those bytes into prototypes: a root with 157 instructions and two children with 38 and 54. The payload uses 46 distinct opcodes, and several opcode numbers do the same thing.</li><li>An interpreter (<code>v29</code> ) that runs them: fetch op/A/B/C, pick a handler through a nested if-tree, bump the pc.</li><li>Strings XOR-encrypted with a cyclic per-string key (which, for extra fun, starts at the key’s second byte).</li></ul>\n<p>However; the AI didn’t write its own deserializer at this point. It swapped the final <code>return v29(v28(),{},v17)(...)</code> for <code>return v28()</code> and ran the wrapper in Lupa to dump the prototypes. That never runs the VM payload, but it does run the obfuscator’s code, and restricted globals in Lupa are not a sandbox. If you don’t trust the script, do this in a VM.</p>\n<p>From there: disassemble, lift to pseudo-Lua, decrypt the six strings, and manually restructure the control flow in the AI session. That interactive recovery took about 11 minutes, and the result is 47 lines. I was steering it the whole time, it’s not a one-click thing. Anyway, the interesting part:</p>\n<pre><code>function sieve_of_eratosthenes(n)\n    local is_prime = {}\n    for i = 1, n do\n        is_prime[i] = i ~= 1\n    end\n    for i = 2, math.floor(math.sqrt(n)) do\n        if is_prime[i] then\n            for j = i * i, n, i do\n                is_prime[j] = false\n            end\n        end\n    end\n    return is_prime\nend\nlocal primes = _G[&quot;sieve_of_eratosthenes&quot;](420)\nfor number, prime in pairs(primes) do\n    if prime then\n        print(&quot;Prime found: &quot; .. number)\n    end\nend</code></pre>\n<p>To check it: the original and the recovered script printed the same 85 lines (four messages plus 81 primes up to 420) on both Lua 5.1 and LuaJIT, and the sieve matched an independent primality check at ten bounds from 0 to 1000. That checks the payload on those inputs, not whole-program equivalence: the wrapper’s own side effects (the <code>FlatIdent_455BF</code> and <code>Descriptor</code> globals) are left out on purpose, and Lua doesn’t generally guarantee <code>pairs</code> iteration order.</p>\n<p>This only works for this one sample though. When I asked the AI to turn it into a real deobfuscator (<code>luaudeobf</code>), it got scalar dispatchers and junk arithmetic working, but still couldn’t recover <code>example.txt</code> by itself.</p>\n<p>It also gave me my favorite example of why rule 3 matters. Lua 5.1 pools <code>0</code> and <code>-0</code> as the same constant, and whichever appears first in the source wins. The recovery tool reordered statements:</p>\n<pre><code>local pc=1\nlocal a,b\nwhile pc~=9 do\n    if pc==2 then b=-0; pc=9\n    elseif pc==1 then a=0; pc=2 end\nend\nprint(1/a,1/b) -- -inf -inf</code></pre>\n<p>“Deobfuscated” into <code>a=0; b=-0</code>, it prints <code>inf inf</code>. The tool’s 404-execution test suite passed anyway. The fix was to leave zero-sensitive dispatchers alone rather than change behavior.</p>\n<h3 id=\"recovering-luraph-1\">Recovering Luraph #1</h3>\n<p>Luraph is a whole different league. Same idea (decoder, VM, prototypes), but:</p>\n<ul><li>The payload is an ASCII85 <code>LPH</code> blob behind a decompressor, loaded with<code>loadstring(..., &quot;Luraph&quot;)</code> . Adding newlines to the decoded layer trips anti-tamper.</li><li>Instructions are spread across parallel tables: opcode <code>B[pc]</code> , operands<code>G[pc]</code> ,<code>T[pc]</code> ,<code>S[pc]</code> .</li><li>It modifies itself. Constants are decoded lazily through <code>__index</code> , and decoding them clears operand cells: the analysis recorded 1,055 materialization triggers, 4,367 cleared cells, and 19,971 decoy references that just error. Handlers also rewrite opcodes and operands at runtime. The instruction tables you dump at load time are not the program that runs.</li></ul>\n<p>What worked was partial evaluation: take Luraph’s own interpreter and specialize it against each prototype, carrying the patched instruction tables along. We also tried a handwritten opcode lifter. It got deleted.</p>\n<p>The wall was state explosion. The entrypoint hit 691,725 states before the 3,600-second deadline. Raising the limits didn’t help. What helped was summarizing parts that were proven side-effect free, so it didn’t walk every state inside them. CFG structuring then took the root from 3,810 labels down to 212, and later to zero gotos.</p>\n<p>Zero gotos still isn’t deobfuscated, though: the frame slots, child factories and operand patching were all still there.</p>\n<p>The VM only disappeared when I asked the AI port the recovered code to Rust, and then to plain Lua 5.1: six modules (<code>decoder</code>, <code>helpers</code>, <code>protector</code>, <code>frames</code>, <code>session</code>, <code>init</code>) with no dispatcher.</p>\n<p>So is it correct? Kinda. Under four sandbox setups, the original and the rewrite did the same host lookups, in the same order, with the same results, until the original itself stopped at a frame-template check with error <code>e1x1g</code>. Changing one error code in the rewrite made the comparison fail, so it’s not a check that always passes. Everything after that (rest of decoder/protector, auth/session) was only checked against test vectors made from the source. If the AI misread something, its tests probably misread it the same way.</p>\n<p>When I asked whether it was “schizogenerated”, the honest answer was: “It isn’t made up, but it hasn’t been proven the same as <code>breakfastEngine.txt</code> either.” <code>(◕‿◕✿)</code></p>\n<h3 id=\"recovering-luraph-2\">Recovering Luraph #2</h3>\n<p>Same markers and loader shape as Luraph #1, but the fields are shuffled (the opcode lives in field 8 instead of 4, and so on), so most of the pipeline carried over. 53 prototypes decoded fine; the root blew up again, this time at 200,100 labels after 886 seconds. Raising the label limit from 30,000 to 200,000 only moved where it died. What fixed it was the same summary trick, plus throwing away VM state that nothing reads later.</p>\n<p>After that came a structured root (164/164 sandbox comparisons against the original) and <code>do ... end</code> cleanup. But that’s still a VM, just a prettier one.</p>\n<h4 id=\"actually-removing-the-vm\">Actually removing the VM</h4>\n<p>The trick was splitting everything into two buckets: stuff only the VM cares about (registers, frames, the patched operand tables) and stuff the script actually does (calls, their order and arguments, return values, errors). The first bucket can go. The second has to stay exactly the same.</p>\n<ol><li><strong>Get the bodies out of Luraph’s own interpreter.</strong><code>vmfree_recover.py</code> specialized each child prototype into a normal function body. When a body didn’t match,<code>vmfree_effect_equivalence.py</code> checked whether the difference was only VM internals (renamed variables, copies). If an actual call changed, it got rejected. No “eh, this one looks nicer”.</li><li><strong>Self-modifying operands.</strong> Dumping the tables once isn’t enough, because Luraph patches them while running. So the recovery traced which write ends up at which read, e.g.<code>T[203] = 3</code> ,<code>S[1333] = 7</code> ,<code>G[1744] = 1</code> . That’s only true at those spots; the arrays aren’t constant.</li><li><strong>Turn the VM frame into normal locals.</strong> Child functions became plain Lua closures. Root helpers only got the variables they actually use, otherwise you hit Lua 5.1’s upvalue/register limits (and stuffing the old frame into a table would just be the VM again).</li><li><strong>Kill the string decoder, but only where it’s safe.</strong> Four root calls to the decoder had constant arguments, and the decoder (plus its helpers) was proven pure for those. So the calls got replaced with their results, and the decoder had nothing left to do. That doesn’t mean every decoder is pure.</li><li><strong>Don’t copy everything.</strong> Child 26 blew up with duplicated tails. Instead of raising limits again, it got rebuilt with shared tails. No falling back to the original VM either.</li></ol>\n<p>This produced <code>breakfastPaladin_vmfree.lua</code>, but it was still 350,353 lines. Turns out removing the VM doesn’t remove the copy-paste that structuring added.</p>\n<h4 id=\"vm-free-isn-t-readable\">VM-free isn’t readable</h4>\n<p>The cleanup ran on the AST; I didn’t ask the AI to rewrite the addon from memory. It removed junk and dead stores, folded constants and copies, then merged duplicated paths back into shared branches and loops. Host lookups stayed where they were. After naming, it was 9,563 lines (<code>breakfastPaladin_clean.lua</code>). Then I asked about the empty argument-count checks:</p>\n<pre><code>if (select(&quot;#&quot;, ...) &gt;= 14) then end</code></pre>\n<p>With genuine standard <code>select</code>, that condition only counts arguments, so it can go. That removed 1,169 empty checks and 167 unused argument-count assignments, down to 8,223 lines (<code>breakfastPaladin_clean_05_varargs.lua</code>).</p>\n<p>But deleting the checks that <em>assign</em> captured variables would be wrong. If an argument is absent, the original can retain a previous value; a normal parameter would receive <code>nil</code>. Explicit <code>nil</code>, <code>false</code>, and an absent argument aren’t interchangeable here. That’s still in v2.</p>\n<p>I still told the AI, “this still looks pretty obfuscated”. Its first readability attempt passed the application checks, syntax audit and differential fixtures, but the saved <code>breakfastPaladin_readable.lua</code> was 8,233 lines and the frame-setup example still looked register-based. Renaming those registers hadn’t solved the problem.</p>\n<p>The problem was shared continuations: helper functions for code reached from several branches. They captured the recycled variables, so simple copy propagation couldn’t go through them. The next attempt merged those continuations into one control-flow graph, restructured it, and split recycled variables by where they’re defined and used. Captured variables had to keep their scope though: moving a declaration out of a loop would make callbacks share one variable instead of getting a fresh one each iteration.</p>\n<p>Two things broke along the way: splitting every register into its own local hit Lua 5.1’s local limit (so locals needed tight scopes, but too many scopes bloated the output), and a <code>Dots</code> vs <code>Varargs</code> mixup in the generated AST broke continuation collapsing until it got fixed.</p>\n<p>From there, the AST passes collapsed safe copies, recovered method calls and string expressions, folded table stores into constructors, recovered arguments where the tuple behavior allowed it, simplified predicates, and assigned usage-based names. For example, this saved frame-setup fragment:</p>\n<pre><code>value_24_7 = captured_14_607_2[selectResult7]\ntemporary_24_loop1v7 = value_24_7\ntemporary_24_loop1v14 = temporary_24_loop1v7\nvalue_24_8 = temporary_24_loop1v14\ntemporary_24_loop1v14 = temporary_24_loop1v7\ntemporary_24_loop1v14 = temporary_24_loop1v14.SetAttribute\nvalue_24_7 = temporary_24_loop1v14\nvalue_24_10 = (&quot; &quot; .. value_24_6)\nvalue_24_10 = (selectResult8 .. value_24_10)\nvalue_24_7(value_24_8, &quot;CurrentAttribute&quot;, value_24_10)</code></pre>\n<p>became:</p>\n<pre><code>frames3[frameKey2]:SetAttribute(&quot;CurrentAttribute&quot;, (actionValue .. &quot; &quot; .. unit2))</code></pre>\n<p>The neighboring <code>SetFrameRef</code> and <code>_onshow</code> calls became direct methods too, but they deliberately repeat the frame-table lookup. A preceding host method could mutate that table; caching the receiver across calls would be a different program. Concatenation association, multiple returns, shadowing, and effect order also got tests that make sure the tool <em>doesn’t</em> simplify them.</p>\n<p>The accepted readability pipeline produced <code>breakfastPaladin_readable_v2.lua</code>. These are the completion report’s measurements, compared with the argument-count-cleaned baseline:</p>\n<div class=\"table-wrap\"><table><thead><tr><th>Measure</th><th>Before v2</th><th>v2</th></tr></thead><tbody><tr><td>Lines</td><td>8,223</td><td>6,792</td></tr><tr><td>Bytes</td><td>403,650</td><td>349,495</td></tr><tr><td>Statements</td><td>6,476</td><td>4,549</td></tr><tr><td>Plain copy statements</td><td>979</td><td>820</td></tr><tr><td>Functions</td><td>248</td><td>224</td></tr></tbody></table></div>\n<p>That still isn’t clean original source. Shared tails, <code>repeat ... break until true</code> blocks, numbered names and argument-retention checks remain. The report even shows more single-use private temporaries at the end: 80 versus 48. Splitting recycled registers exposes separate locals, and some cannot be inlined across an effect or capture-lifetime barrier.</p>\n<p>One suspicious numeric helper also stayed. Running it on its own returns <code>1751328000</code>, July 1, 2025 UTC, which the preserved <code>GetServerTime</code> expiry guard uses. It’s application logic, not a leftover dispatcher; the cleanup didn’t remove the guard. The reason why is, the script is based on a subscription. When July 1, 2025 UTC passes, the users need to renew the script. </p>\n<h4 id=\"so-does-v2-actually-work\">So does v2 actually work?</h4>\n<p>Every step had to pass three checks before I kept it: same app behavior, no VM left + valid syntax, and the transformation fixtures. 43 outputs passed, and 31 of them form the chain that leads to the final file. 12 failed attempts are kept separately and don’t count. The first “readable” version passed everything too; I rejected it because it was still ugly.</p>\n<p>For the final file: same app behavior in 68 sandbox scenarios, and the check caught all 4 deliberately broken versions (dropped trailing <code>nil</code>, swapped API order, wrong return count, broken shared variables). There are also 3,077 passing fixture comparisons, but those are small transformation tests, not 3,077 app scenarios. It compiles on Lua 5.1 with no VM leftovers, and running the cleanup again doesn’t change a byte (which doesn’t mean it can’t be simplified further).</p>\n<p>The catch: this assumes nothing pokes at VM internals, no hooks or environment tricks, the real Lua 5.1 libraries, and the main chunk only runs once per load. Luraph’s anti-tamper isn’t checked; the script’s own guards are. I never ran it in actual WoW, and 68 scenarios aren’t every input.</p>\n<h4 id=\"still-not-done\">Still not done</h4>\n<p>I asked it to “deduplicate the control flow and get rid of register-style variables”. This time it counted what was left before declaring victory: <code>repeat ... until true</code> blocks, repeated argument-count ladders, copy-pasted chunks and numbered locals.</p>\n<p>The plan was four passes:</p>\n<ul><li><strong>Control flow:</strong> rebuild and restructure the CFG; keep a big shared tail as a local function instead of copying it everywhere; only merge conditions if<code>and</code> /<code>or</code> still run in the same order.</li><li><strong>Argument ladders:</strong> turn them into<code>local a, b, ... = ...</code> , but only if those variables are definitely<code>nil</code> beforehand. Otherwise a missing argument has to keep the old value.</li><li><strong>Repeated code:</strong> roll it back into loops/helpers only if call order and captured variables stay the same.</li><li><strong>Names:</strong> merge aliases and reuse names without causing shadowing.</li></ul>\n<p>v3 is still in progress, so I can’t say it worked yet.</p>\n<h2 id=\"what-i-learned-about-using-ai-for-this\">What I learned about using AI for this</h2>\n<ul><li><strong>Define “deobfuscated” up front.</strong> I had to correct the AI at every step: “why its still incomplete?” (the wrapper was unpacked, but the VM was still there), “it doesnt look impressive” (named VM code isn’t recovered source), “remove the VM stuff as well” (goto-free isn’t VM-free). Unpacked, structured, VM-free and proven equivalent are four different results. A better prompt would be “write the shortest, understandable and semanticly equivalent script, use or create tools to analyze the existing code, restructure control flow, deduplicate code and use variables efficently”</li><li><strong>More tokens won’t fix it.</strong> Raising the limits just moved where it crashed; summaries and dropping dead state fixed it. Don’t throw more tokens, instead, use your tokens efficently.</li><li><strong>Ask what the evidence actually is.</strong> “Tests pass” can mean “matches the original”, or it can mean “matches my own reading of the original”. The second one is the AI grading its own homework. Better yet, create the tests yourself if you can.</li><li><strong>Use subagents efficently.</strong> AI will hallucinate with especially if you have a big context, and the point of obfuscation is having big context. A better way of using AI to deobfuscate is dividing big work into smaller pieces, utilizing agents to verify the work.</li><li><strong>If you want to use ai, use harnesses</strong> An AI harness gives tools to AI to interact with the environment. You want to create a loop so AI and harness takes care of most of the work for you. And for extra fun, use plugins to keep you accompany, like<a href=\"https://github.com/NaC-L/omp-uwu\" rel=\"nofollow ugc noopener\">omp-uwu</a><code>σ(≧ε≦σ) ♡</code></li></ul>\n<p><em>Usage snapshot: October 12, 2026. Ten sessions and 94 workers; recorded estimates, not final project totals since we still dont understand the logic.</em></p>\n<p>I never figured out exactly how the original tool worked. The workaround has since been patched, and I don’t have WoW anymore, so I can’t finish checking it against the game. At some point I stopped caring what the script does and started caring how far AI could get with it.</p>\n<h1 id=\"closing\">Closing</h1>\n<p>I didn’t get the answer I originally came for. I did find out where AI actually helps with RE, and where it just makes stuff that looks right.</p>","headings":[{"level":1,"text":"Deobfuscating Luraph: Lifting Lua to LLVM, Then Letting AI Try","id":"deobfuscating-luraph-lifting-lua-to-llvm-then-letting-ai-try"},{"level":1,"text":"Intro","id":"intro"},{"level":1,"text":"The Lua Lifter — luau-rewrite","id":"the-lua-lifter-luau-rewrite"},{"level":1,"text":"Using AI to deobfuscate it","id":"using-ai-to-deobfuscate-it"},{"level":3,"text":"Recovering luaobfuscator","id":"recovering-luaobfuscator"},{"level":3,"text":"Recovering Luraph #1","id":"recovering-luraph-1"},{"level":3,"text":"Recovering Luraph #2","id":"recovering-luraph-2"},{"level":2,"text":"What I learned about using AI for this","id":"what-i-learned-about-using-ai-for-this"},{"level":1,"text":"Closing","id":"closing"}]}}