termivar_scanner/payload_strategies/
ssti_arithmetic_expression_pair.rs1use crate::payload_strategy::{
9 PayloadArtifact, PayloadSeed, PayloadStrategy, PayloadStrategyError, PayloadStrategyLimits,
10 PayloadStrategyRef, PayloadVariantRole,
11};
12
13pub const SSTI_ARITHMETIC_EXPRESSION_PAIR_ID: &str =
14 "web.review.ssti.brace-arithmetic-expression-pair";
15pub const SSTI_ARITHMETIC_EXPRESSION_PAIR_REVISION: u32 = 1;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub(crate) enum SstiProbeFamily {
20 BraceArithmeticV1,
21}
22
23impl SstiProbeFamily {
24 pub(crate) const fn stable_id(self) -> &'static str {
25 match self {
26 Self::BraceArithmeticV1 => "web.review.ssti.family.brace-arithmetic@1",
27 }
28 }
29}
30
31#[derive(Clone, PartialEq, Eq)]
33pub(crate) struct SstiArithmeticProbe {
34 family: SstiProbeFamily,
35 nonce: String,
36 left: u8,
37 right: u8,
38}
39
40impl std::fmt::Debug for SstiArithmeticProbe {
41 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 formatter
43 .debug_struct("SstiArithmeticProbe")
44 .field("family", &self.family.stable_id())
45 .field("encoded_bytes", &self.seed().len())
46 .finish()
47 }
48}
49
50impl SstiArithmeticProbe {
51 pub(crate) fn new(nonce: String, left: u8, right: u8) -> Option<Self> {
52 if !(8..=32).contains(&nonce.len())
53 || !nonce.bytes().all(|byte| byte.is_ascii_hexdigit())
54 || !(2..=12).contains(&left)
55 || !(2..=12).contains(&right)
56 {
57 return None;
58 }
59 Some(Self {
60 family: SstiProbeFamily::BraceArithmeticV1,
61 nonce,
62 left,
63 right,
64 })
65 }
66
67 pub(crate) fn seed(&self) -> String {
68 format!("v1-{}-{}-{}", self.nonce, self.left, self.right)
69 }
70
71 pub(crate) fn control_value(&self) -> String {
72 format!("venom-ssti-{}-control-end", self.nonce)
73 }
74
75 pub(crate) fn candidate_value(&self) -> String {
76 format!(
77 "venom-ssti-{}-{{{{{}*{}}}}}-end",
78 self.nonce, self.left, self.right
79 )
80 }
81
82 pub(crate) fn expected_value(&self) -> String {
83 format!(
84 "venom-ssti-{}-{}-end",
85 self.nonce,
86 u16::from(self.left) * u16::from(self.right)
87 )
88 }
89
90 fn parse(seed: &[u8]) -> Option<Self> {
91 let text = std::str::from_utf8(seed).ok()?;
92 let mut parts = text.split('-');
93 if parts.next()? != "v1" {
94 return None;
95 }
96 let nonce = parts.next()?.to_owned();
97 let left = parts.next()?.parse().ok()?;
98 let right = parts.next()?.parse().ok()?;
99 if parts.next().is_some() {
100 return None;
101 }
102 Self::new(nonce, left, right)
103 }
104}
105
106#[derive(Debug, Clone)]
107pub struct SstiArithmeticExpressionPairStrategy {
108 reference: PayloadStrategyRef,
109}
110
111impl SstiArithmeticExpressionPairStrategy {
112 pub fn new() -> Self {
113 Self {
114 reference: PayloadStrategyRef::new(
115 SSTI_ARITHMETIC_EXPRESSION_PAIR_ID,
116 SSTI_ARITHMETIC_EXPRESSION_PAIR_REVISION,
117 )
118 .expect("the SSTI arithmetic strategy identity is static and valid"),
119 }
120 }
121}
122
123impl Default for SstiArithmeticExpressionPairStrategy {
124 fn default() -> Self {
125 Self::new()
126 }
127}
128
129impl PayloadStrategy for SstiArithmeticExpressionPairStrategy {
130 fn strategy_ref(&self) -> &PayloadStrategyRef {
131 &self.reference
132 }
133
134 fn derive_one(
135 &self,
136 role: PayloadVariantRole,
137 seed: &PayloadSeed,
138 limits: PayloadStrategyLimits,
139 ) -> Result<PayloadArtifact, PayloadStrategyError> {
140 let probe = SstiArithmeticProbe::parse(seed.as_bytes())
141 .ok_or(PayloadStrategyError::DerivationFailed)?;
142 let value = match role {
143 PayloadVariantRole::Control => probe.control_value(),
144 PayloadVariantRole::Candidate => probe.candidate_value(),
145 };
146 PayloadArtifact::new(self.reference.clone(), role, value.into_bytes(), limits)
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 fn limits() -> PayloadStrategyLimits {
155 PayloadStrategyLimits::new(128, 128).unwrap()
156 }
157
158 #[test]
159 fn family_is_safe_bounded_and_exactly_correlated() {
160 let probe = SstiArithmeticProbe::new("a1b2c3d4e5f60708".into(), 3, 11).unwrap();
161 let seed = PayloadSeed::new(probe.seed().into_bytes(), limits()).unwrap();
162 let strategy = SstiArithmeticExpressionPairStrategy::new();
163 let control = strategy
164 .derive_one(PayloadVariantRole::Control, &seed, limits())
165 .unwrap();
166 let candidate = strategy
167 .derive_one(PayloadVariantRole::Candidate, &seed, limits())
168 .unwrap();
169 assert_eq!(control.as_bytes(), probe.control_value().as_bytes());
170 assert_eq!(candidate.as_bytes(), probe.candidate_value().as_bytes());
171 assert_eq!(probe.expected_value(), "venom-ssti-a1b2c3d4e5f60708-33-end");
172 for forbidden in [".", "[", "]", "(", ")", "'", "\"", ";", "/"] {
173 assert!(!probe.candidate_value().contains(forbidden));
174 }
175 }
176
177 #[test]
178 fn invalid_or_unbounded_catalog_seeds_fail_closed() {
179 let strategy = SstiArithmeticExpressionPairStrategy::new();
180 for seed in [
181 "v2-a1b2c3d4-3-4",
182 "v1-short-3-4",
183 "v1-a1b2c3d4-1-4",
184 "v1-a1b2c3d4-3-99",
185 "v1-a1b2c3d4-3-4-extra",
186 ] {
187 let seed = PayloadSeed::new(seed.as_bytes().to_vec(), limits()).unwrap();
188 assert!(matches!(
189 strategy.derive_one(PayloadVariantRole::Candidate, &seed, limits()),
190 Err(PayloadStrategyError::DerivationFailed)
191 ));
192 }
193 }
194}