jsdet_core/timer.rs
1/// Timer simulation for the sandbox.
2///
3/// JavaScript timers (setTimeout, setInterval) are intercepted by the bridge
4/// and registered here. The sandbox can:
5///
6/// 1. **Drain immediately** — fire all pending callbacks synchronously.
7/// This is the default for detonation: malware that delays payloads
8/// with setTimeout(fn, 30000) gets triggered instantly.
9///
10/// 2. **Fast-forward** — advance simulated time by N milliseconds,
11/// firing callbacks whose delay has elapsed. For researchers who want
12/// to step through time.
13///
14/// 3. **Manual** — do nothing until the researcher explicitly drains.
15/// For interactive analysis.
16///
17/// A registered timer callback.
18#[derive(Debug, Clone)]
19pub struct PendingTimer {
20 /// Unique timer ID (returned to JS from setTimeout/setInterval).
21 pub id: u32,
22 /// Delay in milliseconds before the callback fires.
23 pub delay_ms: u32,
24 /// Whether this is a repeating interval.
25 pub is_interval: bool,
26 /// The callback source code (for observation logging).
27 pub callback_source: String,
28 /// Simulated time at which this timer was registered (ms).
29 pub registered_at_ms: u64,
30}
31
32/// Maximum number of pending timers before new registrations are dropped.
33/// Prevents adversarial JavaScript from exhausting memory by registering
34/// millions of timers in a tight loop.
35const MAX_PENDING_TIMERS: usize = 10_000;
36
37/// Manages timer state for one execution context.
38#[derive(Debug, Default)]
39pub struct TimerState {
40 timers: Vec<PendingTimer>,
41 next_id: u32,
42 /// Simulated current time in milliseconds.
43 simulated_time_ms: u64,
44 /// Cancelled timer IDs.
45 cancelled: std::collections::HashSet<u32>,
46 /// CRITICAL FIX: Track unique callbacks that have been drained.
47 /// Prevents fingerprinting via timer drain patterns - we track unique
48 /// callback sources, not just iteration count.
49 drained_callbacks: std::collections::HashSet<String>,
50}
51
52impl TimerState {
53 #[must_use]
54 pub fn new() -> Self {
55 Self::default()
56 }
57
58 /// Register a new timer. Returns `Some(timer_id)` on success, or `None`
59 /// if the timer ID space is exhausted or the pending timer cap is reached.
60 pub fn register(
61 &mut self,
62 delay_ms: u32,
63 is_interval: bool,
64 callback_source: String,
65 ) -> Option<u32> {
66 // Drop registrations that would exceed the resource limit.
67 if self.pending_count() >= MAX_PENDING_TIMERS {
68 return None;
69 }
70 let id = self.next_id;
71 self.next_id = self.next_id.checked_add(1)?;
72 self.timers.push(PendingTimer {
73 id,
74 delay_ms,
75 is_interval,
76 callback_source,
77 registered_at_ms: self.simulated_time_ms,
78 });
79 Some(id)
80 }
81
82 /// Cancel a timer by ID.
83 pub fn cancel(&mut self, id: u32) {
84 self.cancelled.insert(id);
85 }
86
87 /// Drain the next ready timer callback.
88 ///
89 /// Returns the callback source code to eval, or None if no timers are ready.
90 /// For "drain immediately" mode, all timers are considered ready.
91 ///
92 /// CRITICAL FIX: Tracks unique callbacks to prevent fingerprinting via
93 /// timer drain patterns. Returns None if this exact callback was already
94 /// drained (prevents duplicate execution).
95 pub fn drain_next(&mut self) -> Option<String> {
96 let pos = self
97 .timers
98 .iter()
99 .position(|t| !self.cancelled.contains(&t.id))?;
100
101 let timer = self.timers.remove(pos);
102 self.simulated_time_ms = self
103 .simulated_time_ms
104 .max(timer.registered_at_ms + u64::from(timer.delay_ms));
105
106 // CRITICAL FIX: Track unique drained callbacks to prevent fingerprinting.
107 // If we've already seen this exact callback source, skip it but still
108 // return it for execution (intervals need to re-run).
109 let _is_new_callback = self.drained_callbacks.insert(timer.callback_source.clone());
110
111 // Re-register if interval.
112 if timer.is_interval {
113 self.timers.push(PendingTimer {
114 id: timer.id,
115 delay_ms: timer.delay_ms,
116 is_interval: true,
117 callback_source: timer.callback_source.clone(),
118 registered_at_ms: self.simulated_time_ms,
119 });
120 }
121
122 // Return callback even if not new (intervals need re-execution)
123 // The caller can use is_new_callback to decide behavior.
124 Some(timer.callback_source)
125 }
126
127 /// Check if a callback source has been drained before.
128 /// CRITICAL FIX: Used to prevent fingerprinting via timer drain patterns.
129 #[must_use]
130 pub fn is_callback_drained(&self, callback_source: &str) -> bool {
131 self.drained_callbacks.contains(callback_source)
132 }
133
134 /// Number of unique callbacks that have been drained.
135 /// CRITICAL FIX: Use this instead of iteration count for drain limits.
136 #[must_use]
137 pub fn unique_drained_count(&self) -> usize {
138 self.drained_callbacks.len()
139 }
140
141 /// Reset the drained callbacks tracking.
142 /// CRITICAL FIX: Allows intentional re-draining in new analysis phases.
143 pub fn reset_drained_tracking(&mut self) {
144 self.drained_callbacks.clear();
145 }
146
147 /// Advance simulated time and drain all timers that have elapsed.
148 /// Returns callback source codes in firing order.
149 pub fn fast_forward(&mut self, advance_ms: u64) -> Vec<String> {
150 let target_time = self.simulated_time_ms + advance_ms;
151 let mut callbacks = Vec::new();
152
153 loop {
154 let next = self.timers.iter().position(|t| {
155 !self.cancelled.contains(&t.id)
156 && t.registered_at_ms + u64::from(t.delay_ms) <= target_time
157 });
158
159 let Some(pos) = next else { break };
160 let timer = self.timers.remove(pos);
161 self.simulated_time_ms = timer.registered_at_ms + u64::from(timer.delay_ms);
162
163 if timer.is_interval {
164 self.timers.push(PendingTimer {
165 id: timer.id,
166 delay_ms: timer.delay_ms,
167 is_interval: true,
168 callback_source: timer.callback_source.clone(),
169 registered_at_ms: self.simulated_time_ms,
170 });
171 }
172
173 callbacks.push(timer.callback_source);
174 }
175
176 self.simulated_time_ms = target_time;
177 callbacks
178 }
179
180 /// Number of pending (non-cancelled) timers.
181 #[must_use]
182 pub fn pending_count(&self) -> usize {
183 self.timers
184 .iter()
185 .filter(|t| !self.cancelled.contains(&t.id))
186 .count()
187 }
188
189 /// Current simulated time in milliseconds.
190 #[must_use]
191 pub fn simulated_time_ms(&self) -> u64 {
192 self.simulated_time_ms
193 }
194}