Sneaky Javascript - Anonymous Function Abuse
Preface
I find myself muttering “Hmm…I wonder if this will work…” under my breath a lot, usually as an excuse to distract myself from whatever task I’m supposed to be working on. On a good day I’m able to stifle the urge to pull on whichever thread has stolen my attention, but this was not one of those days.
The musing caught me shortly after I finished writing an anonymous function declaration in a Javascript codebase I was working on. It prompted me to slap together a five-line code review challenge that ended up being quite tricky for many folks to solve.
The Challenge
The challenge itself is quite simple - review the code, guess the output, then verify your guess by executing the code.
/*
Simple code review challenge. Guess the output, then run it and see what it actually is.
*/
const AGE = 25
const MAX_AGE = 20
// if the condition fails, run this function
const failFunction = (() => console.log('Old'))
// if the condition succeeds, run this function
const successFunction = (() => console.log('Young'))
// Do a check to see if the age is less than or equal to the max age and run the appropriate function
(AGE <= MAX_AGE) === true ? successFunction() : failFunction()
Pretty straightforward, right? Let’s break it down from the perspective of a normal entry/mid-level Javascript/Typescript developer.
Below, we have two numeric constants being defined and assigned. This seems pretty normal.
const AGE = 25
const MAX_AGE = 20
Next we have two functions being defined for later usage.
// if the condition fails, run this function
const failFunction = (() => console.log('Old'))
// if the condition succeeds, run this function
const successFunction = (() => console.log('Young'))
And finally we have a ternary operation that compares AGE
and MAX_AGE
, checks if truthy, and evaluates the successFunction
if true, otherwise it evaluates the failFunction
.
// Do a check to see if the age is less than or equal to the max age and run the appropriate function
(AGE <= MAX_AGE) === true ? successFunction() : failFunction()
Since we know the AGE
and MAX_AGE
values, we can simplify the last line of code like:
(false) === true ? successFunction() : failFunction()
As a developer, I’d probably say something like:
“Well, false is not equal to true, therefore
failFunction
would run. So the output would be ‘Old’. Final answer.”
This would be a perfectly reasonable guess, and this was the conclusion that the majority of the folks I polled arrived at.
The Actual Results
So when we run the original script above, we can see the following console output:
Young
Old
This is the point where the folks I gave this challenge to started scratching their heads. I was getting questions like:
- “Is the ternary causing them to both evaluate?”
- “Is it due to the strict type equality checking?”
- “Wait..what?”
- “I hate Javascript.” (Not a question, but worth including because lol)
How It Works
This challenge works really well due to the following:
- The Javascript parser is super loose and greedy.
- By leaving comments, we can lead the developer on a bit and control the narrative regarding execution flow.
- The code itself is incredibly simple, so the developer makes very reasonable assumptions.
Let’s take a look at a stripped down version of the code.
const AGE = 25
const MAX_AGE = 20
const failFunction = (() => console.log('Old'))
const successFunction = (() => console.log('Young'))
(AGE <= MAX_AGE) === true ? successFunction() : failFunction()
The things that should jump out at you as “odd”, but not entirely suspicious:
- Extra parentheses around the function definitions.
- In many cases this is fine.
const ten = (10);
still evaluates to10
. Maintaining the correct order of operations is (in my opinion) a good habit for larger expressions.
- In many cases this is fine.
- No semicolons.
- Javascript doesn’t require you to use semicolons, and in fact, some linter profiles within the JS/TS ecosystem have rules to prevent the developer from using semicolons.
- Semi-confusing ternary.
- As mentioned above, managing the order of operations for an expression is important.
Now here’s what is actually happening
const failFunction = (() => {
console.log('Old')
})
const successFunction = (() => {
console.log('Young')
})(AGE <= MAX_AGE)
if (successFunction === true) {
successFunction()
} else {
failFunction()
}
Wait, what?
Yep. The additional parentheses around the anonymous function definitions and the lack of semicolons creates a Immediately Invoked Function Expression.
The successFunction
anonymous function invokes itself, and assigns undefined
to the successFunction
variable.
Then the ternary does a comparison evaluation to check if undefined === true
, which it isn’t, and evaluates failFunction
.
“But you have whitespace and comments between the function and parameter segments!”
Yep! Javascript doesn’t care about trivial things like that if you’re not using semicolons.
// The following will output "2" to the console.
console.log
/* blah blah */
/* blah blah */
(1 + 1)
Is this actually a problem?
Under the right circumstances it could be. Take a look at the following “realistic” snippet:
// index.js
// This is the main entrypoint for the application.
// It detects whether or not we're in a production
// environment and runs the appropriate function.
// This is the real function that gets called
const startApp = (() => {
console.log('The real function was called. Awesome!')
})
// This debug function only exists for dev purposes.
// It never actually gets called, so don't worry about it!
const startAppDebug = (() => {
console.log('🤠howdy partner')
// Database "drop table" migration(s)?
// Dump database?
// Write creds to publicly-accessible log file for "debug purposes"?
})
// Check if we're in debug more or not
(process.NODE_ENV !== 'production') ? startAppDebug() : startApp();
Now consider the following:
- JSLint/ESLint profiles are usually committed to repositories.
- With a bit of scraping and JSON parsing, one can very easily find repositories which wouldn’t trigger a build failure due to the lack of semicolons.
- This includes repos without linter configuration at all.
- One can sort by stars or NPM downloads to find high-impact modules to target.
- With a bit of scraping and JSON parsing, one can very easily find repositories which wouldn’t trigger a build failure due to the lack of semicolons.
- Open source maintainers are (typically) overworked and unappreciated.
- New PR’s are always welcome, especially from folks who have established themselves as being helpful in the past.
Obviously I’m not saying that this is a huge issue, but given the number of folks I polled, this could be an effective way to slip some sketchy code into a repo or even to a targeted user.
Exfiltrating ~/.ssh
, .env
, etc could be done quite easily under the right circumstances if the tactic above is implemented in a Node environment.
One might attempt to steal cookies, track users, or append remote scripts
on the frontend.
Mitigation
This kind of syntax abuse can be mitigated by:
- Understanding this little “gotcha” and educating your team about it.
- Enforcing the use of semicolons in your codebase.
- You can also enforce the outerIIFEBody rule to make the IIFE functions more readable and apparent. Other IIFE rules exist, too!
Credits & Shoutouts
- Me, writing the challenge and this article
- A curious and kind list of Twitch streamers who agreed to take the challenge while live-streaming in order to help validate the idea: