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
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
//! Refreshes recorded ICE connection state while preserving its format and timing.
|
|
2
2
|
//!
|
|
3
|
-
//! Reports recorded host candidates, matching a network that blocks UDP
|
|
4
|
-
//! servers, since they would report this machine's address instead of the proxy's.
|
|
3
|
+
//! Reports recorded host candidates only, matching a network that blocks UDP to STUN servers.
|
|
5
4
|
//!
|
|
6
5
|
//! The probe reuses a connection opened earlier. Chrome's recorded startup connection shares
|
|
7
6
|
//! one mDNS name among host candidates; WebKit's uses a separate name for each. Keeps candidate
|
|
8
7
|
//! lines, timing offsets, shared names and foundations, and offer codec and attribute order.
|
|
9
8
|
//! Generates a fresh session ID, credentials, certificate fingerprint, mDNS names, foundations,
|
|
10
|
-
//! and ports.
|
|
9
|
+
//! and host ports.
|
|
11
10
|
|
|
12
11
|
use std::collections::HashMap;
|
|
13
12
|
|
|
@@ -24,8 +23,9 @@ fn tokens(line: &str) -> Vec<String> {
|
|
|
24
23
|
line.split(' ').map(str::to_owned).collect()
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
/// Whether the candidate `line` has type `host` rather than, say, `srflx` (server-reflexive).
|
|
27
|
+
pub(crate) fn is_host(line: &str) -> bool {
|
|
28
|
+
line.split(' ').nth(7) == Some("host")
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/// Session identity shared by candidates and SDP.
|
|
@@ -82,6 +82,7 @@ impl Identity {
|
|
|
82
82
|
|
|
83
83
|
/// Replaces `previous`'s connection state with a fresh one, keeping its capabilities and timing.
|
|
84
84
|
/// Candidates that shared an mDNS name or a foundation in the recording share the fresh one.
|
|
85
|
+
/// Candidates other than host candidates are left out.
|
|
85
86
|
pub(crate) fn refresh(previous: Measurement) -> Result<Measurement> {
|
|
86
87
|
let Measurement::Observed {
|
|
87
88
|
preparation,
|
|
@@ -109,17 +110,17 @@ pub(crate) fn refresh(previous: Measurement) -> Result<Measurement> {
|
|
|
109
110
|
.candidate
|
|
110
111
|
.as_str()
|
|
111
112
|
.context("a recorded media candidate is not a line")?;
|
|
112
|
-
|
|
113
|
-
if !is_host(&tokens) {
|
|
113
|
+
if !is_host(line) {
|
|
114
114
|
continue;
|
|
115
115
|
}
|
|
116
|
-
let
|
|
116
|
+
let tokens = tokens(line);
|
|
117
|
+
let name = names
|
|
117
118
|
.entry(tokens[4].clone())
|
|
118
119
|
.or_insert_with(|| format!("{}.local", uuid()));
|
|
119
120
|
let foundation = *foundations
|
|
120
121
|
.entry(tokens[0].clone())
|
|
121
122
|
.or_insert_with(|| u32::from_le_bytes(random()));
|
|
122
|
-
let candidate = identity.candidate(&tokens, foundation,
|
|
123
|
+
let candidate = identity.candidate(&tokens, foundation, name, port());
|
|
123
124
|
let offset = media::delta(recorded_candidate.observed_ms, recorded_started_ms)?;
|
|
124
125
|
candidates.push(Candidate {
|
|
125
126
|
candidate: json!(candidate),
|
|
@@ -248,7 +249,7 @@ mod tests {
|
|
|
248
249
|
assert_eq!(offsets, [-100, -83]);
|
|
249
250
|
assert_eq!(snapshot.finished_ms - snapshot.started_ms, 154);
|
|
250
251
|
for tokens in &lines {
|
|
251
|
-
assert!(
|
|
252
|
+
assert!(tokens[7] == "host" && tokens[4].ends_with(".local"));
|
|
252
253
|
assert!(!HOST.contains(&tokens[4]) && !OTHER_HOST.contains(&tokens[4]));
|
|
253
254
|
}
|
|
254
255
|
assert_ne!(lines[0][4], lines[1][4], "WebKit names each host candidate");
|
|
@@ -349,6 +349,15 @@ impl Fields {
|
|
|
349
349
|
}
|
|
350
350
|
|
|
351
351
|
fn read(&self, record: &JsObject, started_ms: i64) -> Result<Measurement> {
|
|
352
|
+
/// The four-entry support array, deserialized from a JSON array in field order.
|
|
353
|
+
#[derive(Deserialize)]
|
|
354
|
+
struct Support {
|
|
355
|
+
audio_support: Vec<i8>,
|
|
356
|
+
video_support: Vec<i8>,
|
|
357
|
+
audio_decoding: Option<Vec<i32>>,
|
|
358
|
+
video_decoding: Option<Vec<i32>>,
|
|
359
|
+
}
|
|
360
|
+
|
|
352
361
|
let field = |name: &str| {
|
|
353
362
|
record
|
|
354
363
|
.get(name)
|
|
@@ -374,12 +383,12 @@ impl Fields {
|
|
|
374
383
|
.collect::<Result<_>>()?;
|
|
375
384
|
// Read MediaSource support bits followed by decoding bits or null, for audio first and then
|
|
376
385
|
// video.
|
|
377
|
-
let
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
386
|
+
let Support {
|
|
387
|
+
audio_support,
|
|
388
|
+
video_support,
|
|
389
|
+
audio_decoding,
|
|
390
|
+
video_decoding,
|
|
391
|
+
} = js::from_value(field(MEDIA_SUPPORT)?.clone())
|
|
383
392
|
.context("the media support is not four entries")?;
|
|
384
393
|
let reported = [
|
|
385
394
|
(&self.audio_types, &audio_decoding),
|
|
@@ -514,34 +523,39 @@ pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Box<Fields>> {
|
|
|
514
523
|
.iter()
|
|
515
524
|
.find(|text| text.starts_with(prefix) && text.contains(codec))
|
|
516
525
|
};
|
|
517
|
-
let audio = find("audio/mp4;", "mp4a.40.2")?;
|
|
518
|
-
if !audio.contains('A') {
|
|
519
|
-
let arrays = probe.index.string_arrays(probe.entry);
|
|
520
|
-
let list = |prefix: &str, codec: &str| {
|
|
521
|
-
crate::candidates::exactly_one(
|
|
522
|
-
arrays.iter().filter(|items| {
|
|
523
|
-
items.first().is_some_and(|text| text.starts_with(prefix))
|
|
524
|
-
&& items.iter().any(|text| text.contains(codec))
|
|
525
|
-
}),
|
|
526
|
-
"media list",
|
|
527
|
-
)
|
|
528
|
-
.ok()
|
|
529
|
-
.cloned()
|
|
530
|
-
};
|
|
531
|
-
return Some(Box::new(Fields {
|
|
532
|
-
audio_types: list("audio/mp4;", "mp4a.40.2")?,
|
|
533
|
-
video_types: list("video/mp4;", "avc1.42E01E")?,
|
|
534
|
-
excluded_types: list("audio/mp4;", "theora")?,
|
|
535
|
-
}));
|
|
536
|
-
}
|
|
537
|
-
let video = find("video/mp4;", "avc1.42E01E")?;
|
|
538
|
-
let excluded = find("audio/mp4;", "theora")?;
|
|
539
526
|
let types = |list: &str| -> Vec<String> { list.split('A').map(str::to_owned).collect() };
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
527
|
+
let joined = || {
|
|
528
|
+
Some(Box::new(Fields {
|
|
529
|
+
audio_types: types(find("audio/mp4;", "mp4a.40.2")?),
|
|
530
|
+
video_types: types(find("video/mp4;", "avc1.42E01E")?),
|
|
531
|
+
excluded_types: types(find("audio/mp4;", "theora")?),
|
|
532
|
+
}))
|
|
533
|
+
};
|
|
534
|
+
joined().or_else(|| {
|
|
535
|
+
// Builds with hex names list each type: the audio types, the video types, then the
|
|
536
|
+
// excluded types, which mix both.
|
|
537
|
+
let is_media = |text: &str| text.starts_with("audio/") || text.starts_with("video/");
|
|
538
|
+
let run: Vec<String> = decoded
|
|
539
|
+
.iter()
|
|
540
|
+
.skip_while(|text| !is_media(text))
|
|
541
|
+
.take_while(|text| is_media(text))
|
|
542
|
+
.map(|text| text.to_string())
|
|
543
|
+
.collect();
|
|
544
|
+
let leading = |list: &[String], prefix: &str| {
|
|
545
|
+
list.iter()
|
|
546
|
+
.take_while(|text| text.starts_with(prefix))
|
|
547
|
+
.count()
|
|
548
|
+
};
|
|
549
|
+
let (audio, rest) = run.split_at(leading(&run, "audio/"));
|
|
550
|
+
let (video, excluded) = rest.split_at(leading(rest, "video/"));
|
|
551
|
+
(!audio.is_empty() && !video.is_empty() && !excluded.is_empty()).then(|| {
|
|
552
|
+
Box::new(Fields {
|
|
553
|
+
audio_types: audio.to_vec(),
|
|
554
|
+
video_types: video.to_vec(),
|
|
555
|
+
excluded_types: excluded.to_vec(),
|
|
556
|
+
})
|
|
557
|
+
})
|
|
558
|
+
})
|
|
545
559
|
}
|
|
546
560
|
|
|
547
561
|
#[cfg(test)]
|
|
@@ -57,6 +57,7 @@ impl Measurement {
|
|
|
57
57
|
/// loads, and this probe's mark at `marked_ms`. Reports times at 0.1 ms resolution relative
|
|
58
58
|
/// to iframe navigation.
|
|
59
59
|
#[must_use]
|
|
60
|
+
#[expect(clippy::too_many_arguments)]
|
|
60
61
|
pub(crate) fn observed(
|
|
61
62
|
plan: &ProbePlan,
|
|
62
63
|
index: usize,
|
|
@@ -4,6 +4,7 @@ use percent_encoding::percent_decode_str;
|
|
|
4
4
|
use serde::{Deserialize, Serialize};
|
|
5
5
|
|
|
6
6
|
use crate::stage2::analysis::recognize::ProbeView;
|
|
7
|
+
use crate::stage2::vocabulary;
|
|
7
8
|
|
|
8
9
|
/// Number of strings from the opening `<style>#` to the closing button, including fragments
|
|
9
10
|
/// interleaved with 27 ID references.
|
|
@@ -42,24 +43,8 @@ pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
|
|
|
42
43
|
{
|
|
43
44
|
return None;
|
|
44
45
|
}
|
|
45
|
-
let arrays = probe
|
|
46
|
-
.index
|
|
47
|
-
.string_arrays(probe.entry)
|
|
48
|
-
.into_iter()
|
|
49
|
-
.filter(|items| {
|
|
50
|
-
items.len() == 10
|
|
51
|
-
&& items.iter().all(|text| {
|
|
52
|
-
!text.is_empty()
|
|
53
|
-
&& text
|
|
54
|
-
.bytes()
|
|
55
|
-
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
|
56
|
-
})
|
|
57
|
-
});
|
|
58
|
-
if let Ok(ids) = crate::candidates::exactly_one(arrays, "rectangle IDs") {
|
|
59
|
-
return Some(Fields { ids });
|
|
60
|
-
}
|
|
61
46
|
// Match `list.split(separator)`, including lists decoded from percent encoding first.
|
|
62
|
-
(2..strings.len()).find_map(|at| {
|
|
47
|
+
let split = (2..strings.len()).find_map(|at| {
|
|
63
48
|
let separator = &strings[at - 1];
|
|
64
49
|
if strings[at] != "split" || separator.is_empty() {
|
|
65
50
|
return None;
|
|
@@ -75,5 +60,15 @@ pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
|
|
|
75
60
|
.iter()
|
|
76
61
|
.all(|id| !id.is_empty() && id.bytes().all(|byte| byte.is_ascii_alphanumeric()));
|
|
77
62
|
valid.then_some(Fields { ids })
|
|
63
|
+
});
|
|
64
|
+
// Builds with hex names store the IDs as hex names in list order: the last ten of the last
|
|
65
|
+
// run of at least ten before the markup. An option read may precede them in the run.
|
|
66
|
+
split.or_else(|| {
|
|
67
|
+
let run = strings[..start]
|
|
68
|
+
.chunk_by(|left, right| vocabulary::is_hex_name(left) == vocabulary::is_hex_name(right))
|
|
69
|
+
.rfind(|run| run.len() >= 10 && vocabulary::is_hex_name(&run[0]))?;
|
|
70
|
+
Some(Fields {
|
|
71
|
+
ids: run[run.len() - 10..].to_vec(),
|
|
72
|
+
})
|
|
78
73
|
})
|
|
79
74
|
}
|
|
@@ -10,6 +10,7 @@ use crate::js::{JsObject, json};
|
|
|
10
10
|
use crate::listing::Index;
|
|
11
11
|
use crate::stage2::analysis::recognize::ProbeView;
|
|
12
12
|
use crate::stage2::call_stack::ProbeCalls;
|
|
13
|
+
use crate::stage2::vocabulary;
|
|
13
14
|
use crate::vm::Term;
|
|
14
15
|
|
|
15
16
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
@@ -29,7 +30,7 @@ impl Fields {
|
|
|
29
30
|
|
|
30
31
|
/// Finds the closure called by the probe function at `entry` to construct `new Error()`.
|
|
31
32
|
fn calls(index: &Index<'_>, entry: u32) -> Option<ProbeCalls> {
|
|
32
|
-
|
|
33
|
+
index
|
|
33
34
|
.effects_in(index.function_range(entry)?)
|
|
34
35
|
.find_map(|(pc, effect)| {
|
|
35
36
|
let (callee, _) = effect.call()?;
|
|
@@ -37,11 +38,8 @@ fn calls(index: &Index<'_>, entry: u32) -> Option<ProbeCalls> {
|
|
|
37
38
|
return None;
|
|
38
39
|
};
|
|
39
40
|
construction(index, closure)
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
construct,
|
|
43
|
-
arity: index.arity(entry)?,
|
|
44
|
-
})
|
|
41
|
+
})
|
|
42
|
+
.map(|construct| ProbeCalls { construct })
|
|
45
43
|
}
|
|
46
44
|
|
|
47
45
|
/// The opcode with which the function at `entry` constructs `new Error()`.
|
|
@@ -63,66 +61,52 @@ fn construction(index: &Index<'_>, entry: u32) -> Option<u8> {
|
|
|
63
61
|
})
|
|
64
62
|
}
|
|
65
63
|
|
|
66
|
-
///
|
|
67
|
-
|
|
64
|
+
/// Finds the output field hidden by base64 encoding and a two-byte XOR key. A name that decodes to
|
|
65
|
+
/// `stack` identifies the key. The output field is the remaining name after excluding `stack` and
|
|
66
|
+
/// the record counter from the header. Builds with hex names encode only `stack`.
|
|
67
|
+
fn encoded_result_field(features: &[String], header: &BTreeSet<String>) -> Option<String> {
|
|
68
68
|
use base64::Engine;
|
|
69
69
|
use base64::engine::general_purpose::STANDARD;
|
|
70
70
|
|
|
71
71
|
let decoded: Vec<Vec<u8>> = features
|
|
72
72
|
.iter()
|
|
73
|
-
.filter_map(|text|
|
|
74
|
-
|
|
75
|
-
let text = if text.starts_with('\0') {
|
|
76
|
-
decoded = crate::listing::decode_string(text, "").ok()?;
|
|
77
|
-
&decoded
|
|
78
|
-
} else {
|
|
79
|
-
text
|
|
80
|
-
};
|
|
81
|
-
STANDARD.decode(text).ok()
|
|
82
|
-
})
|
|
83
|
-
.filter(|bytes| (3..=25).contains(&bytes.len()))
|
|
73
|
+
.filter_map(|text| STANDARD.decode(text).ok())
|
|
74
|
+
.filter(|bytes| (3..=12).contains(&bytes.len()))
|
|
84
75
|
.collect();
|
|
85
|
-
let
|
|
76
|
+
let plain: BTreeSet<String> = features
|
|
86
77
|
.iter()
|
|
87
|
-
.filter(|
|
|
88
|
-
.filter_map(|candidate| {
|
|
89
|
-
let key = [candidate[0] ^ b's', candidate[1] ^ b't'];
|
|
90
|
-
candidate
|
|
91
|
-
.iter()
|
|
92
|
-
.zip(b"stack")
|
|
93
|
-
.enumerate()
|
|
94
|
-
.all(|(index, (cipher, plain))| cipher ^ key[index % 2] == *plain)
|
|
95
|
-
.then_some(key)
|
|
96
|
-
})
|
|
97
|
-
.collect();
|
|
98
|
-
let key = candidates::exactly_one(keys, "stack string key").ok()?;
|
|
99
|
-
Some(
|
|
100
|
-
decoded
|
|
101
|
-
.iter()
|
|
102
|
-
.filter_map(|bytes| {
|
|
103
|
-
String::from_utf8(
|
|
104
|
-
bytes
|
|
105
|
-
.iter()
|
|
106
|
-
.zip(key.iter().cycle())
|
|
107
|
-
.map(|(byte, mask)| byte ^ mask)
|
|
108
|
-
.collect(),
|
|
109
|
-
)
|
|
110
|
-
.ok()
|
|
111
|
-
})
|
|
112
|
-
.collect(),
|
|
113
|
-
)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/// Ignore ordinary base64-looking words and the record counter when identifying the output.
|
|
117
|
-
fn encoded_result_field(names: &[String], header: &BTreeSet<String>) -> Option<String> {
|
|
118
|
-
let names = names.iter().filter(|name| {
|
|
119
|
-
crate::stage2::vocabulary::is_name(name)
|
|
120
|
-
&& name.as_str() != "stack"
|
|
121
|
-
&& !header.contains(*name)
|
|
122
|
-
});
|
|
123
|
-
candidates::at_most_one_distinct(names, "stack result field")
|
|
124
|
-
.ok()?
|
|
78
|
+
.filter(|name| vocabulary::is_hex_name(name))
|
|
125
79
|
.cloned()
|
|
80
|
+
.collect();
|
|
81
|
+
for candidate in decoded.iter().filter(|bytes| bytes.len() == "stack".len()) {
|
|
82
|
+
let key = [candidate[0] ^ b's', candidate[1] ^ b't'];
|
|
83
|
+
let reveal = |bytes: &Vec<u8>| {
|
|
84
|
+
let revealed = bytes
|
|
85
|
+
.iter()
|
|
86
|
+
.zip(key.iter().cycle())
|
|
87
|
+
.map(|(byte, mask)| byte ^ mask);
|
|
88
|
+
String::from_utf8(revealed.collect()).ok()
|
|
89
|
+
};
|
|
90
|
+
if reveal(candidate).as_deref() != Some("stack") {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
// Plain strings such as `this` can also be valid base64. Restrict candidates to the
|
|
94
|
+
// four-to-nine-character program names.
|
|
95
|
+
let revealed = decoded.iter().filter_map(reveal).filter(|name| {
|
|
96
|
+
(4..=9).contains(&name.len())
|
|
97
|
+
&& name.starts_with(|first: char| first.is_ascii_alphabetic())
|
|
98
|
+
&& name
|
|
99
|
+
.chars()
|
|
100
|
+
.all(|character| character.is_ascii_alphanumeric())
|
|
101
|
+
});
|
|
102
|
+
let names = revealed
|
|
103
|
+
.chain(plain.iter().cloned())
|
|
104
|
+
.filter(|name| name != "stack" && !header.contains(name));
|
|
105
|
+
if let Ok(result) = candidates::exactly_one(names, "stack result field") {
|
|
106
|
+
return Some(result);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
None
|
|
126
110
|
}
|
|
127
111
|
|
|
128
112
|
/// Finds the output field in the string table by excluding the header's record-counter name,
|
|
@@ -158,23 +142,12 @@ fn result_field(features: &[String], header: &BTreeSet<String>) -> Option<String
|
|
|
158
142
|
}
|
|
159
143
|
|
|
160
144
|
pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
encoded.as_ref()?;
|
|
168
|
-
let fields = probe.writes.fields.iter().filter(|name| {
|
|
169
|
-
crate::stage2::vocabulary::is_name(name)
|
|
170
|
-
&& !probe.header.contains(*name)
|
|
171
|
-
&& !probe.wrapper.contains(*name)
|
|
172
|
-
});
|
|
173
|
-
candidates::exactly_one(fields, "plain stack result")
|
|
174
|
-
.ok()
|
|
175
|
-
.cloned()
|
|
176
|
-
})?;
|
|
177
|
-
Some(Fields { result, calls })
|
|
145
|
+
result_field(probe.session_features, probe.header)
|
|
146
|
+
.or_else(|| encoded_result_field(probe.session_features, probe.header))
|
|
147
|
+
.map(|result| Fields {
|
|
148
|
+
result,
|
|
149
|
+
calls: calls(probe.index, probe.entry),
|
|
150
|
+
})
|
|
178
151
|
}
|
|
179
152
|
|
|
180
153
|
#[cfg(test)]
|
|
@@ -215,38 +188,12 @@ mod tests {
|
|
|
215
188
|
],
|
|
216
189
|
] {
|
|
217
190
|
let features = features.map(str::to_owned);
|
|
218
|
-
let names = encoded_names(&features).unwrap();
|
|
219
191
|
assert_eq!(
|
|
220
|
-
encoded_result_field(&
|
|
192
|
+
encoded_result_field(&features, &header).as_deref(),
|
|
221
193
|
Some("CJkad9"),
|
|
222
194
|
"{features:?}"
|
|
223
195
|
);
|
|
224
|
-
assert!(encoded_result_field(&
|
|
196
|
+
assert!(encoded_result_field(&features, &BTreeSet::new()).is_none());
|
|
225
197
|
}
|
|
226
198
|
}
|
|
227
|
-
|
|
228
|
-
#[test]
|
|
229
|
-
fn decodes_inline_tables_and_rejects_conflicting_keys() {
|
|
230
|
-
use base64::{Engine, engine::general_purpose::STANDARD};
|
|
231
|
-
let encode = |text: &str, key: [u8; 2]| {
|
|
232
|
-
STANDARD.encode(
|
|
233
|
-
text.bytes()
|
|
234
|
-
.enumerate()
|
|
235
|
-
.map(|(index, byte)| byte ^ key[index % 2])
|
|
236
|
-
.collect::<Vec<_>>(),
|
|
237
|
-
)
|
|
238
|
-
};
|
|
239
|
-
let key = [17, 44];
|
|
240
|
-
let field = "_0123456789abcdef01234567";
|
|
241
|
-
let features = ["stack", field].map(|text| {
|
|
242
|
-
let encoded = encode(text, key);
|
|
243
|
-
format!("\0{}{encoded}padding", char::from(encoded.len() as u8))
|
|
244
|
-
});
|
|
245
|
-
let names = encoded_names(&features).unwrap();
|
|
246
|
-
assert_eq!(
|
|
247
|
-
encoded_result_field(&names, &BTreeSet::new()).as_deref(),
|
|
248
|
-
Some(field)
|
|
249
|
-
);
|
|
250
|
-
assert!(encoded_names(&[encode("stack", key), encode("stack", [18, 45])]).is_none());
|
|
251
|
-
}
|
|
252
199
|
}
|
|
@@ -126,13 +126,7 @@ impl Fields {
|
|
|
126
126
|
/// or before opening. The close status follows.
|
|
127
127
|
/// - The platform option precedes the URL suffix `/w`.
|
|
128
128
|
pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
|
|
129
|
-
let
|
|
130
|
-
.session_features
|
|
131
|
-
.iter()
|
|
132
|
-
.filter(|text| !matches!(text.as_str(), "Date" | "Math" | "length"))
|
|
133
|
-
.cloned()
|
|
134
|
-
.collect();
|
|
135
|
-
strings.dedup();
|
|
129
|
+
let strings = probe.session_features;
|
|
136
130
|
let markers = ["WebSocket", "onopen", "onmessage", "onclose", "isFinite"];
|
|
137
131
|
if !markers
|
|
138
132
|
.iter()
|
|
@@ -147,21 +141,19 @@ pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
|
|
|
147
141
|
};
|
|
148
142
|
let before = |sequence: &[&str]| strings.get(find(sequence)?.checked_sub(1)?).cloned();
|
|
149
143
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
let [
|
|
163
|
-
crate::candidates::exactly_one(candidates, "WebSocket summary").ok()?
|
|
164
|
-
else {
|
|
144
|
+
// Builds that load builtins such as `length`, `Date` and `Math` in the header omit them
|
|
145
|
+
// here. Their summary follows `readyState` and `close` instead.
|
|
146
|
+
let (summary, statuses) = match find(&["length"]) {
|
|
147
|
+
Some(length) => (length.checked_sub(5)?, length + 1),
|
|
148
|
+
None => {
|
|
149
|
+
let summary = find(&["readyState", "close"])? + 2;
|
|
150
|
+
(summary, summary + 5)
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
let [status, sent, received, opened, _error] = strings.get(summary..summary + 5)? else {
|
|
154
|
+
return None;
|
|
155
|
+
};
|
|
156
|
+
let [complete, incomplete] = strings.get(statuses..statuses + 2)? else {
|
|
165
157
|
return None;
|
|
166
158
|
};
|
|
167
159
|
let finite = find(&["isFinite"])?;
|
|
@@ -178,11 +170,12 @@ pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
|
|
|
178
170
|
received: received.clone(),
|
|
179
171
|
opened: opened.clone(),
|
|
180
172
|
round_trips: before(&["JSON", "stringify"])?,
|
|
181
|
-
elapsed: before(&["String", "now"])?,
|
|
173
|
+
elapsed: before(&["String", "Date", "now"]).or_else(|| before(&["String", "now"]))?,
|
|
182
174
|
statuses: Statuses {
|
|
183
175
|
complete: complete.clone(),
|
|
184
176
|
incomplete: incomplete.clone(),
|
|
185
|
-
timed_out: before(&["String", "round"])
|
|
177
|
+
timed_out: before(&["String", "Math", "round"])
|
|
178
|
+
.or_else(|| before(&["String", "round"]))?,
|
|
186
179
|
failed_before_open: failed_before_open.clone(),
|
|
187
180
|
failed_after_open: failed_after_open.clone(),
|
|
188
181
|
closed: closed.clone(),
|
|
@@ -207,68 +200,6 @@ pub(crate) fn recognize(probe: &ProbeView<'_>) -> Option<Fields> {
|
|
|
207
200
|
mod tests {
|
|
208
201
|
use super::*;
|
|
209
202
|
|
|
210
|
-
#[test]
|
|
211
|
-
fn recovers_summary_after_string_conversion_reads() {
|
|
212
|
-
use crate::{
|
|
213
|
-
listing::Index,
|
|
214
|
-
stage2::{analysis::recognize::Strings, records::WriteOrder, vocabulary::Vocabulary},
|
|
215
|
-
vm::Listing,
|
|
216
|
-
};
|
|
217
|
-
use std::collections::BTreeSet;
|
|
218
|
-
|
|
219
|
-
let mut features: Vec<String> = "WebSocket readyState close Status1 Sent1 String Sent1 Recv1 String Recv1 Open1 Trips1 JSON stringify Elapsed1 String now Error1 Status1 Sent1 Recv1 Open1 Error1 Done1 Short1 send Time1 String round isFinite"
|
|
220
|
-
.split_whitespace().map(str::to_owned).collect();
|
|
221
|
-
features.push(String::new());
|
|
222
|
-
features.extend(
|
|
223
|
-
"After1 Before1 Closed1 Route1 /w onopen onmessage onclose"
|
|
224
|
-
.split_whitespace()
|
|
225
|
-
.map(str::to_owned),
|
|
226
|
-
);
|
|
227
|
-
let writes = WriteOrder {
|
|
228
|
-
fields: "Status1 Sent1 Recv1 Open1 Error1 Trips1 Elapsed1"
|
|
229
|
-
.split_whitespace()
|
|
230
|
-
.map(str::to_owned)
|
|
231
|
-
.collect(),
|
|
232
|
-
..WriteOrder::default()
|
|
233
|
-
};
|
|
234
|
-
let listing = Listing::default();
|
|
235
|
-
let index = Index::new(&listing);
|
|
236
|
-
let vocabulary = Vocabulary::infer::<&[String]>(&[]);
|
|
237
|
-
let empty = BTreeSet::new();
|
|
238
|
-
let recover = |features: &[String]| {
|
|
239
|
-
recognize(&ProbeView {
|
|
240
|
-
index: &index,
|
|
241
|
-
vocabulary: &vocabulary,
|
|
242
|
-
position: 0,
|
|
243
|
-
header: &empty,
|
|
244
|
-
wrapper: &empty,
|
|
245
|
-
pool: "",
|
|
246
|
-
entry: 0,
|
|
247
|
-
session_features: features,
|
|
248
|
-
writes: &writes,
|
|
249
|
-
strings: Strings::new(features, &empty),
|
|
250
|
-
})
|
|
251
|
-
};
|
|
252
|
-
let fields = recover(&features).unwrap();
|
|
253
|
-
assert_eq!(fields.status, "Status1");
|
|
254
|
-
assert_eq!(fields.sent, "Sent1");
|
|
255
|
-
assert_eq!(fields.received, "Recv1");
|
|
256
|
-
assert_eq!(fields.opened, "Open1");
|
|
257
|
-
assert_eq!(fields.round_trips, "Trips1");
|
|
258
|
-
assert_eq!(fields.elapsed, "Elapsed1");
|
|
259
|
-
assert_eq!(fields.statuses.complete, "Done1");
|
|
260
|
-
assert_eq!(fields.statuses.incomplete, "Short1");
|
|
261
|
-
features.extend(
|
|
262
|
-
"Status1 Sent1 Recv1 Open1 Error1 Other1 Other2"
|
|
263
|
-
.split_whitespace()
|
|
264
|
-
.map(str::to_owned),
|
|
265
|
-
);
|
|
266
|
-
assert!(
|
|
267
|
-
recover(&features).is_none(),
|
|
268
|
-
"ambiguous summaries must not be guessed"
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
203
|
fn fields() -> Fields {
|
|
273
204
|
Fields {
|
|
274
205
|
platform: "ppebL7".into(),
|
|
@@ -124,13 +124,13 @@ pub struct Probe {
|
|
|
124
124
|
pub operation: Option<Operation>,
|
|
125
125
|
/// The strings the probe's collection function uses, in order.
|
|
126
126
|
pub features: Vec<String>,
|
|
127
|
-
/// Collection strings including uses of constants held in registers.
|
|
128
|
-
#[serde(default)]
|
|
129
|
-
pub expanded_features: Vec<String>,
|
|
130
127
|
/// Recovered write order used to arrange computed fields. Replayed answers retain the
|
|
131
128
|
/// browser's recorded order.
|
|
132
129
|
#[serde(default)]
|
|
133
130
|
pub writes: WriteOrder,
|
|
131
|
+
/// Session literals recovered from result writes that cannot be replayed unchanged.
|
|
132
|
+
#[serde(default)]
|
|
133
|
+
pub replay_literals: JsObject,
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
impl Probe {
|
|
@@ -144,8 +144,6 @@ impl Probe {
|
|
|
144
144
|
/// The order in which a probe's collection function writes its record's fields.
|
|
145
145
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
|
146
146
|
pub struct WriteOrder {
|
|
147
|
-
#[serde(default)]
|
|
148
|
-
pub internal: Vec<String>,
|
|
149
147
|
/// Stored names in execution order: the synchronous body first, then its callbacks.
|
|
150
148
|
pub fields: Vec<String>,
|
|
151
149
|
/// Fields that only callbacks write after the probe has finished. They follow the clock
|
|
@@ -207,14 +205,7 @@ impl ProbePlan {
|
|
|
207
205
|
.iter()
|
|
208
206
|
.map(|probe| probe.features.as_slice())
|
|
209
207
|
.collect();
|
|
210
|
-
|
|
211
|
-
let expanded: Vec<&[String]> = self
|
|
212
|
-
.probes
|
|
213
|
-
.iter()
|
|
214
|
-
.map(|probe| probe.expanded_features.as_slice())
|
|
215
|
-
.collect();
|
|
216
|
-
vocabulary.extend_expanded(&expanded);
|
|
217
|
-
vocabulary
|
|
208
|
+
Vocabulary::infer(&features)
|
|
218
209
|
})
|
|
219
210
|
}
|
|
220
211
|
|