cfsolve 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +10 -9
- data/UPSTREAM.md +6 -6
- data/lib/cfsolve/profiles/safari-26-ios/answers.json +2562 -1887
- data/lib/cfsolve/profiles/safari-26-ios/challenge-page-answers.json +1873 -1198
- data/lib/cfsolve/profiles/safari-26-ios.json +0 -8
- data/lib/cfsolve/version.rb +2 -2
- data/vendor/cfsolve/README.md +0 -35
- data/vendor/cfsolve/examples/build_profile.rs +17 -15
- data/vendor/cfsolve/examples/capture_session.rs +37 -0
- data/vendor/cfsolve/examples/check_answers.rs +66 -0
- data/vendor/cfsolve/examples/compare_session.rs +190 -0
- data/vendor/cfsolve/examples/stage2_snapshot.rs +4 -1
- data/vendor/cfsolve/profiles/safari-26-ios/answers.json +2562 -1887
- data/vendor/cfsolve/profiles/safari-26-ios/challenge-page-answers.json +1873 -1198
- data/vendor/cfsolve/profiles/safari-26-ios.json +0 -8
- data/vendor/cfsolve/scripts/capture_cssom.mjs +42 -0
- data/vendor/cfsolve/scripts/serve_cssom.py +111 -0
- data/vendor/cfsolve/src/capture/profile.rs +7 -18
- data/vendor/cfsolve/src/capture/recorded.rs +42 -7
- data/vendor/cfsolve/src/challenge/installed/audit.rs +131 -0
- data/vendor/cfsolve/src/challenge/installed.rs +122 -271
- data/vendor/cfsolve/src/challenge/payload.rs +107 -5
- data/vendor/cfsolve/src/challenge/state.rs +98 -16
- data/vendor/cfsolve/src/deobfuscate/encoded_strings.rs +3 -2
- data/vendor/cfsolve/src/deobfuscate/helper_objects.rs +17 -32
- data/vendor/cfsolve/src/deobfuscate/register_arrays.rs +18 -5
- data/vendor/cfsolve/src/embedding_page/extra_params.rs +0 -14
- data/vendor/cfsolve/src/embedding_page/page.rs +28 -2
- data/vendor/cfsolve/src/http/client.rs +11 -0
- data/vendor/cfsolve/src/js/ast.rs +12 -3
- data/vendor/cfsolve/src/js/objects.rs +9 -2
- data/vendor/cfsolve/src/listing/fold.rs +0 -83
- data/vendor/cfsolve/src/listing/query.rs +25 -244
- data/vendor/cfsolve/src/listing.rs +0 -1
- data/vendor/cfsolve/src/session/initial.rs +14 -5
- data/vendor/cfsolve/src/session/probes.rs +160 -125
- data/vendor/cfsolve/src/session/timing.rs +58 -53
- data/vendor/cfsolve/src/session/widget.rs +5 -6
- data/vendor/cfsolve/src/stage2/analysis/analyze.rs +50 -33
- data/vendor/cfsolve/src/stage2/analysis/discovery.rs +61 -66
- data/vendor/cfsolve/src/stage2/analysis/envelope.rs +31 -21
- data/vendor/cfsolve/src/stage2/analysis/recognize.rs +3 -5
- data/vendor/cfsolve/src/stage2/analysis.rs +0 -1
- data/vendor/cfsolve/src/stage2/audit_globals.rs +310 -0
- data/vendor/cfsolve/src/stage2/benchmark.rs +5 -8
- data/vendor/cfsolve/src/stage2/call_stack.rs +72 -16
- data/vendor/cfsolve/src/stage2/interaction/native.rs +2 -1
- data/vendor/cfsolve/src/stage2/interaction/plan.rs +0 -3
- data/vendor/cfsolve/src/stage2/payload/recover.rs +54 -23
- data/vendor/cfsolve/src/stage2/probes/ice.rs +11 -10
- data/vendor/cfsolve/src/stage2/probes/media.rs +47 -33
- data/vendor/cfsolve/src/stage2/probes/performance.rs +1 -0
- data/vendor/cfsolve/src/stage2/probes/rects.rs +12 -17
- data/vendor/cfsolve/src/stage2/probes/stack.rs +50 -103
- data/vendor/cfsolve/src/stage2/probes/websocket_echo.rs +17 -86
- data/vendor/cfsolve/src/stage2/probes.rs +0 -1
- data/vendor/cfsolve/src/stage2/records.rs +4 -13
- data/vendor/cfsolve/src/stage2/reference.json +1 -1
- data/vendor/cfsolve/src/stage2/reference.rs +98 -100
- data/vendor/cfsolve/src/stage2/registry.rs +41 -34
- data/vendor/cfsolve/src/stage2/replay.rs +182 -254
- data/vendor/cfsolve/src/stage2/stylesheets.rs +176 -0
- data/vendor/cfsolve/src/stage2/trace.rs +18 -0
- data/vendor/cfsolve/src/stage2/vocabulary.rs +111 -401
- data/vendor/cfsolve/src/stage2.rs +2 -1
- data/vendor/cfsolve/src/vm/disassemble.rs +39 -10
- data/vendor/cfsolve/src/vm/interpret/access.rs +5 -21
- data/vendor/cfsolve/src/vm/interpret/builtin.rs +6 -53
- data/vendor/cfsolve/src/vm/interpret/call.rs +86 -9
- data/vendor/cfsolve/src/vm/interpret/compile.rs +626 -0
- data/vendor/cfsolve/src/vm/interpret/copies.rs +232 -120
- data/vendor/cfsolve/src/vm/interpret/evaluate/tests.rs +96 -8
- data/vendor/cfsolve/src/vm/interpret/evaluate.rs +219 -92
- data/vendor/cfsolve/src/vm/interpret/explore.rs +155 -39
- data/vendor/cfsolve/src/vm/interpret/frozen.rs +171 -0
- data/vendor/cfsolve/src/vm/interpret/intrinsic.rs +350 -0
- data/vendor/cfsolve/src/vm/interpret/plan.rs +71 -44
- data/vendor/cfsolve/src/vm/interpret/run.rs +202 -13
- data/vendor/cfsolve/src/vm/interpret.rs +7 -2
- data/vendor/cfsolve/src/vm/machine.rs +6 -2
- data/vendor/cfsolve/src/vm/packed_names.rs +307 -0
- data/vendor/cfsolve/src/vm/session.rs +36 -14
- data/vendor/cfsolve/src/vm/value.rs +13 -0
- data/vendor/cfsolve/src/vm.rs +1 -0
- data/vendor/cfsolve/vendor/btls-sys/src/lib.rs +3 -0
- metadata +13 -6
- data/vendor/cfsolve/src/listing/normalize.rs +0 -264
- data/vendor/cfsolve/src/stage2/analysis/outputs.rs +0 -327
- data/vendor/cfsolve/src/stage2/probes/stylesheets.rs +0 -249
- data/vendor/cfsolve/src/stage2/reference-expanded.json +0 -42
- data/vendor/cfsolve/src/stage2/session_names.rs +0 -470
|
@@ -5,17 +5,21 @@
|
|
|
5
5
|
//! globals include a build marker, a selector log, and functions built from constants, such as
|
|
6
6
|
//! the key transform's `window[name] = factory(secret)`.
|
|
7
7
|
|
|
8
|
+
use std::cell::RefCell;
|
|
8
9
|
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
|
10
|
+
use std::rc::Rc;
|
|
9
11
|
|
|
10
12
|
use anyhow::Result;
|
|
11
|
-
use
|
|
13
|
+
use swc_core::ecma::ast::BinaryOp;
|
|
12
14
|
|
|
13
15
|
use crate::js::{JsObject, JsValue, Options, Window, json};
|
|
14
|
-
use crate::listing::fold::{Frame, Known
|
|
16
|
+
use crate::listing::fold::{Frame, Known};
|
|
15
17
|
use crate::listing::query;
|
|
16
18
|
use crate::script::Source;
|
|
17
19
|
use crate::vm::{Effect, Exit, Listing, Term};
|
|
18
20
|
|
|
21
|
+
mod audit;
|
|
22
|
+
|
|
19
23
|
/// Values tracked beyond literals while recovering installed options.
|
|
20
24
|
#[derive(Clone)]
|
|
21
25
|
enum Held {
|
|
@@ -24,7 +28,13 @@ enum Held {
|
|
|
24
28
|
Window,
|
|
25
29
|
/// The result of a call with one string-constant argument.
|
|
26
30
|
Built(String),
|
|
27
|
-
|
|
31
|
+
/// An array the audit fills by index, holding native function sources where known.
|
|
32
|
+
AuditArray(Rc<RefCell<BTreeMap<usize, Option<String>>>>),
|
|
33
|
+
NativeFunction(String),
|
|
34
|
+
/// The result of a `now()` call.
|
|
35
|
+
Clock,
|
|
36
|
+
ClockDelta,
|
|
37
|
+
GpuPromise,
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
/// Options and globals left by the initial program.
|
|
@@ -37,21 +47,16 @@ pub struct Installed {
|
|
|
37
47
|
pub window: Window,
|
|
38
48
|
/// Window globals created by calls with one string constant, mapped to that constant.
|
|
39
49
|
pub built: BTreeMap<String, String>,
|
|
40
|
-
///
|
|
41
|
-
pub
|
|
42
|
-
///
|
|
43
|
-
pub
|
|
50
|
+
/// Initial global values as classified by the environment audit.
|
|
51
|
+
pub audit: BTreeMap<String, String>,
|
|
52
|
+
/// Semantic roles for globals whose values alone do not identify their definitions.
|
|
53
|
+
pub audit_roles: BTreeMap<String, String>,
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
/// Recovers values installed by the initial `program`, decoded from its program runner call.
|
|
47
57
|
pub fn read(source: &Source, program: &[u8]) -> Result<Installed> {
|
|
48
|
-
let
|
|
49
|
-
|
|
50
|
-
Ok(read_listing(&listing))
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
fn read_listing(listing: &Listing) -> Installed {
|
|
54
|
-
let initialization = initialization(listing);
|
|
58
|
+
let listing = source.disassemble(program)?;
|
|
59
|
+
let initialization = initialization(&listing);
|
|
55
60
|
let mut frame: Frame<Held> = Frame::default();
|
|
56
61
|
let mut objects: HashMap<u32, JsObject> = HashMap::new();
|
|
57
62
|
let mut installed = Installed::default();
|
|
@@ -65,31 +70,27 @@ fn read_listing(listing: &Listing) -> Installed {
|
|
|
65
70
|
let Effect::SetProperty { object, key, value } = effect else {
|
|
66
71
|
return;
|
|
67
72
|
};
|
|
68
|
-
let (Some(object), Some(Known::Literal(JsValue::String(key)))) =
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
let (Some(object), Some(Known::Literal(JsValue::String(key))), Some(value)) = (
|
|
74
|
+
frame.resolve(object),
|
|
75
|
+
frame.resolve(key),
|
|
76
|
+
frame.resolve(value),
|
|
77
|
+
) else {
|
|
71
78
|
return;
|
|
72
79
|
};
|
|
73
|
-
let value = evaluate(frame, value);
|
|
74
80
|
if matches!(object, Known::Ext(Held::Window)) {
|
|
75
|
-
let
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
_ => "?".to_owned(),
|
|
81
|
+
let audit = match &value {
|
|
82
|
+
Known::Literal(JsValue::String(text)) => Some(text.clone()),
|
|
83
|
+
Known::Literal(JsValue::Number(number)) => Some(number.to_string()),
|
|
84
|
+
Known::Literal(JsValue::Bool(true)) => Some("T".into()),
|
|
85
|
+
Known::Literal(JsValue::Bool(false)) => Some("F".into()),
|
|
86
|
+
Known::Object(_) => Some("o".into()),
|
|
87
|
+
Known::Ext(Held::Built(_)) => Some("N".into()),
|
|
88
|
+
_ => None,
|
|
85
89
|
};
|
|
86
|
-
if
|
|
87
|
-
installed.audit.insert(key.clone(),
|
|
90
|
+
if let Some(audit) = audit {
|
|
91
|
+
installed.audit.insert(key.clone(), audit);
|
|
88
92
|
}
|
|
89
93
|
}
|
|
90
|
-
let Some(value) = value else {
|
|
91
|
-
return;
|
|
92
|
-
};
|
|
93
94
|
match (object, value) {
|
|
94
95
|
// The encoder skips function members, such as `toJSON`.
|
|
95
96
|
(Known::Object(id), Known::Literal(value)) => {
|
|
@@ -116,77 +117,79 @@ fn read_listing(listing: &Listing) -> Installed {
|
|
|
116
117
|
});
|
|
117
118
|
}
|
|
118
119
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
let Some((at, Term::Get(_, key))) = index.dereference(store.pc, &Term::Reg(store.object))
|
|
122
|
-
else {
|
|
123
|
-
continue;
|
|
124
|
-
};
|
|
125
|
-
if index.text(at, &key).as_deref() != Some("window") {
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
let Some((_, value)) = index.dereference(store.pc, &store.value) else {
|
|
129
|
-
continue;
|
|
130
|
-
};
|
|
131
|
-
let kind = match value {
|
|
132
|
-
Term::Closure { .. } => "N",
|
|
133
|
-
Term::Object(_) => "o",
|
|
134
|
-
Term::Array(_) => "",
|
|
135
|
-
Term::Binary(_, _, _) | Term::Unary(_, _) => "number",
|
|
136
|
-
Term::Call { .. } => "call",
|
|
137
|
-
Term::Get(_, _) => "get",
|
|
138
|
-
_ => "?",
|
|
139
|
-
};
|
|
140
|
-
if installed
|
|
141
|
-
.audit
|
|
142
|
-
.get(&store.key)
|
|
143
|
-
.is_none_or(|value| value == "?")
|
|
144
|
-
{
|
|
145
|
-
installed.audit.insert(store.key.clone(), kind.into());
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
for (pc, effect) in index.effects_in(..) {
|
|
149
|
-
effect.visit_terms(&mut |term| {
|
|
150
|
-
if let Term::Call {
|
|
151
|
-
callee, arguments, ..
|
|
152
|
-
} = term
|
|
153
|
-
&& let [argument] = arguments.as_slice()
|
|
154
|
-
&& let Some(value) = index.text(pc, argument)
|
|
155
|
-
&& installed.built.values().any(|secret| secret == &value)
|
|
156
|
-
&& let Some(factory) = index.member(pc, callee)
|
|
157
|
-
&& matches!(factory.object, Term::Reg(global) if Some(global) == index.global())
|
|
158
|
-
{
|
|
159
|
-
installed.factories.insert(factory.key);
|
|
160
|
-
}
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
installed
|
|
120
|
+
audit::read(&listing, &mut installed);
|
|
121
|
+
Ok(installed)
|
|
164
122
|
}
|
|
165
123
|
|
|
166
124
|
/// The value an assignment writes. Recognizes calls with one string constant, such as the key
|
|
167
|
-
/// transform `factory(secret)
|
|
125
|
+
/// transform `factory(secret)`, and folds the arithmetic and string methods that unpack
|
|
126
|
+
/// length-prefixed names.
|
|
168
127
|
fn evaluate(frame: &Frame<Held>, value: &Term) -> Option<Known<Held>> {
|
|
169
|
-
if let Some(value) = constant(frame, value) {
|
|
170
|
-
return Some(Known::Literal(value));
|
|
171
|
-
}
|
|
172
128
|
Some(match value {
|
|
173
|
-
Term::Closure { .. } => Known::Ext(Held::Function),
|
|
174
129
|
Term::Array(elements) if elements.is_empty() => Known::Ext(Held::EmptyArray),
|
|
175
130
|
Term::Get(_, key) => match frame.resolve(key)?.text()? {
|
|
176
131
|
"_cf_chl_opt" => Known::Ext(Held::Options),
|
|
177
132
|
"window" => Known::Ext(Held::Window),
|
|
178
133
|
_ => return None,
|
|
179
134
|
},
|
|
180
|
-
Term::Call {
|
|
135
|
+
Term::Call {
|
|
136
|
+
callee,
|
|
137
|
+
this,
|
|
138
|
+
arguments,
|
|
139
|
+
} => {
|
|
140
|
+
if this.is_none()
|
|
141
|
+
&& let Some(folded) = string_method(frame, callee, arguments)
|
|
142
|
+
{
|
|
143
|
+
return Some(folded);
|
|
144
|
+
}
|
|
181
145
|
let [argument] = arguments.as_slice() else {
|
|
182
146
|
return None;
|
|
183
147
|
};
|
|
184
148
|
Known::Ext(Held::Built(frame.resolve(argument)?.text()?.to_owned()))
|
|
185
149
|
}
|
|
150
|
+
Term::Binary(BinaryOp::Add, left, right) => {
|
|
151
|
+
Known::Literal(json!(number(frame, left)? + number(frame, right)?))
|
|
152
|
+
}
|
|
153
|
+
Term::Binary(BinaryOp::Mul, left, right) => {
|
|
154
|
+
Known::Literal(json!(number(frame, left)? * number(frame, right)?))
|
|
155
|
+
}
|
|
186
156
|
value => frame.resolve(value)?,
|
|
187
157
|
})
|
|
188
158
|
}
|
|
189
159
|
|
|
160
|
+
/// Folds `charCodeAt` and `slice` on a known string. Some programs store names as
|
|
161
|
+
/// length-prefixed constants padded with NULs and unpack them with
|
|
162
|
+
/// `text.slice(2, 2 + 256 * text.charCodeAt(0) + text.charCodeAt(1))`.
|
|
163
|
+
fn string_method(frame: &Frame<Held>, callee: &Term, arguments: &[Term]) -> Option<Known<Held>> {
|
|
164
|
+
let Term::Get(receiver, method) = callee else {
|
|
165
|
+
return None;
|
|
166
|
+
};
|
|
167
|
+
let text: Vec<u16> = frame.resolve(receiver)?.text()?.encode_utf16().collect();
|
|
168
|
+
let index = |argument: &Term| {
|
|
169
|
+
let index = number(frame, argument)?;
|
|
170
|
+
// Negative indexes count from the end; the unpacking never uses them.
|
|
171
|
+
(index >= 0.0 && index.fract() == 0.0).then_some(index as usize)
|
|
172
|
+
};
|
|
173
|
+
Some(match (frame.resolve(method)?.text()?, arguments) {
|
|
174
|
+
("charCodeAt", [position]) => {
|
|
175
|
+
Known::Literal(json!(f64::from(*text.get(index(position)?)?)))
|
|
176
|
+
}
|
|
177
|
+
("slice", [start, end]) => {
|
|
178
|
+
let end = index(end)?.min(text.len());
|
|
179
|
+
let start = index(start)?.min(end);
|
|
180
|
+
Known::Literal(json!(String::from_utf16(&text[start..end]).ok()?))
|
|
181
|
+
}
|
|
182
|
+
_ => return None,
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
fn number(frame: &Frame<Held>, term: &Term) -> Option<f64> {
|
|
187
|
+
match frame.resolve(term)? {
|
|
188
|
+
Known::Literal(value) => value.as_f64(),
|
|
189
|
+
_ => None,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
190
193
|
/// Finds startup instructions reachable from the entry, the returned function called by the
|
|
191
194
|
/// iframe, and their callees. Excludes exception handlers and callbacks that are installed
|
|
192
195
|
/// without being called.
|
|
@@ -261,198 +264,46 @@ fn return_register(listing: &Listing) -> Option<u8> {
|
|
|
261
264
|
#[cfg(test)]
|
|
262
265
|
mod tests {
|
|
263
266
|
use super::*;
|
|
264
|
-
use crate::vm::
|
|
265
|
-
use swc_core::ecma::ast::BinaryOp;
|
|
266
|
-
|
|
267
|
-
fn text(value: &str) -> Term {
|
|
268
|
-
Term::Const(Constant::String(value.into()))
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
fn number(value: f64) -> Term {
|
|
272
|
-
Term::Const(Constant::Number(value))
|
|
273
|
-
}
|
|
267
|
+
use crate::vm::Constant;
|
|
274
268
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
269
|
+
#[test]
|
|
270
|
+
fn unpacks_length_prefixed_names() {
|
|
271
|
+
let mut frame: Frame<Held> = Frame::default();
|
|
272
|
+
frame.set(1, Some(Known::Literal(json!("\0\u{3}abc\0\0\0"))));
|
|
273
|
+
let register = |register| Box::new(Term::Reg(register));
|
|
274
|
+
let number = |value| Term::Const(Constant::Number(value));
|
|
275
|
+
let method = |name: &str, arguments| Term::Call {
|
|
276
|
+
callee: Box::new(Term::Get(
|
|
277
|
+
register(1),
|
|
278
|
+
Box::new(Term::Const(Constant::String(name.into()))),
|
|
279
|
+
)),
|
|
278
280
|
this: None,
|
|
279
281
|
arguments,
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
2,
|
|
292
|
-
method(Term::Reg(99), "factory", vec![text("synthetic-secret")]),
|
|
293
|
-
),
|
|
294
|
-
Effect::SetProperty {
|
|
295
|
-
object: Box::new(Term::Reg(1)),
|
|
296
|
-
key: Box::new(text("transform")),
|
|
297
|
-
value: Box::new(Term::Reg(2)),
|
|
298
|
-
},
|
|
299
|
-
Effect::Set(
|
|
300
|
-
3,
|
|
301
|
-
method(Term::Reg(99), "unrelated", vec![text("different-value")]),
|
|
302
|
-
),
|
|
303
|
-
];
|
|
304
|
-
let mut listing = Listing::default();
|
|
305
|
-
listing.functions.push(Position::new(0, 0));
|
|
306
|
-
let count = effects.len() as u32;
|
|
307
|
-
for (pc, effect) in (0..).zip(effects) {
|
|
308
|
-
listing.instructions.insert(
|
|
309
|
-
pc,
|
|
310
|
-
Instruction {
|
|
311
|
-
pc,
|
|
312
|
-
key: 0,
|
|
313
|
-
opcode: 0,
|
|
314
|
-
paths: vec![Path {
|
|
315
|
-
conditions: vec![],
|
|
316
|
-
effects: vec![effect],
|
|
317
|
-
exit: if pc + 1 == count {
|
|
318
|
-
Exit::Return
|
|
319
|
-
} else {
|
|
320
|
-
Exit::Next { pc: pc + 1, key: 0 }
|
|
321
|
-
},
|
|
322
|
-
}],
|
|
323
|
-
},
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
let installed = read_listing(&listing);
|
|
327
|
-
assert_eq!(installed.factories, BTreeSet::from(["factory".into()]));
|
|
328
|
-
assert_eq!(
|
|
329
|
-
installed.built,
|
|
330
|
-
BTreeMap::from([("transform".into(), "synthetic-secret".into())])
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
#[test]
|
|
335
|
-
fn recovers_a_key_transform_with_an_inline_decoded_global_name() {
|
|
336
|
-
let effects = vec![
|
|
337
|
-
Effect::Set(
|
|
338
|
-
1,
|
|
339
|
-
Term::Get(
|
|
340
|
-
Box::new(Term::Global("self".into())),
|
|
341
|
-
Box::new(text("window")),
|
|
342
|
-
),
|
|
343
|
-
),
|
|
344
|
-
Effect::Set(2, text("\0\u{9}fresh_key\0padding")),
|
|
345
|
-
Effect::Set(3, method(Term::Reg(2), "charCodeAt", vec![number(0.0)])),
|
|
346
|
-
Effect::Set(4, method(Term::Reg(2), "charCodeAt", vec![number(1.0)])),
|
|
347
|
-
Effect::Set(
|
|
348
|
-
3,
|
|
349
|
-
Term::Binary(
|
|
350
|
-
BinaryOp::Mul,
|
|
351
|
-
Box::new(Term::Reg(3)),
|
|
352
|
-
Box::new(number(256.0)),
|
|
353
|
-
),
|
|
354
|
-
),
|
|
355
|
-
Effect::Set(
|
|
356
|
-
3,
|
|
357
|
-
Term::Binary(
|
|
358
|
-
BinaryOp::Add,
|
|
359
|
-
Box::new(Term::Reg(3)),
|
|
360
|
-
Box::new(Term::Reg(4)),
|
|
361
|
-
),
|
|
362
|
-
),
|
|
363
|
-
Effect::Set(
|
|
364
|
-
3,
|
|
365
|
-
Term::Binary(BinaryOp::Add, Box::new(Term::Reg(3)), Box::new(number(2.0))),
|
|
366
|
-
),
|
|
367
|
-
Effect::Set(
|
|
368
|
-
2,
|
|
369
|
-
method(Term::Reg(2), "slice", vec![number(2.0), Term::Reg(3)]),
|
|
370
|
-
),
|
|
371
|
-
Effect::Set(
|
|
372
|
-
5,
|
|
373
|
-
Term::Call {
|
|
374
|
-
callee: Box::new(Term::Reg(99)),
|
|
375
|
-
this: None,
|
|
376
|
-
arguments: vec![text("synthetic-secret")],
|
|
377
|
-
},
|
|
378
|
-
),
|
|
379
|
-
Effect::SetProperty {
|
|
380
|
-
object: Box::new(Term::Reg(1)),
|
|
381
|
-
key: Box::new(Term::Reg(2)),
|
|
382
|
-
value: Box::new(Term::Reg(5)),
|
|
383
|
-
},
|
|
384
|
-
];
|
|
385
|
-
let mut listing = Listing::default();
|
|
386
|
-
listing.functions.push(Position::new(0, 0));
|
|
387
|
-
listing.instructions.insert(
|
|
388
|
-
0,
|
|
389
|
-
Instruction {
|
|
390
|
-
pc: 0,
|
|
391
|
-
key: 0,
|
|
392
|
-
opcode: 0,
|
|
393
|
-
paths: vec![Path {
|
|
394
|
-
conditions: vec![],
|
|
395
|
-
effects,
|
|
396
|
-
exit: Exit::Return,
|
|
397
|
-
}],
|
|
398
|
-
},
|
|
282
|
+
};
|
|
283
|
+
let mut set = |target, term: Term| {
|
|
284
|
+
let value = evaluate(&frame, &term);
|
|
285
|
+
frame.set(target, value);
|
|
286
|
+
};
|
|
287
|
+
// `text.slice(2, 256 * text.charCodeAt(0) + text.charCodeAt(1) + 2)`
|
|
288
|
+
set(2, method("charCodeAt", vec![number(0.0)]));
|
|
289
|
+
set(3, method("charCodeAt", vec![number(1.0)]));
|
|
290
|
+
set(
|
|
291
|
+
4,
|
|
292
|
+
Term::Binary(BinaryOp::Mul, Box::new(number(256.0)), register(2)),
|
|
399
293
|
);
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
294
|
+
set(4, Term::Binary(BinaryOp::Add, register(4), register(3)));
|
|
295
|
+
set(
|
|
296
|
+
4,
|
|
297
|
+
Term::Binary(BinaryOp::Add, register(4), Box::new(number(2.0))),
|
|
403
298
|
);
|
|
404
|
-
|
|
299
|
+
set(5, method("slice", vec![number(2.0), Term::Reg(4)]));
|
|
300
|
+
assert_eq!(frame.register(5).and_then(Known::text), Some("abc"));
|
|
405
301
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
&method(text("a😀z"), "charCodeAt", vec![number(1.0)])
|
|
413
|
-
),
|
|
414
|
-
Some(JsValue::Number(0xd83d as f64))
|
|
415
|
-
);
|
|
416
|
-
assert_eq!(
|
|
417
|
-
constant(
|
|
418
|
-
&frame,
|
|
419
|
-
&method(text("a😀z"), "slice", vec![number(1.0), number(3.0)])
|
|
420
|
-
),
|
|
421
|
-
Some(json!("😀"))
|
|
422
|
-
);
|
|
423
|
-
assert_eq!(
|
|
424
|
-
constant(
|
|
425
|
-
&frame,
|
|
426
|
-
&method(text("abcdef"), "slice", vec![number(-3.0), number(99.0)])
|
|
427
|
-
),
|
|
428
|
-
Some(json!("def"))
|
|
429
|
-
);
|
|
430
|
-
assert_eq!(
|
|
431
|
-
constant(
|
|
432
|
-
&frame,
|
|
433
|
-
&method(text("abc"), "slice", vec![number(2.0), number(1.0)])
|
|
434
|
-
),
|
|
435
|
-
Some(json!(""))
|
|
436
|
-
);
|
|
437
|
-
assert_eq!(
|
|
438
|
-
constant(
|
|
439
|
-
&frame,
|
|
440
|
-
&method(text("a😀z"), "slice", vec![number(1.0), number(2.0)])
|
|
441
|
-
),
|
|
442
|
-
None
|
|
443
|
-
);
|
|
444
|
-
let outside = constant(
|
|
445
|
-
&frame,
|
|
446
|
-
&method(text("abc"), "charCodeAt", vec![number(-1.0)]),
|
|
447
|
-
)
|
|
448
|
-
.unwrap();
|
|
449
|
-
assert!(outside.as_f64().unwrap().is_nan());
|
|
450
|
-
assert!(
|
|
451
|
-
constant(
|
|
452
|
-
&frame,
|
|
453
|
-
&method(Term::Reg(99), "slice", vec![number(0.0), number(2.0)])
|
|
454
|
-
)
|
|
455
|
-
.is_none()
|
|
456
|
-
);
|
|
302
|
+
// A call with one string argument still builds a global.
|
|
303
|
+
let built = evaluate(&frame, &method("factory", vec![Term::Reg(5)]));
|
|
304
|
+
assert!(matches!(built, Some(Known::Ext(Held::Built(secret))) if secret == "abc"));
|
|
305
|
+
// Out-of-range and negative indexes stay unknown.
|
|
306
|
+
assert!(evaluate(&frame, &method("charCodeAt", vec![number(9.0)])).is_none());
|
|
307
|
+
assert!(evaluate(&frame, &method("slice", vec![number(-1.0), number(2.0)])).is_none());
|
|
457
308
|
}
|
|
458
309
|
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
//! Builds the plaintext first challenge payload by evaluating the template's field expressions
|
|
2
2
|
//! against session options and the script's namespace.
|
|
3
3
|
|
|
4
|
+
use std::sync::LazyLock;
|
|
5
|
+
|
|
4
6
|
use anyhow::{Context, Result, ensure};
|
|
7
|
+
use regex::Regex;
|
|
5
8
|
use swc_core::ecma::ast::{BinaryOp, Expr};
|
|
6
9
|
use swc_core::ecma::codegen;
|
|
7
10
|
|
|
8
11
|
use crate::challenge::expression;
|
|
9
|
-
use crate::challenge::state::{Difference, ScriptState};
|
|
12
|
+
use crate::challenge::state::{Difference, Getter, ScriptState};
|
|
10
13
|
use crate::js::{JsValue, Options, Payload, Window, ast, json};
|
|
11
14
|
use crate::random_id;
|
|
12
15
|
|
|
@@ -41,8 +44,7 @@ pub(crate) fn infer_globals(
|
|
|
41
44
|
(None, Shape::Called) => script
|
|
42
45
|
.getters
|
|
43
46
|
.get(name)
|
|
44
|
-
.and_then(|
|
|
45
|
-
.cloned()
|
|
47
|
+
.and_then(|getter| called(getter, options))
|
|
46
48
|
.unwrap_or_else(|| json!([])),
|
|
47
49
|
// No interaction has been recorded, so the collection is empty.
|
|
48
50
|
(None, Shape::Stringified) => json!([]),
|
|
@@ -66,6 +68,66 @@ pub(crate) fn infer_globals(
|
|
|
66
68
|
globals
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
/// The value `getter` returns: its option, with each render sample's stack split at page URLs
|
|
72
|
+
/// if the getter does so.
|
|
73
|
+
fn called(getter: &Getter, options: &Options) -> Option<JsValue> {
|
|
74
|
+
let mut value = options.get(&getter.option)?.clone();
|
|
75
|
+
if let Some(origin) = &getter.page_origin
|
|
76
|
+
&& let JsValue::Array(samples) = &mut value
|
|
77
|
+
{
|
|
78
|
+
let origin = options
|
|
79
|
+
.get(origin)
|
|
80
|
+
.and_then(JsValue::as_str)
|
|
81
|
+
.unwrap_or_default();
|
|
82
|
+
for sample in samples {
|
|
83
|
+
if let JsValue::Object(sample) = sample
|
|
84
|
+
&& let Some(stack) = sample.get("s").and_then(JsValue::as_str)
|
|
85
|
+
{
|
|
86
|
+
let split = split_page_urls(stack, origin);
|
|
87
|
+
sample.insert("s".to_owned(), split);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
Some(value)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// Splits `stack` as the iframe's sample getter does. Each URL on `origin`, less a trailing
|
|
95
|
+
/// `:line` or `:line:column`, becomes the index of that URL in a list of at most 64. Returns
|
|
96
|
+
/// `[parts, urls]`, where `parts` alternates text and indices and ends with text.
|
|
97
|
+
fn split_page_urls(stack: &str, origin: &str) -> JsValue {
|
|
98
|
+
const MAX_URLS: usize = 64;
|
|
99
|
+
static URL: LazyLock<Regex> =
|
|
100
|
+
LazyLock::new(|| Regex::new(r#"https?://[^\s)"'<>]+"#).expect("the URL pattern is valid"));
|
|
101
|
+
static POSITION: LazyLock<Regex> = LazyLock::new(|| {
|
|
102
|
+
Regex::new(r":[0-9]+(?::[0-9]+)?$").expect("the position pattern is valid")
|
|
103
|
+
});
|
|
104
|
+
let mut parts = Vec::new();
|
|
105
|
+
let mut urls: Vec<&str> = Vec::new();
|
|
106
|
+
let mut copied = 0;
|
|
107
|
+
for found in URL.find_iter(stack) {
|
|
108
|
+
let url = found.as_str();
|
|
109
|
+
let url = POSITION
|
|
110
|
+
.find(url)
|
|
111
|
+
.map_or(url, |position| &url[..position.start()]);
|
|
112
|
+
if url != origin && !url.starts_with(&format!("{origin}/")) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
let index = match urls.iter().position(|known| *known == url) {
|
|
116
|
+
Some(index) => index,
|
|
117
|
+
None if urls.len() >= MAX_URLS => continue,
|
|
118
|
+
None => {
|
|
119
|
+
urls.push(url);
|
|
120
|
+
urls.len() - 1
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
parts.push(json!(&stack[copied..found.start()]));
|
|
124
|
+
parts.push(json!(index));
|
|
125
|
+
copied = found.start() + url.len();
|
|
126
|
+
}
|
|
127
|
+
parts.push(json!(&stack[copied..]));
|
|
128
|
+
json!([parts, urls])
|
|
129
|
+
}
|
|
130
|
+
|
|
69
131
|
#[derive(Debug, Clone)]
|
|
70
132
|
pub struct Field {
|
|
71
133
|
pub name: String,
|
|
@@ -89,7 +151,10 @@ impl Field {
|
|
|
89
151
|
#[must_use]
|
|
90
152
|
pub(crate) fn option<'a>(&'a self, script: &'a ScriptState) -> Option<&'a str> {
|
|
91
153
|
expression::option_key(&self.expression).or_else(|| match self.global()? {
|
|
92
|
-
(name, Shape::Called) => script
|
|
154
|
+
(name, Shape::Called) => script
|
|
155
|
+
.getters
|
|
156
|
+
.get(name)
|
|
157
|
+
.map(|getter| getter.option.as_str()),
|
|
93
158
|
_ => None,
|
|
94
159
|
})
|
|
95
160
|
}
|
|
@@ -199,6 +264,37 @@ mod tests {
|
|
|
199
264
|
use crate::challenge::state::Elapsed;
|
|
200
265
|
use crate::challenge::tally::Tally;
|
|
201
266
|
|
|
267
|
+
/// Page URLs become indices into a list of distinct URLs; other hosts stay in the text.
|
|
268
|
+
#[test]
|
|
269
|
+
fn splits_stacks_at_page_urls() {
|
|
270
|
+
let stack = "Error\n at f (https://captchatest.book.bi/app.js:1:20)\n at g \
|
|
271
|
+
(https://challenges.cloudflare.com/turnstile/v0/api.js:2:7)\n at h \
|
|
272
|
+
(https://captchatest.book.bi/app.js:3:4)";
|
|
273
|
+
assert_eq!(
|
|
274
|
+
split_page_urls(stack, "https://captchatest.book.bi"),
|
|
275
|
+
json!([
|
|
276
|
+
[
|
|
277
|
+
"Error\n at f (",
|
|
278
|
+
0,
|
|
279
|
+
":1:20)\n at g (https://challenges.cloudflare.com/turnstile/v0/api.js:2:7)\n at h (",
|
|
280
|
+
0,
|
|
281
|
+
":3:4)"
|
|
282
|
+
],
|
|
283
|
+
["https://captchatest.book.bi/app.js"]
|
|
284
|
+
])
|
|
285
|
+
);
|
|
286
|
+
// As in the captured sessions, a stack without page URLs stays whole.
|
|
287
|
+
assert_eq!(
|
|
288
|
+
split_page_urls(stack, "https://example.com"),
|
|
289
|
+
json!([[stack], []])
|
|
290
|
+
);
|
|
291
|
+
assert_eq!(split_page_urls(stack, ""), json!([[stack], []]));
|
|
292
|
+
assert_eq!(
|
|
293
|
+
split_page_urls("at https://a.b:443/x.js:2:7", "https://a.b:443"),
|
|
294
|
+
json!([["at ", 0, ":2:7"], ["https://a.b:443/x.js"]])
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
202
298
|
fn field(name: &str, text: &str) -> Field {
|
|
203
299
|
Field {
|
|
204
300
|
name: name.into(),
|
|
@@ -313,7 +409,13 @@ mod tests {
|
|
|
313
409
|
global: "tally".into(),
|
|
314
410
|
counters: vec!["a".into()],
|
|
315
411
|
}),
|
|
316
|
-
getters: BTreeMap::from([(
|
|
412
|
+
getters: BTreeMap::from([(
|
|
413
|
+
"getter".into(),
|
|
414
|
+
Getter {
|
|
415
|
+
option: "opt".into(),
|
|
416
|
+
page_origin: None,
|
|
417
|
+
},
|
|
418
|
+
)]),
|
|
317
419
|
..ScriptState::default()
|
|
318
420
|
};
|
|
319
421
|
let options = Options::from_iter([("opt".to_owned(), json!(["x"]))]);
|