Skip to content

FIX: Check the default SSTIOutputScorer template-body patterns in linear time - #4296

Open
Jaafer Rahmani (bIackr0se) wants to merge 1 commit into
microsoft:mainfrom
bIackr0se:fix/ssti-gadget-linear
Open

Jaafer Rahmani (bIackr0se) wants to merge 1 commit into
microsoft:mainfrom
bIackr0se:fix/ssti-gadget-linear

Conversation

@bIackr0se

Copy link
Copy Markdown
Contributor

Description

SSTIOutputScorer() runs four default patterns that span a template body: "Python Gadget Chain", "Twig Execution Filter", "FreeMarker Gadget" and "SpEL / Java Type Expression". Each one starts at a template opener such as {{, {%, ${, *{ or <#, and reads ahead until either the exploit marker (__class__, |filter('system'), ...) or the closer of that expression. When neither a marker nor a closer follows, re.search reads to the end of the reply again from every opener, so scoring takes quadratic time on repeated openers: a 32 KB reply that repeats {{ takes 26 s on main. This PR checks those four patterns in linear time with the same verdict, as #3966 did for the default Markdown image patterns.

  • The four patterns share one match condition: some opener is followed by the marker, with no closer starting in between. For one opener, the nearest marker after it decides.
  • _opener_reaches_target walks the openers once, in order, and keeps the nearest closer and marker found for the previous opener while they are still ahead. A new search runs only when its cached match has fallen behind the current opener, so the search positions only move forward.
  • Openers are found with a lookahead, so overlapping openers such as the {% inside {{%} are still considered, as the regex considers them.
  • The pattern strings do not change. A custom pattern under one of these names still goes through re.search.

Default SSTIOutputScorer().score_text_async(reply) with initialize_pyrit_async(memory_db_type=IN_MEMORY), Python 3.12, on a warmed scoring path. The 8,000-character times were checked against three timed runs after one untimed scoring call; each remaining row was checked once after earlier timed scoring calls:

reply                                                      chars   main              this PR
"{{" * 4000                                                 8000   False, 1.58 s     False, 0.002 s
"{{" * 16000                                               32000   False, 26.0 s     False, 0.004 s
"{%" * 16000                                               32000   False, 14.5 s     False, 0.003 s
"{{ ''.__class__.__mro__[1].__subclasses__() }}"              46   True              True (same rationale)
"{{['id']|filter('system')}}"                                 27   True              True (same rationale)
"{{ user.name }} and later __class__ outside any template"    56   False             False

Each new check gives the same verdict as re.search with its default pattern on 300,194 strings (the 194 string literals of test_ssti_output_scorer.py plus 300,000 random sequences of template tokens such as {{, -, }}, %}, ${, *{, <#, [=, ], __class__, |FILTER('SYSTEM'), _self.env and T(System).getenv(), with no difference; about 10% to 16% of them match each pattern.

Tests: test_ssti_scorer_scales_on_unclosed_openers (10 s timeout; all seven cases time out on main), test_ssti_scorer_verdict_matches_default_pattern, test_ssti_scorer_custom_pattern_under_default_name_uses_regex, and test_ssti_linear_checks_were_written_for_the_default_patterns, which fails when a default pattern changes without its linear check, in tests/unit/score/regex/test_ssti_output_scorer.py.

…ear time

The Python Gadget Chain, Twig Execution Filter, FreeMarker Gadget and SpEL
default patterns scan from every template opener to the next closer, so a
reply that repeats an opener without a closer takes quadratic time (26 s for
32 KB of unclosed {{ on main). Keep the default pattern strings and, when a
default is in use, check its match condition in one pass over the openers.
Copilot AI balanced review requested due to automatic review settings November 14, 2026 22:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants