Skip to content

v2.1 - #78

Open
Serpensin wants to merge 7 commits into
zeusssz:mainfrom
Serpensin:main
Open

v2.1#78
Serpensin wants to merge 7 commits into
zeusssz:mainfrom
Serpensin:main

Conversation

@Serpensin

Copy link
Copy Markdown
Contributor
  • Replaced bytecode encoding with a polymorphic chunked loader using PRNG-based byte transformation.
  • Reworked string encoding with per-string keys and multiple decode strategies.
  • Added a new constant_encoding module for primitive and integer constant obfuscation.
  • Replaced the old control-flow TODO wrapper with a state-machine style obfuscator.
  • Added VM dispatch randomization and fake opcode handlers.
  • Adjusted pipeline ordering so VM/bytecode combinations remain runtime-safe.
  • Hardened target compatibility for Luau/GLua, including safer anti-tamper behavior.
  • Fixed compressor output that produced Luau-invalid do;/then; style syntax.
  • Increased speed for testing and generating examples
  • Updated docs/version text to 2.1.0.

- Introduced a new module for constant encoding to obfuscate numeric and primitive constants.
- Updated the manifest to include the new constant encoding preset and module.
- Adjusted pipeline orders for existing modules to accommodate the new module.
- Enhanced the VMGenerator to include randomized fake handlers for opcode dispatching.
- Modified the anti-tamper module to remove debug keys for improved security.
- Refactored the bytecode encoder to implement a more complex encoding scheme with randomization.
- Improved the compressor to handle semicolon removal more effectively.
- Revamped the control flow obfuscator to utilize randomized naming and fake blocks.
- Updated the string encoder to enhance encoding methods and improve variable naming.
- Added tests to ensure the new constant encoding module is properly integrated and functional.

@zeusssz zeusssz left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall good. only the changes i highlighted on specific files are something to note. lgtm otherwise

Comment thread src/hercules.lua
Comment thread README.md
@Serpensin
Serpensin requested a review from zeusssz June 24, 2026 12:20

@zeusssz zeusssz left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some of these changes dont necessarily apply, because of the pipeline, but since some presets dont use some modules, and we can also run with custom modules, it may be better to seal the gaps mentioned, as they wont cause much harm. after these, it can be merged.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seen this, and i have a few questions. in line 98

local num = code:match("^(%d+)", pos)

only handles decimal integers.
so something like

local x = 3.5

would be left untouched,

since we aim to be compatible with most flavours, i.e luau, glua, i think handling of things like floats is good for compatibility in general. even things like binary literals, which are supported in Luau will be ignored, so i think broadening the scope is a good idea.

i also think if we run

type X = true | false

the obfuscator will say type X = (not not 1) | (not 1), which is incorrect.

and from a security perspective, the replacement itself is trivially automated. from what i can see, it either ends up as

(#"xxxxx"+k)

or

((n+a)-a)

this predictability is also present in other places, i.e. in true/false, nil, etc.

maybe selecting from a pool of replacements is a good idea (you will have to factor in how expensive one is as compared to the other as well, when selecting your pool)

for these numbers especially, i believe you could utilise recursive generation, i.e. instead of 123 becoming

(123+456)-456

put out

(((56+67)+431)-431) -- where 56 and 67 are themselves generated

so it becomes something like

((((20+37)+66)+431)-431)

also, backticks are not recognised.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the anti tamper is actually a bit weaker than older versions...

like here

["string.byte"] = string.byte

for one, the reference is resolved when the code starts running, not when output is generated.

and if someone overrides it, so something like

string.byte = fake

so the checks still pass, because youre comparing the function to itself. it itself isnt immutable.

one more thing, it only checks

if T(ref) ~= "function" then

not actually seeing if its a real function
so if someone does the below it will still pass.

math.abs = function(x)
    return 123
end

also, the script only compares type(mt.__index) against "function", "table" etc. if someone replaces __index with another function, the check still succeeds.

again, you're checking the type, not the object, when ideally, you should.

and the debug library check is also similar, and it suffers from the problem of someone replacing, let us say, debug.getinfo with

function(...)
    return {}
end

and it still passes. it isnt that strong of an anti hook measure..

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it assumes load, string.dump, loadstring exist in all envs, but may not be viable in luau/other lua envs.
chunk sizes are always 37–113 bytes, maybe we should derive this from the size rather than it being static

i have a question too, here youve done mod = 2147483647. i presume its the mersenne prime for the lcg mentioned below, but if not, might be worth having it vary so every script wont have the same prng (only applies if it was chosen intentionally)

another thing about that, is there reason three multipliers were chosen with a modulus of 2^31 - 1? from what i saw online, its seen often with different moduli (i.e. 2^31/2^32), since the approach isnt wrong, i thought it would be worth asking why so

Note Below (Not necessarily needed changes)
i researched on what you implemented here, and it seems to be lcg with a byte extracted from state. but it isnt really cryptographic

state = (state * mul + inc) % mod

recovering the original bytecode is straightforward because the seed, the parameters, the algorithm are all embedded. i guess this is unavoidable, but worth noting

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the fake blocks have conditions that are provably true (a+b == b+a), so the dead branch is going to be flattened by any deobfuscator/optimiser worth its salt. it may be worth making these predicates less obviously constant

the real branch also has the same issue

if ((guard*guard)-(guard*guard)) == 0 then

which is 0==0, so else branch is dead

state machine also only visits one real state, so effectively we reach the real code and exit, and every other fake branch also exits. i suggest linking fake states together randomly instead of making them all terminate, because currently its unreachable decorations on a single real node, and so the CFG is less linear

output is also very uniform for this one module, its like this

do
    while true do
        if ...
        elseif ...
        elseif ...
        else
            break
        end

        if state == exit then
            break
        end
    end
end

and since this is the same for each file, its automatable, and combined with the above, its pretty easy for it to be sidestepped

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

long comments are detected but improperly parsed, so the the code ends up treating it like a comment only up till the newline, so any words in the long bracket comments gets treated as a string, and may be encoded

if ch == "-" and code:sub(pos + 1, pos + 1) == "-" then
    local nl = code:find("\n", pos)
    ...
end

another question i had was around using

load("return " .. literal)

to evaluate string literals. i dont immediately see an issue because only quoted literals reach this point, but i was wondering if theres a reason for evaluating them instead of decoding escape sequences, because from research, it seems to also be a viable approach.

the generated decoder is also identical between outputs. it may be good to be varying the decoder structure a bit more, otherwise it becomes recognisable and then it is easier to reverse, especially since this occurs after the vm

also long bracket strings ([[...]]/[=[...]=]) are currently skipped entirely, so those string literals remain unobfuscated

a minor note is the XOR implementation, it is a bit slow, but this is a minor problem/nitpick

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one thing i did notice is that the fake handlers are always emitted before the real ones. although the real handlers are shuffled, the fake block is still a recognisable prefix to it, so maybe itll be worth inserting the fake handlers into dispatch_ops (or another combined list) before shuffling it, so both real and fake handlers are interleaved and harder to separate..

also, the fake handlers currently all have essentially the same body, as below.

local _hx = ...
_hx = (_hx * 3) % 997

since they're never actually reached, it isn't a correctness issue, but the repeated structure makes them easy to see and wipe. introducing a small pool of different fake handler things might make them blend in a little better, despite the steps after it, it might be a good change

Comment thread tests/README.md

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe change the filename to TESTING.md, and it could be worth manually writing this, as it might leave a bad taste in the mouth of some that it has been ai generated.

@Serpensin

Copy link
Copy Markdown
Contributor Author

Just make the changes yourself. I don't really want to spend that much time on this.
I've given you a way better foundation, that it was before. I only let AI work on this, because you wouldn't do anything in over one year.
So as long as you run the tests on your PC and keep the manifest stuff, I don't want to spend more time on a project with a language, I can't even read properly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants