26/01/26
HowJSON-JavaScriptDiscrepancyLeadstoRCE(Lodash)
Official Writeup: gap | 0xL4ugh v5
Thanks for sharing!
بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ

"Once you see it, you can't unsee it."
Hello!! "Gap" challenge writeup is finally here, this challenge wasn't about finding a complex gadget chain in the application logic. It was about exploiting a fundamental misalignment between how JSON defines data and how JavaScript defines code. Believe me, you are gonna enjoy this one!
We are going to trick
lodash.template into treating a single JSON object key as multiple function arguments, allowing us to smuggle in an RCE payload via ES6 default parameters. Let's break it down step-by-step.1. The Function() Constructor
To understand this exploit, you have to understand exactly how
new Function() works in JavaScript. It is the core mechanism Lodash uses to compile strings into executable templates.Most developers see this:
But the first argument(s) are just strings. If you pass an array, JavaScript automatically joins it with commas.
The JavaScript engine parses that string (
"a,b") as a formal parameter list. It doesn't care that a and b came from an array or a single string. It just sees the comma as a separator.Where is the dnager? If we can control that string, we can inject commas. If we can inject commas, we can define new arguments that the developer never intended to exist.. 😃
2. The Bug: Trusting Object.keys()
In
lodash.template, the library allows you to pass an imports object. This is meant for helper functions (like formatDate).Here is the actual vulnerable code from
lodash.js:Lodash merges the user options with settings, then extracts the keys and values. Crucially, it blindly trusts
importsKeys.A few lines later, it constructs the template function:
The issue here is that
importsKeys is passed directly as the first argument to Function. It assumes these keys are just variable names. It performs no validation.3. Comma Injection
This is where JSON and JS collide.
In JSON, this is a single key:
When Lodash processes this:
- Keys:
["context, injected"](Array with 1 string) - Values:
["value"](Array with 1 string)
When
new Function() receives that key array, it converts it to a string: "context, injected".The Result:
We have successfully desynchronized the function signature from the data.
- The function expects 2 arguments (
context,injected). - Lodash only provides 1 value (
"value").
This creates a "Gap" (hence the name 😁). The first argument (
context) eats the value ("value"). The second argument (injected) is left starving—it receives undefined.4. The Trigger: ES6 Default Parameters
In modern JavaScript, you can define default values for arguments. These execute only if the argument is
undefined.You must understand this sentence well, read it again
If we call
test(undefined), the code executes.We don't need to inject into the function body. We don't need to break out of a string context. We execute code during the argument initialization phase, before the function body even runs. Neat, right?
5. Exploit Strategy A: The "Pinned" Method
We can't just send a raw payload because Lodash is "helpful". It automatically appends its own library object (
_) to the end of the imports.If we blindly send our payload, the argument list looks like this:
function(unused, payload, _)And the values look like this:
["val", lodashLibraryObject]The values slide over.
unused gets "val". payload gets lodashLibraryObject. Since payload is not undefined, our code does not run.The Solution: Pinning _
We explicitly include
_ in our JSON to control the alignment.The Payload:
The Execution Flow:
-
Parsing Keys: Lodash sees keys:
["_", "unused, x = console.log('PWNED')"]JS Engine sees args:_, unused, x(plus the assignment code) -
Parsing Values: Lodash sees values:
["pinned", "val"] -
The Mapping (The Moment of Truth):
| Function Argument | Value Received | Result |
|---|---|---|
_ | "pinned" | Standard assignment |
unused | "val" | Standard assignment |
x | undefined | Ran out of values! Default param executes. |
6. Exploit Strategy B: The "Over-Extension" Method
Alternatively, you can choose not to fight the default
_ injection, but rather swallow it.If we know Lodash will append
_ to the keys and the library object to the values, we can structure our payload to "eat" that extra value and still come up short.The Payload:
The Execution Flow:
-
Parsing Keys:
- User key:
"a,b,c = ..." - Default key:
"_"(Appended by Lodash) - Resulting Signature:
function(a, b, c = ..., _)
- User key:
-
Parsing Values:
- User value:
"bruh" - Default value:
lodashLibraryObject(Appended by Lodash) - Resulting Values:
["bruh", lodashLibraryObject]
- User value:
-
The Mapping:
| Function Argument | Value Received | Result |
|---|---|---|
a | "bruh" | Filled by your value |
b | lodashLibraryObject | Filled by the default _ "sliding" over |
c | undefined | Ran out of values! Default param executes. |
_ | undefined | Irrelevant |
This method is arguably cleaner as it requires fewer keys in the JSON payload, but requires understanding that you are creating three slots (
a, b, c) to consume two values ("bruh", lodashLib).7. Final Payload
We combine everything. We use the comma injection to create the gap, the pinned
_ to align the gap (using Strategy A)Request:
What happens on the server:
- Express parses the JSON body.
lodashtakes the keys.new Functioncreates:- The function is called with
("pinned", "val"). _="pinned".unused="val".x=undefined.- Bingo!! The default parameter evaluates. The shell command runs.
That was it! These technical details were really fun to understand. Sadly, it was solved by AI by a lot of players, but at least now I know how to make new anti-AI challenges 😈😈. Stay tuned for more 💖


