mruby-regexp: refuse a pattern too large for its jump targets - #7064
Merged
Conversation
Every jump target in the compiled bytecode lives in `re_inst.offset`, a
`uint16_t`, while `code_len` is a `uint32_t`, and nothing checked that the
program stayed addressable by that field. Once a pattern compiled to more
than 65535 instructions the targets wrapped on the way in, the engine jumped
to an unrelated instruction, and the pattern stopped matching text it
describes. No exception, no warning, just a wrong answer.
```ruby
r = Regexp.new("(?:abc){21844}x*y")
r.match?("abc" * 21844 + "y") # CRuby: true, mruby: false
r = Regexp.new("(?:abc){30000}(?:y|z)")
r.match?("abc" * 30000 + "z") # CRuby: true, mruby: false
```
This is reachable without pathological input. `RE_MAX_REPEAT` is 32768, so a
single accepted quantifier such as `(?:abc){32768}` already emitted 98304
instructions, and `{n,m}` expansion multiplies a group body by its count, so a
generated pattern got there on ordinary-looking input. Whether such a pattern
answered correctly depended on where its quantifiers sat: `(?:abcd){20000}` on
its own has no jump past the boundary and always worked, which is what made
this so quiet.
Raise `RegexpError` rather than widen the field. `emit()` is the one place
`code_len` grows, so a single comparison there covers every producer, including
`insert_inst()`, which grows the array through `emit()` as well. Widening
`offset` to `uint32_t` would double `re_inst` from 4 to 8 bytes for every
pattern in the program to serve patterns that essentially nobody writes, and
the struct's own comment says the 4-byte size is for alignment. CRuby refuses
oversized patterns the same way: `Regexp.new("a{100001}")` raises `RegexpError`
with `too big number for repeat range`.
The bound is on the whole program rather than on each target, so a pattern that
used to fit only because its targets happened to stay low now raises as well.
`(?:abc){21843}x*y` is one: it compiles to exactly 65536 instructions and its
highest target is 65533, so it answered correctly before. The window is one
count wide per shape, and that is the price of keeping the check to one
comparison on the single funnel.
The `offset >= 0xffff` guard in `insert_inst()` was an ad hoc brake on the same
overflow. It cannot be reached now, since `insert_inst()` calls `emit()` first
and `emit()` refuses to grow the program to where an offset of `0xffff` could
exist, so it goes with the change it was standing in for.
There is no memory-safety consequence to undo: a wrapped target was always
smaller than the real `code_len`, so dispatch stayed inside the array, and both
engines already guard the top end.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThe regexp compiler now enforces a ChangesRegexp bytecode limits
Estimated code review effort: 2 (Simple) | ~10 minutes Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every jump target in the compiled bytecode lives in
re_inst.offset, auint16_t(re_internal.h:43), whilecode_lenis auint32_t(
re_internal.h:78), and nothing checked that the program stayed addressableby that field. Once a pattern compiled to more than 65535 instructions the
targets wrapped on the way in, the engine jumped to an unrelated instruction,
and the pattern stopped matching text it describes. No exception, no warning,
just a wrong answer.
The boundary is exact. In
(?:abc){n}x*ythe split forx*is patched with3n + 4: theSAVE(0), the3ncharacter instructions, thex, the splitinserted in front of it and the loop
JMP. 21844 is the first count whosetarget reaches 65536, and that is where the answers start diverging. A pattern
with no jump past the boundary, such as
(?:abcd){20000}on its own, stillworks, which is what makes this so quiet: whether a long pattern is right or
wrong depends on where its quantifiers sit.
This is reachable without pathological input.
RE_MAX_REPEATis 32768(
re_compile.c:496), so a single accepted quantifier such as(?:abc){32768}already emits 98304 instructions, and
{n,m}expansion multiplies a group bodyby its count in
compile_quantified(), so a generated pattern gets there onordinary-looking input.
Cause
Every site that stores a target narrows silently.
emit()andpatch()takethe target as
uint16_t, so the conversion happens at the call: forwardtargets from
c->code_len, backward targets from the atom start.compile_quantified()andcompile_alt()add explicit(uint16_t)c->code_lencasts of their own, which is where the repros lose the skip for
x*and bothtargets of
(?:y|z).emit_atom_copy()rewrites a copied atom's own targetsthrough the same narrow field, so
{n,m}expansion carries the wrap into everycopy.
insert_inst()then relocates targets withoffset++on the same field.Walking the first repro at
n = 21850:(?:abc){21850}leavescode_lenat65551, the
*callsinsert_inst()to put aRE_SPLITat the atom start, andthe patch stores
65554 & 0xFFFF, that is 18. The split's skip branch lands inthe middle of the literal run, that thread dies, and the pattern can no longer
skip the
x*. The loopJMPemitted one line earlier is wrapped too, storing65551 & 0xFFFF, that is 15, so the loop's back edge is lost along with theskip.
There is no memory-safety consequence: a wrapped target is always smaller than
the real
code_len, so dispatch stays inside the array, and both engines guardthe top end anyway (
re_exec.c:140,re_exec.c:484). An ASAN and UBSan buildagrees, running the repros clean and answering
falsejust the same. Thedamage is confined to the answer.
Fix
Raise at compile time rather than widen.
emit()is the one placecode_lengrows, so a single comparison there covers every producer, including
insert_inst(), which grows the array throughemit()as well.RE_MAX_CODE_LENsits next to it, in the manner ofRE_MAX_REPEATandRE_MAX_CLASSES, the two limits this gem already reports.Widening
offsettouint32_tis the alternative. It doublesre_instfrom 4to 8 bytes for every pattern in the program to serve patterns that essentially
nobody writes, and the struct's own comment says the 4-byte size is for
alignment. The cheap check looks like the right trade for a gem that targets
small systems. CRuby refuses oversized patterns the same way, for example
Regexp.new("a{100001}")raisesRegexpErrorwithtoo big number for repeat range, so raising here is behaviour to copy rather than invent.The bound is on the whole program rather than on each target, so a pattern that
used to fit only because its targets happened to stay low now raises as well.
(?:abc){21843}x*yis one: it compiles to exactly 65536 instructions and itshighest target is 65533, so it answered correctly before. The window is one
count wide per shape, which the sweep below measures, and that is the price of
keeping the check to one comparison on the single funnel.
The
offset >= 0xffffguard ininsert_inst()was an ad hoc brake on the sameoverflow. It cannot be reached now:
insert_inst()callsemit()first, andemit()refuses to grow the program to where an offset of0xffffcouldexist, so the guard goes with the change it was standing in for.
The executors are not touched. They read whatever the compiler wrote and are
correct for any in-range target.
Tests
Regexp - pattern too large for its jump targets is refusedinmrbgems/mruby-regexp/test/regexp.rb: the largest(?:abc){n}that stillcompiles and the first one that does not, with the message asserted; the two
shapes that used to answer wrongly rather than raise; and
(?:ab){32768}, aquantifier the parser still accepts that reaches the bound on its own. The
counts sit either side of the bound, so a later change to the instruction cost
per atom cannot quietly move the boundary back into the working range.
The assertions are on compilation only. Compiling either pattern is instant,
but matching the below-bound one needs a 64 KB subject and about 3.2s on a host
build, which does not belong in the suite.
Verified on x86_64-linux
(
(?:abc){n}x*y,(?:abc){n}(?:y|z),(?:abc){n}(?:y)?z) andnfrom21835 to 21850, 48 cases, each matched against a subject the pattern
describes. CRuby answers
trueon all 48. Before this change mruby answeredfalseon 20 of them; after it, none answersfalse. 23 now raiseRegexpError: the 20 wrong answers, plus the 3 cases that were correctbefore and that the whole-program bound gives up (
x*yat 21843,(?:y|z)at 21843,
(?:y)?zat 21844).rake test: 1988 total, 1969 OK, 0 KO, 0 crash, and bintest 105 OK.MRUBY_CONFIG=build_config/asan.rb rake test(clang, ASAN and UBSan,MRB_UTF8_STRING): 2165 total, 2162 OK, 0 KO, 0 crash, no sanitizer report,and bintest 78 OK.
clang -Wall -Wextraoverre_compile.c: no new warning.Summary by CodeRabbit
RegexpError.