Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

redos in es5-ext #201

Closed
GAP-dev opened this issue Sep 26, 2023 · 24 comments
Closed

redos in es5-ext #201

GAP-dev opened this issue Sep 26, 2023 · 24 comments
Assignees
Labels

Comments

@GAP-dev
Copy link

GAP-dev commented Sep 26, 2023

Test Environment:

Apple M1 macbook air, 2020 (ventura 13.3.1)

node module

name : es5-ext

node js

version : v18.16.0

2023_09_26

이동하 ( Lee Dong Ha of ZeroPointer Lab )

강성현    ( kang seonghyeun )

박성진    ( sungjin park )

김찬호    ( Chanho Kim )

이수영    ( Lee Su Young )

김민욱    ( MinUk Kim )

Team : For CVE

Subject : ReDoS_Vulnerability_Report_in_the_es5-ext_Module_copy.js_2023-09-26 011618.041116.txt

Dear es5-ext team,
I am writing to report a potential ReDoS (Regular Expression Denial of Service) vulnerability in your es5-ext module. It has come to my attention that the current regex implementation for parsing values in the module is susceptible to excessive backtracking, leading to potential DoS attacks.The regex implementation in question is as follows:

/^\sfunction\s([\0-')-\uffff]+)\s(([\0-(-\uffff]))\s*{/

This vulnerability can be exploited when there is an imbalance in parentheses, which results in excessive backtracking and subsequently increases the CPU load and processing time significantly. This vulnerability can be triggered using the following input:

'function{' + 'n'.repeat(31) + '){'

Here is a simple PoC code to demonstrate the issue:

const protocolre = /^\sfunction\s([\0-')-\uffff]+)\s(([\0-(-\uffff]))\s*{/;

const startTime = Date.now();
const maliciousInput = 'function{' + 'n'.repeat(31) + '){'

protocolre.test(maliciousInput);

const endTime = Date.now();

console.log("process time: ", endTime - startTime, "ms");

Modification adds a limit to the number of digits, thereby preventing the ReDoS vulnerability from occurring.
I believe it is crucial to address this issue promptly to ensure the security of your module for all users. Please let me know if you need any further information or assistance with this matter.

https://www.npmjs.com/package/es5-ext

@medikoo medikoo self-assigned this Sep 26, 2023
@medikoo
Copy link
Owner

medikoo commented Sep 26, 2023

@GAP-dev, thanks for opening that.

Have you approached the real-world scenario when that happened?

In normal circumstances, I believe ReDos cannot happen, as this regex is run only on function string representations where, by design, we cannot get into excessive backtracking (see that first group excludes ( character, which in all cases will follow the group (e.g., 'function ' + 'n'.repeat(31) + ' (){} which is a valid representation match on the spot.

Your test example ('function{' + 'n'.repeat(31) + '){') doesn't show a valid function string representation

Such an attack would require overriding toString on a function instance first (so it returns an invalid string that triggers the issue). Still, this should be out of scope as if an attacker can manipulate the objects in the process. Similarly, we can just run an infinite loop to block the process.

@medikoo medikoo assigned GAP-dev and unassigned medikoo Sep 26, 2023
@medikoo medikoo added the question Further information is requested label Sep 26, 2023
@GAP-dev
Copy link
Author

GAP-dev commented Sep 27, 2023

Yes, This vulnerability can't exploit right now.

It seems that the input string is being sanitized. However, this is a potential vulnerability. If combined with another vulnerability, it could be exploited in a real-world service.

It should be implemented to further protect the system from potential attacks.

It's always a good practice to assume that attackers can and will find and exploit vulnerabilities, so a defense-in-depth approach, employing multiple layers of security controls and measures, is highly recommended.

@medikoo
Copy link
Owner

medikoo commented Sep 27, 2023

@GAP-dev what solution do you propose?

@GAP-dev
Copy link
Author

GAP-dev commented Sep 27, 2023

/^\sfunction\s+[a-zA-Z_$][0-9a-zA-Z_$]\s*([^\)])\s{/
like this

@medikoo
Copy link
Owner

medikoo commented Sep 27, 2023

@GAP-dev Version you propose is broken:

const re = /^\sfunction\s+[a-zA-Z_$][0-9a-zA-Z_$]\s*([^\)])\s{/

console.log(re.test(String(function myFuńctión() { })))

Logs false

@GAP-dev
Copy link
Author

GAP-dev commented Sep 27, 2023

oh, sorry
/function\s+([\p{L}$][\p{L}\p{N}$])\s()\s*{\s*}/gu
how about this?

const regex = /function\s+([\p{L}_$][\p{L}\p{N}_$]*)\s*\(\)\s*\{\s*\}/gu;
const str = `
function myFuńctión() { }
function myfunciton() { }
function F() { }
function add() { }
`;
let m;
while ((m = regex.exec(str)) !== null) {
    console.log(m[1]);
}

image

@medikoo
Copy link
Owner

medikoo commented Sep 28, 2023

@GAP-dev what you test above is not a valid function string representation, also in this package we need to support all ES5+ engines, while u flag was introduced with ES2018

@GAP-dev
Copy link
Author

GAP-dev commented Sep 30, 2023

or
/^\sfunction\s(.)?\s((.))?\s*{/
😅

@medikoo
Copy link
Owner

medikoo commented Oct 2, 2023

@GAP-dev

const re = /^\sfunction\s(.)?\s((.))?\s*{/

console.log(re.test(String(function myFuńctión() { })))

Prints false

@GAP-dev
Copy link
Author

GAP-dev commented Oct 3, 2023

sorry...

const re =  /^\s*function\s*(.)*\s*\(([\0-(*-\uffff]*)\)\s*\{/
console.log(re.test(String(function myFuńctión() { })))

@medikoo
Copy link
Owner

medikoo commented Oct 3, 2023

@GAP-dev this also won't work, as matching groups need to properly capture function name and its arguments, with what you've proposed we get n in function name matching group

@medikoo
Copy link
Owner

medikoo commented Oct 3, 2023

@GAP-dev Ideally would be if you propose change as PR, as then tests will automatically pick issues for you

@SCH227
Copy link

SCH227 commented Nov 28, 2023

Hi everyone!
I also found the inefficient regex issue in copy.js from my side.
It is recommended to disclose this type of security issues privately. However, as this was already made public, I will share here a PoC I discovered which triggers the ReDoS by abusing copy.js usage directly:

const copy = require('es5-ext/function/#/copy');
var payload = function CRASHCRASHCRASHCRASHCRASHCRASHCRASHCRASHCRASHCRASHCRASHCRASHCRASH(consoleLog = () => console.log("bu")) {consoleLog();}
var copiedFunction = copy.call(payload);

@medikoo
Copy link
Owner

medikoo commented Nov 28, 2023

@SCH227 Thanks for putting light on that. So, apparently, there's a real-world case where this issue can be triggered.

I will work this week on fixing that. I don't think there's a reliable way to fix the regexp (if you feel otherwise, I'll be grateful for a hint). The solutions I see:

  • In function/#/copy - we can simplify regex just to retrieve the function name. Other logic can be easily refactored to not rely on parsed-out arguments input
  • In function/#/to-string-tokens - there's no simple solution. I think I should switch from regex to a state machine based parser as esniff

@medikoo medikoo added bug and removed question Further information is requested labels Nov 28, 2023
@SCH227
Copy link

SCH227 commented Nov 28, 2023

@medikoo, thank you for the prompt response, and for this awesome project!

Your proposed solutions make sense to me. I believe they will narrow the attack surface, complex regexes are hard to debug so prone to errors. But if you want to keep them, I have these ideas:

  • The root cause of the exponential complexity in copy.js regex seems to be the nested quantifier. Does a regex like ^\s*function\s*([\0-')-\uffff]*)\s*\(([\0-(*-\uffff]*)\)\s*\{ fails in any corner case you know?
  • The previous proposed fix (and also the regex in to-string-tokens.js) is still vulnerable to a lesser complexity backtracking. If it is not mandatory to allow arbitrary whitespaces, a mitigation to this issue is to limit them: ^\s{0,10}function\s{0,10}([\0-')-\uffff]*)\s{0,10}\(([\0-(*-\uffff]*)\)\s{0,10}\{ . That, together with limiting the maximum length of the function declaration to parse to a reasonable value, may make exploitation unfeasible.
  • Another possibility would be to implement another function stripWhitespaces(), in charge of filtering out unnecessary sequences of whitespaces before String().match(re)

@medikoo
Copy link
Owner

medikoo commented Nov 28, 2023

The root cause of the exponential complexity in copy.js regex seems to be the nested quantifier.

Indeed, great find 👍 I didn't notice that quirk there. I believe (and as I tested) changing (x+)* into (x*) won't make any functional difference, and is cleaner.

If it is not mandatory to allow arbitrary whitespaces, a mitigation to this issue is to limit them

This is more controversial, as functions can be defined with absurd whitespace gaps, and its stringified form will reflect that. Still, as I test, we don't have to restrict too much, e.g., we can do \s{0,100}, and then putting the process on a stall should no longer be possible. WDYT?

I believe I can go with that, and in the unlikely event of a real-world case where it appears to be breaking, I'll probably revert to a state-machine-based idea.

@SCH227
Copy link

SCH227 commented Nov 29, 2023

Mostly I agree.
On my tests, the regex /^\s{0,100}function\s{0,100}([\0-')-\uffff]*)\s{0,100}\(([\0-(*-\uffff]*)\)\s{0,100}\{/ executes in around 1 second for a backtracking payload of length 20,000 (vs. ∼0.2 ms for a no backtracking one).
If you believe that limiting functions length to something in the order of 10,000 will not break real-world use-cases, I would go for adding that too so to avoid the chance of abusing the regex with loong payloads.

@medikoo
Copy link
Owner

medikoo commented Nov 29, 2023

executes in around 1 second for a backtracking payload of length 20,000

By "backtracking payload," do you mean backtracking function body? If so, we can skip that in regex (so keep regex as you pasted in the last comment)

On my side, this regex executes in 11ms for function with a string length of 30,000, and it doesn't seem to get greater for more extensive functions (I got same result for 200,000 length)

@SCH227
Copy link

SCH227 commented Nov 29, 2023

Sorry if I was not clear enough, let me explain.
After fixing the nested quantifier, the regex is still vulnerable to a lesser complexity catastrophic backtracking but with a different payload. I compared the times of execution for a payload causing backtracking and for another one that does not, seeing how they grow with the payload length:

const regex = /^\s{0,100}function\s{0,100}([\0-')-\uffff]*)\s{0,100}\(([\0-(*-\uffff]*)\)\s{0,100}\{/;

// String known to NOT cause catastrophic backtracking
const str1 = 'a'.repeat(5000);

// String known to cause catastrophic backtracking
const str2 = 'function' +' '.repeat(5000) + '(consoleLog = () => console.log("bu")) {consoleLog();}';

console.time('Execution Time - No Backtracking');
const result1 = regex.test(str1);
console.timeEnd('Execution Time - No Backtracking');

console.time('Execution Time - Backtracking');
const result2 = regex.test(str2);
console.timeEnd('Execution Time - Backtracking');

Output:

Execution Time - No Backtracking: 0.187ms
Execution Time - Backtracking: 253.638ms

For a payload length of 20,000:

Execution Time - No Backtracking: 0.192ms
Execution Time - Backtracking: 1.036s

For a payload length of 200,000:

Execution Time - No Backtracking: 0.358ms
Execution Time - Backtracking: 10.456s

@medikoo
Copy link
Owner

medikoo commented Nov 29, 2023

@SCH227, thanks for the explanations.

I don't think it makes sense to take into account the str1 case, as it doesn't reflect a valid function string.

Concerning other cases, after thinking it through, I think the best would be to switch to a state-machine approach, as even if we improve regex with proposed suggestions, it'll remain invalid for some ES2015+ function cases (which were not around when this utility was originally written).

e.g. it'll fail for: function (foo = function () {}) {}, and this cannot be handled with regex (at least not with one we have in JS engine).

Therefore, I think the ultimate solution would be to refactor this to esniff based version, I'll take care of it in next days

@medikoo medikoo assigned medikoo and unassigned GAP-dev Nov 29, 2023
@SCH227
Copy link

SCH227 commented Nov 29, 2023

Yes, str1 case was only meant for timing purposes.

I agree, that would be the best quality solution.

Actually, my PoCs rely on the fact that the regex doesn't match some valid function cases. You can see that if there's a match, backtracking does not occurs:

medikoo added a commit that referenced this issue Feb 23, 2024
@medikoo
Copy link
Owner

medikoo commented Feb 23, 2024

@GAP-dev and @SCH227 big thanks in helping coining that issue

Fixed with:

Published with v0.10.63

@medikoo medikoo closed this as completed Feb 23, 2024
@SCH227
Copy link

SCH227 commented Feb 25, 2024

@medikoo thank you for the fix!
Do you believe this issue applies for creating a repository security advisory? If so, please include @GAP-dev and me as reporters/analysts

@medikoo
Copy link
Owner

medikoo commented Feb 26, 2024

@SCH227 very good point. I've created it: GHSA-4gmj-3p3h-gm8h

renovate bot referenced this issue in Unleash/unleash Feb 27, 2024
[![Mend
Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [es5-ext](https://togithub.com/medikoo/es5-ext) | [`0.10.62` ->
`0.10.63`](https://renovatebot.com/diffs/npm/es5-ext/0.10.62/0.10.63) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/es5-ext/0.10.63?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/es5-ext/0.10.63?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/es5-ext/0.10.62/0.10.63?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/es5-ext/0.10.62/0.10.63?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

### GitHub Vulnerability Alerts

####
[CVE-2024-27088](https://togithub.com/medikoo/es5-ext/security/advisories/GHSA-4gmj-3p3h-gm8h)

### Impact

Passing functions with very long names or complex default argument names
into `function#copy` or`function#toStringTokens` may put script to stall

### Patches
Fixed with
medikoo/es5-ext@3551cdd
and
medikoo/es5-ext@a52e957
Published with v0.10.63

### Workarounds
No real workaround aside of refraining from using above utilities.

### References

[https://github.com/medikoo/es5-ext/issues/201](https://togithub.com/medikoo/es5-ext/issues/201)

---

### Release Notes

<details>
<summary>medikoo/es5-ext (es5-ext)</summary>

###
[`v0.10.63`](https://togithub.com/medikoo/es5-ext/blob/HEAD/CHANGELOG.md#01063-2024-02-23)

[Compare
Source](https://togithub.com/medikoo/es5-ext/compare/v0.10.62...v0.10.63)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "" in timezone Europe/Madrid,
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR has been generated by [Mend
Renovate](https://www.mend.io/free-developer-tools/renovate/). View
repository job log
[here](https://developer.mend.io/github/Unleash/unleash).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy4yMTIuMCIsInVwZGF0ZWRJblZlciI6IjM3LjIxMi4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiJ9-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

3 participants