-
Notifications
You must be signed in to change notification settings - Fork 1.6k
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
Add isolated PCRE match limits as a layer of ReDoS defense #2736
Add isolated PCRE match limits as a layer of ReDoS defense #2736
Conversation
Hello @brandonpayton , Thanks for the contribution. While that earlier PR predates my involvement in the project, I do know and agree that concern about limits intended for ModSecurity having effects beyond ModSecurity was indeed a reason to avoid some possible implementations. I don't know that there were any such tangible reasons associated with not considering the per-transaction overrides that you are proposing here. I have not examined the code in this PR in detail, but I'll make a few observations to start with:
|
142bffa
to
a43a773
Compare
Hi @martinhsv, thank you for your feedback on this PR. I'm happy to hear you are open to changes of this nature.
The regex util updates in this PR just add a basic Is there anything you'd like to see to help move this PR forward? Just a heads up: I am taking a sabbatical for the next few months and may take a while to respond, but I am still very much interested in this change. Note: I rebased and resolved merge conflicts but haven't had a chance to test that update yet. |
IMO, it's better to use atomic group or possessive quantifiers for regex rules. |
02a16fd
to
5cc77e4
Compare
Hi @liudongmiao, do you mean that it would be better if rule authors wrote better, safer regular expressions? If so, I agree. The purpose of this PR is to serve as a safety net when loading rules written by others. One example is the OWASP Core Rule Set. We have encountered issues with some of these rules in production. Of course, it would be better to make fixes to individual rules, but to avoid encountering new problems in production, it is valuable to have a safety net like these match limits. |
Hello @martinhsv, Would you like me to make any changes to this PR? Is there anything I can do to help this PR or something like it to land? My employer Automattic is interested in using this regex safety net so we can more comfortably leverage community rule sets like those from OWASP. We have seen some ReDoS issues with open source rule sets and would like to mitigate the risk. |
Hello @brandonpayton , Yes, this is one of the items that I'd hoped to get back to during the coming period. I'll review this within the next few days. |
Hi @brandonpayton , There are a few design assumptions here that I think might be worth considering. The population of your two new variables is 'first wins' -- i.e. they only get set if they have not already been set. In general ModSecurity has a 'last wins' approach. This is true for variables like MATCHED_VAR and for pcre API matches as well (a repeated group like If we continue with only holding a single value, I would suggest we use 'last wins' instead. Counterarguments are welcome. But this bleeds into some bigger questions. I'm somewhat hesitant about the proposed new variable RX_ERROR_RULE_ID. This is not something ModSecurity has done before. Default rule-specific processing information tends to be communicated through ModSecurity's Debug Log. I'm not necessarily rejecting the concept of maybe providing this sort of information through a variable. After all, some installations may prefer to run without the debug log engaged, and having some basic information available in such cases may be helpful. However, if we do wish to pursue that goal, we might wonder if we really want to restrict the admin to only knowing about 1 of possibly multiple failures. Also, we may want to think more broadly: a processing failure that is related to a specific rule may be relevant to other types of operators besides rx. Maybe we should really be considering a new variable in the form of a collection like RULE_ERROR where each key is the rule id where a failure was seen, and the value is a fixed text describing the error. I'm not sure I've thought this through fully; and implementing an idea like that might be better done separately, as then it would have better visibility with (and potential input from) the community. As an immediate measure to get your central goal moving forward, I'm wondering if it might be fruitful to limit the near term change to the existing model: set a single signalling variable (in ModSecurity v2, this was called MSC_PCRE_LIMITS_EXCEEDED) and leave additional information communication to the Debug Log. Thoughts and counterarguments are welcome. |
Hi @martinhsv, Thank you for your thought and guidance. What you suggested sounds reasonable and wise.
It's good to hear about "last wins" versus "first wins", and I do not believe there is any good reason for this to deviate from overall ModSecurity philosophy and practice.
OK, it seems like we should continue favoring the Debug Log, especially for this iteration.
At the time, I was not sure how to do something like this or whether it was possible. If the community ends up wanting something like this, it sounds good to add in a follow-up patch.
That's a good point about community input. Agreed.
Do I understand correctly that we are talking about dropping the |
Because there is also the possibility of other PCRE errors, we may need an |
I've updated this PR to use the simpler flag variables I don't love using multiple flag vars but believe no PCRE exec errors should pass completely silently because it means the rule where the error occurred was effectively ignored. Perhaps, if two flag vars are undesirable, using a single |
I know that libmodsecurity3 has only one type of message in error log (we can call it as a "regular" message), while mod_security2 has also the "Execution error" type of messages. I'm sure it would hard to add, but would you consider adding a similar log message (to the Nginx's error.log particularly) if the engine runs into a match limit, as like the mod_security2? (See here and here). The line seems something like this:
and appears without triggering of any rules. That would be very useful. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A few notes ...
Hmph. My comments seem to be gone. I'll give it another go ...
@@ -470,6 +471,7 @@ class RulesSetProperties { | |||
ConfigDouble m_requestBodyLimit; | |||
ConfigDouble m_requestBodyNoFilesLimit; | |||
ConfigDouble m_responseBodyLimit; | |||
ConfigInt m_pcreMatchLimit; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm ok with this size. 'int' should be big enough on any architecture that matters.
Note, however, the disparity between pcre1 and pcre2 (long vs. uint32_t) for this. This may another reason to avoid doing the get_default
headers/modsecurity/transaction.h
Outdated
@@ -134,6 +134,8 @@ class TransactionAnchoredVariables { | |||
m_variableInboundDataError(t, "INBOUND_DATA_ERROR"), | |||
m_variableMatchedVar(t, "MATCHED_VAR"), | |||
m_variableMatchedVarName(t, "MATCHED_VAR_NAME"), | |||
m_variableMscPcreErrored(t, "MSC_PCRE_ERRORED"), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given the name of MSC_PCRE_LIMITS_EXCEEDED, I can understand the temptation to use the verb-past-tense.
But for consistency with existing variables like MULTIPART_STRICT_ERROR and INBOUND_DATA_ERROR (etc.), I would suggest dropping the 'ED' at the end.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That makes good sense. Changed the name to MSC_PCRE_ERROR
.
src/utils/regex.cc
Outdated
@@ -311,5 +378,43 @@ int Regex::search(const std::string& s) const { | |||
#endif | |||
} | |||
|
|||
unsigned long Regex::get_default_match_limit() const { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure I see the value in doing this.
If the ModSecurity config has not specified a limit, why do we need to do anything special here?
Why not just allow the builtin limits within PCRE to take effect?
I'm a little bit concerned especially that this code will get run for every attempt at a match -- even a relatively small number of cycles and memory jumps can add up.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for bringing this up. I agree that we can drop get_default_match_limit()
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dropped get_default_match_limit()
. We are now defaulting the match_limit param to zero and only applying the match limit when it is greater than zero.
src/utils/regex.cc
Outdated
unsigned long Regex::get_default_match_limit() const { | ||
unsigned long default_match_limit; | ||
#ifdef WITH_PCRE2 | ||
int ret = pcre2_config(PCRE2_CONFIG_MATCHLIMIT, &default_match_limit); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The doc says this should be a pointer to a uint32_t.
(Of course, this only needs to be addressed if we don't abandon this get_default functionality entirely).
return default_match_limit; | ||
} | ||
|
||
RegexResult Regex::to_regex_result(int pcre_exec_result) const { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cppcheck may complain about this.
It doesn't do anything with an object's state (e.g. no member variables are accessed), it could be a static function.
Don't worry about this for now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right. I suspect this is just the result of mental habit from working with other programming languages.
Pcre2MatchContextPtr() | ||
: m_match_context(pcre2_match_context_create(NULL)) {} | ||
|
||
Pcre2MatchContextPtr(const Pcre2MatchContextPtr&) = delete; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If disallowing copy constructor, disallow copy-assignment operator too?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for pointing this out. Fixed.
|
||
if (transaction && transaction->m_rules->m_pcreMatchLimit.m_set) { | ||
unsigned long match_limit = transaction->m_rules->m_pcreMatchLimit.m_value; | ||
regex_result = re->searchOneMatch(input, captures, match_limit); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we abandon the get_default functionality, I suspect there wouldn't be a compelling reason to overload this function (or the 'Global' equivalent). A single function could either have the second argument explicitly passed something like 0 or -1 (or the second arg could be declared with a default of one of those).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I went ahead and added a default value for the match_limit param in the declarations in src/utils/regex.h and left this logic the same. My thinking is that, this way, only regex.cc needs to worry about the default value.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we're getting close. A few comments ...
5a5b33d
to
826c3cc
Compare
Thanks, @martinhsv. I just pushed some changes to address your review comments. Please let me know if you'd like something adjusted. |
Hi @airween, thank you for mentioning this. The idea seems pretty reasonable as execution errors are generally undesired and unexpected, but I think it might be better to tackle this in a separate PR. |
Hi @martinhsv, is there anything left to do under this PR? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've mentioned a couple of specific things in the code.
Also:
- This does not implement the limit when a regex is in a place like the variable specification (e.g. 'SecRule ARGS:/^parm[0-9]/ "phase:2 ...'. I assume that's on purpose and I think that's reasonable. Regular expressions in that position are very unlikely to cause ReDoS worry.
- Do you want to rebase this PR so that it's mergeable?
- We should probably include an automated test for this. If you're not sure how to do this, if you have an example handy (configured limit, regex, input string), I can do so after the fact
|
||
// FIXME: DRY regex error reporting. This logic is currently duplicated in other operators. | ||
if (regex_result != Utils::RegexResult::Ok) { | ||
transaction->m_variableMscPcreError.set("1", transaction->m_variableOffset); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error variable gets set to '1' when an error is encountered, but there is no setting to '0' otherwise -- it's just not set to anything.
ModSecurity error variables should have testable value for 'no error' and this is conventionally '0'. Often this the variable is set to either 0 or 1 upon completion of the single task per transaction (such as REQBODY_ERROR). You won't be able to do that here. The best analog is URL_ENCODED_ERROR, which is initialized to 0 in the Transaction constructors.
Same thing with m_variableMscPcreLimitsExceeded
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for pointing this out. It's definitely good to explicitly set it to a sensible initial value. Done.
src/utils/regex.cc
Outdated
} | ||
|
||
bool Regex::searchGlobal(const std::string& s, std::vector<SMatchCapture>& captures) const { | ||
RegexResult Regex::searchGlobal(const std::string& s, std::vector<SMatchCapture>& captures, unsigned long match_limit) const { | ||
const char *subject = s.c_str(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This variable is declared and initialized under both compilation configurations (WITH_PCRE2 and else), but it is only actually used in the 'else' condition.
This line should be be moved to within that 'else' (as you have done in the searchOneMatch function). cppcheck will complain otherwise.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for noticing that. Fixed.
If the rx or rxGlobal operator encounters a regex error, the RX_ERROR and RX_ERROR_RULE_ID variables are set. RX_ERROR contains a simple error code which can be either OTHER or MATCH_LIMIT. RX_ERROR_RULE_ID unsurprisingly contains the ID of the rule associated with the error. More than one rule may encounter regex errors, but only the first error is reflected in these variables.
826c3cc
to
d875738
Compare
Hi @martinhsv, thank you for the review.
Honestly, it would be nice to eventually support match limits there wherever regular expressions are used in processing transactions, but the initial focus was on the rx operators.
Done.
Initially, I did not see how to add tests, but with your encouragement, I dug a bit and found the test setup is fairly intuitive. There is now a test for the rx operator, testing the MSC_PCRE_ERROR and MSC_PCRE_LIMITS_EXCEEDED flags. Unfortunately, I did not have any luck triggering the error condition with a test for the rxGlobal operator, at least with PCRE2, so I have not added a test there yet. It is a bit puzzling but may have something to do with the PCRE2 options used by Regex::searchGlobal(). |
I remember triggering such an error while testing this patch and |
Hi @martinhsv, regarding the QA check failures in the
It looks like there is a cppcheck suppression entry for rule_with_operator.cc that appears to be for a similar reason (declaration and use with ms_dbg_a). Should I add entries for these to |
Actually, I am not sure whether warnings cause the failure but haven't yet found another cause for the cppcheck failure. |
Merged. There were a couple of things cppcheck was flagging: a pre-existing exclusion whose line number needed adjusting and the couple of (false positive) unreadVariable alerts. I've handled those. I've also made a small whitespace adjustment from your final revision to the PR, but I think we're all good now. Thanks for the contribution. Note to others that this PR incidentally advanced the bison version for generated files to 3.8.2. That had been planned for this spring in any case but was taken care of here. |
Great! Thanks for all your guidance and patience, @martinhsv. I've learned a lot more about ModSecurity while working on this PR. |
This PR ...
RX_ERROR
andRX_ERROR_RULE_ID
so that regex engine failures can be handled by dedicated rules, rather than silently ignored. Today, if regex execution returns an error, therx
andrxGlobal
operators simply fail to match. This could even be considered a security concern since such a regex error will cause a rule to be quietly bypassed.NOTE: In the future, ModSecurity may support more proactive measures to identify vulnerable regular expressions before they can be used, but there is no harm in keeping multiple layers of defense. Doing so may even be wise, in the same way we design modern buildings to avoid fires yet still employ fire fighters for emergencies.
Background
A similar PR was rejected in the past, and if I understand correctly, it was rejected for the following reasons:
If that is correct, I think it is worth revisiting these reasons:
Match limits can be applied to individual PCRE
pcre_exec()
and PCRE2pcre2_match()
invocations without affecting other PCRE uses in the same memory space. Intuitively, if custom settings can be passed to matching functions, those settings should be limited to the function call. But we can also find evidence in the PCRE source code. In the legacy version of PCRE, thepcre_exec()
function has a local match_data structure that includes the selected match limit setting. The structure is declared as part of the stack frame here. It is assigned the default match limit value here and, if specified, the custom match limit value here. Since the custom setting is maintained as part of the stack frame, it should have no global effects on other PCRE users or even other subsequent PCRE calls from ModSecurity.ModSecurity can isolate itself from PCRE's default by choosing its own default match limit (even just PCRE's normal default of 10,000,000) and always specifying that limit when invoking
pcre_exec()
orpcre2_match()
. If ModSecurity did this for all PCRE limits instead of relying upon PCRE defaults, ModSecurity users could be isolated from the effects of modified PCRE defaults.Eliminating the possibility of ReDoS prior to invoking PCRE or another regex engine is a noble goal, but such a feature does not exist in ModSecurity today. In the meantime, ModSecurity users have real ReDoS concerns that can be addressed through regular expression engine configuration, and such configuration could co-exist peacefully with more proactive anti-ReDoS measures when they are developed.
Implementation
This PR re-enables the
SecPcreMatchLimit
config directive, saves the match limit as a transaction property, and applies the limit in therx
andrxGlobal
operators. This works with both PCRE and the experimental PCRE2 support.This PR also adds
RX_ERROR
andRX_ERROR_RULE_ID
variables which are set in the event of a regular expression error.RX_ERROR
exposes the reason for error and can be set to either "MATCH_LIMIT" or "OTHER".RX_ERROR_RULE_ID
exposes the rule ID where the regex error happened so error-handling rules can be written to respond differently based on a specific rule ID or range of rule IDs.Here is a naive example of rules written against the RX error variables:
Review
Arguably, regex engine configuration and regex error handling could be split into two PRs, but I'm starting with a single PR for the sake of discussion.
What do you think? Is this a reasonable change? Would you be willing to reconsider allowing users to set ModSecurity-specific PCRE match limits?