keyhog_verifier/oob/client.rs
1//! Low-level interactsh protocol client.
2//!
3//! A thin async wrapper around the projectdiscovery/interactsh-server register/
4//! poll/deregister endpoints. Stateless aside from the RSA keypair, secret,
5//! correlation id, and HTTP client - `OobSession` (in `session.rs`) layers
6//! the per-finding subscription, polling loop, and notification fan-out on top.
7//!
8//! ## Crypto invariants
9//!
10//! - RSA-2048, OAEP padding, SHA-256 hash and MGF - interactsh-server speaks
11//! exactly this combination; `RSA_PKCS1_OAEP_PADDING` with SHA-256 in their
12//! Go code. Other parameters won't decrypt.
13//! - AES-256-CFB with a 16-byte IV prepended to ciphertext. Each interaction
14//! carries an independent IV; the AES key is per-poll-batch.
15//! - We never log credentials, public keys, or decrypted payloads. Errors
16//! carry stable strings - useful for support, opaque to leaks.
17
18use std::time::Duration;
19
20use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
21use rand::distributions::Alphanumeric;
22use rand::{rngs::OsRng, Rng};
23use reqwest::Client;
24use rsa::pkcs8::{EncodePublicKey, LineEnding};
25use rsa::{Oaep, RsaPrivateKey, RsaPublicKey};
26use serde::{Deserialize, Serialize};
27use sha2::Sha256;
28use thiserror::Error;
29use tracing::{debug, warn};
30
31/// Stable bucket name for the global rate limiter. Every OOB call across
32/// every detector shares this bucket so the aggregate request rate to the
33/// upstream collector never exceeds the configured `--verify-rate`. Using
34/// the literal string `"oob.interactsh"` (not the server URL) means the
35/// budget covers all configured collectors collectively - the limit is
36/// about our own machine not blasting traffic, not about per-host fairness.
37const OOB_SERVICE: &str = "oob.interactsh";
38
39/// All errors that can arise from the OOB client. `Transient` errors mean the
40/// caller should retry (network blip, rate-limit); everything else is final.
41#[derive(Debug, Error)]
42pub enum InteractshError {
43 #[error("interactsh keypair generation failed: {0}")]
44 KeyGen(String),
45 #[error("interactsh public-key encoding failed: {0}")]
46 KeyEncode(String),
47 #[error("interactsh register failed (HTTP {status}): {body}")]
48 Register { status: u16, body: String },
49 #[error("interactsh poll failed (HTTP {status}): {body}")]
50 Poll { status: u16, body: String },
51 #[error("interactsh response shape unexpected: {0}")]
52 BadResponse(String),
53 #[error("interactsh collector host blocked by SSRF guard: {0}")]
54 BlockedCollector(String),
55 #[error("interactsh AES key unwrap failed: {0}")]
56 AesUnwrap(String),
57 #[error("interactsh interaction decrypt failed: {0}")]
58 Decrypt(String),
59 #[error("interactsh transport error: {0}")]
60 Transport(#[from] reqwest::Error),
61 #[error("interactsh request timed out after {0:?}")]
62 Timeout(Duration),
63}
64
65/// Protocol category of a received interaction.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67pub enum InteractionProtocol {
68 Dns,
69 Http,
70 Smtp,
71 Other,
72}
73
74impl InteractionProtocol {
75 // `#[doc(hidden)] pub` rather than `pub(super)`: the OOB protocol-string
76 // parser is exercised directly by the boundary test
77 // `oob_interaction_protocol_parse_exact`. Hidden from the rendered API —
78 // it is an internal categorizer, not a semver-covered surface.
79 #[doc(hidden)]
80 pub fn parse(s: &str) -> Self {
81 match s.to_ascii_lowercase().as_str() {
82 "dns" => Self::Dns,
83 "http" => Self::Http,
84 "smtp" | "smtp-mail" => Self::Smtp,
85 _ => Self::Other,
86 }
87 }
88}
89
90/// One decrypted interaction returned by the collector.
91#[derive(Debug, Clone)]
92pub struct Interaction {
93 /// Full 33-char unique id (correlation-id || 13-char suffix). This is
94 /// what we match against per-finding URLs we minted.
95 pub unique_id: String,
96 pub protocol: InteractionProtocol,
97 pub remote_address: String,
98 pub timestamp: String,
99 /// Raw protocol payload (HTTP request line + headers, DNS query, etc.).
100 /// Sized - interactsh truncates server-side, but we cap to 16 KiB here as
101 /// a defense-in-depth budget against memory abuse from a hostile server.
102 pub raw_payload: String,
103}
104
105/// One interactsh registration. Cheap to clone (Arc-friendly fields only on
106/// caller's side; here we hold owned values because the session pins this
107/// for the lifetime of the engine).
108pub struct InteractshClient {
109 http: Client,
110 server: String,
111 correlation_id: String,
112 secret_key: String,
113 private_key: RsaPrivateKey,
114 /// Length of the per-URL suffix (interactsh uses 13 to bring total ID to
115 /// 33 chars). Exposed for tests; production always uses 13.
116 suffix_len: usize,
117}
118
119impl InteractshClient {
120 /// Test-only constructor without network registration. Returns
121 /// `Err` if the RSA keygen RNG fails - which never happens on a
122 /// healthy platform, but propagating the error keeps this constructor
123 /// off the no-panic-in-production gate and matches the rest of the
124 /// `InteractshError` surface. Test callers wrap with `.unwrap()` at
125 /// the test boundary.
126 pub fn for_test(server: &str) -> Result<Self, InteractshError> {
127 let private_key = RsaPrivateKey::new(&mut OsRng, 1024)
128 .map_err(|e| InteractshError::KeyGen(e.to_string()))?;
129 Ok(Self {
130 http: Client::new(),
131 server: normalize_server(server),
132 correlation_id: "abcdefghijklmnopqrst".to_string(),
133 secret_key: "test-secret".to_string(),
134 private_key,
135 suffix_len: 13,
136 })
137 }
138}
139
140/// JSON shapes from interactsh-server. Field names match the upstream Go
141/// definitions (`pkg/server/types.go`). `serde(default)` keeps us forward-
142/// compatible with future fields.
143#[derive(Serialize)]
144struct RegisterRequest<'a> {
145 #[serde(rename = "public-key")]
146 public_key: &'a str,
147 #[serde(rename = "secret-key")]
148 secret_key: &'a str,
149 #[serde(rename = "correlation-id")]
150 correlation_id: &'a str,
151}
152
153#[derive(Deserialize, Default)]
154#[serde(default)]
155struct PollResponse {
156 /// Each entry is base64( AES-256-CFB( IV[16] || ciphertext ) ).
157 data: Vec<String>,
158 /// Auxiliary metadata; ignored.
159 #[allow(dead_code)]
160 extra: Vec<String>,
161 /// Base64( RSA-OAEP-SHA256( 32-byte AES key ) ). Server omits when there
162 /// are no interactions; in that case `data` is also empty.
163 aes_key: Option<String>,
164}
165
166/// Decrypted interaction shape. `serde(default)` because interactsh-server
167/// sometimes ships partial events (failed protocol parse, etc.) and we'd
168/// rather degrade gracefully than 500.
169/// Hard cap on the body of a `/poll` response. Protects the process from a
170/// hostile or misbehaving collector returning a multi-gigabyte JSON that
171/// would force `serde_json::from_slice` to allocate the whole thing
172/// in-memory before we can validate it. 4 MiB comfortably fits any
173/// reasonable poll batch - see the rationale at the call site.
174const MAX_POLL_BODY_BYTES: usize = 4 * 1024 * 1024;
175
176/// Cap on error/diagnostic bodies. We only display the first 256 chars in
177/// the error message anyway, but the cap prevents a server returning a
178/// 500 with a 1 GiB body from spiking memory.
179const ERROR_BODY_CAP: usize = 64 * 1024;
180
181/// Stream a response body into a Vec under a hard byte cap. Returns
182/// `BadResponse` if the cap is exceeded - abort the read rather than
183/// trust the server's framing.
184async fn read_capped_bytes(
185 resp: reqwest::Response,
186 cap: usize,
187) -> Result<Vec<u8>, InteractshError> {
188 use futures_util::StreamExt;
189 let mut stream = resp.bytes_stream();
190 let mut buf: Vec<u8> = Vec::new();
191 while let Some(chunk) = stream.next().await {
192 let chunk = chunk.map_err(InteractshError::Transport)?;
193 if buf.len().saturating_add(chunk.len()) > cap {
194 return Err(InteractshError::BadResponse(format!(
195 "response body exceeds {cap}-byte cap"
196 )));
197 }
198 buf.extend_from_slice(&chunk);
199 }
200 Ok(buf)
201}
202
203/// Like `read_capped_bytes` but for diagnostic error messages - never
204/// returns `Err`; on a stream failure or cap breach it returns whatever
205/// was buffered so the error log can still surface something.
206async fn read_capped_text(resp: reqwest::Response, cap: usize) -> String {
207 use futures_util::StreamExt;
208 let mut stream = resp.bytes_stream();
209 let mut buf: Vec<u8> = Vec::new();
210 while let Some(chunk) = stream.next().await {
211 let Ok(chunk) = chunk else { break };
212 if buf.len().saturating_add(chunk.len()) > cap {
213 break;
214 }
215 buf.extend_from_slice(&chunk);
216 }
217 String::from_utf8_lossy(&buf).into_owned()
218}
219
220impl InteractshClient {
221 /// Build, generate keys, and register with the collector. The returned
222 /// client is ready to mint URLs and be polled.
223 pub async fn register(http: Client, server: &str) -> Result<Self, InteractshError> {
224 // RSA-2048 keygen happens on a blocking thread - it's CPU-bound for
225 // ~100ms and would otherwise stall the runtime.
226 let private_key = tokio::task::spawn_blocking(|| {
227 RsaPrivateKey::new(&mut OsRng, 2048).map_err(|e| InteractshError::KeyGen(e.to_string()))
228 })
229 .await
230 .map_err(|e| InteractshError::KeyGen(format!("join error: {e}")))??;
231
232 let public_key = RsaPublicKey::from(&private_key);
233 let pem = public_key
234 .to_public_key_pem(LineEnding::LF)
235 .map_err(|e| InteractshError::KeyEncode(e.to_string()))?;
236 let public_key_b64 = B64.encode(pem.as_bytes());
237
238 // Correlation id is 20 lowercase alphanumerics - interactsh-server
239 // matches incoming subdomains by this prefix, so the ID space must
240 // be wide enough that collisions are statistically impossible across
241 // every concurrent scanner sharing the collector. 36^20 ≈ 1.3e31.
242 let correlation_id: String = OsRng
243 .sample_iter(&Alphanumeric)
244 .take(20)
245 .map(|b| (b as char).to_ascii_lowercase())
246 .collect();
247 let secret_key = uuid::Uuid::new_v4().to_string();
248
249 let server = normalize_server(server);
250
251 // SSRF gate (kimi verifier audit MEDIUM finding). The OOB collector
252 // traffic - register/poll/deregister - does NOT flow through
253 // `resolved_client_for_url`, which is the only place the verify path
254 // enforces private-IP / DNS-rebinding protection. Without a gate here,
255 // `--oob-server 169.254.169.254` (cloud metadata), `127.0.0.1`, or any
256 // RFC1918 host turns the unattended poller into an SSRF primitive that
257 // also leaks the session secret (embedded in the poll query string) to
258 // an internal service. We validate the collector host BEFORE the first
259 // request: first the URL-string check (literal IPs, integer/hex/octal
260 // encodings, `localhost`/`.internal`/`.local`), then a post-resolution
261 // IP check so a hostname that resolves to a private address - the DNS
262 // rebinding case a 'trusted' hostname cannot rule out - is refused too.
263 // The check is a pure validation: re-running it is a no-op, so the fix
264 // is idempotent.
265 ssrf_check_collector(&server).await?;
266
267 let body = RegisterRequest {
268 public_key: &public_key_b64,
269 secret_key: &secret_key,
270 correlation_id: &correlation_id,
271 };
272 // SECURITY/POLITENESS: kimi verifier audit LOW finding. Every OOB
273 // request - register, poll, deregister - shares the same upstream
274 // interactsh collector. Without rate limiting, a scan that fires
275 // 200 detector-verify subscriptions in parallel would hammer the
276 // collector with 200 register calls in flight at once, get IP-banned,
277 // and silently lose all OOB observability for the rest of the run.
278 // We bucket every OOB call under a single service id so the global
279 // limiter (default 5 rps) governs the aggregate.
280 crate::rate_limit::get_rate_limiter()
281 .wait(OOB_SERVICE)
282 .await;
283 let resp = http
284 .post(format!("{server}/register"))
285 .json(&body)
286 .send()
287 .await?;
288 let status = resp.status();
289 if !status.is_success() {
290 let body = read_capped_text(resp, ERROR_BODY_CAP).await;
291 return Err(InteractshError::Register {
292 status: status.as_u16(),
293 body: body.chars().take(256).collect(),
294 });
295 }
296 // Drain (and discard) the register success body under a cap. Some
297 // interactsh deployments echo registration metadata; we don't need
298 // it but must not let the connection sit half-read indefinitely.
299 let _ = read_capped_bytes(resp, ERROR_BODY_CAP).await;
300 debug!(target: "keyhog::oob", correlation_id = %correlation_id, server = %server, "registered with interactsh collector");
301
302 Ok(Self {
303 http,
304 server,
305 correlation_id,
306 secret_key,
307 private_key,
308 suffix_len: 13,
309 })
310 }
311
312 /// Mint a fresh callback URL bound to this session. The full 33-char
313 /// subdomain is returned (unique-id) plus the host the service should
314 /// hit. Caller is responsible for embedding it where the credential's
315 /// API will follow.
316 pub fn mint_url(&self) -> MintedUrl {
317 let suffix: String = OsRng
318 .sample_iter(&Alphanumeric)
319 .take(self.suffix_len)
320 .map(|b| (b as char).to_ascii_lowercase())
321 .collect();
322 let unique_id = format!("{}{}", self.correlation_id, suffix);
323 let host = format!("{}.{}", unique_id, self.server_host());
324 let url = format!("https://{host}");
325 MintedUrl {
326 unique_id,
327 host,
328 url,
329 }
330 }
331
332 /// Poll once. Returns every interaction the collector has buffered for
333 /// this correlation id since the last poll.
334 pub async fn poll(&self) -> Result<Vec<Interaction>, InteractshError> {
335 // See `register` for the rate-limiter rationale - same bucket so all
336 // OOB traffic to the collector aggregates under one budget.
337 crate::rate_limit::get_rate_limiter()
338 .wait(OOB_SERVICE)
339 .await;
340 let resp = self
341 .http
342 .get(format!("{}/poll", self.server))
343 .query(&[("id", &self.correlation_id), ("secret", &self.secret_key)])
344 .send()
345 .await?;
346 let status = resp.status();
347 if !status.is_success() {
348 let body = read_capped_text(resp, ERROR_BODY_CAP).await;
349 return Err(InteractshError::Poll {
350 status: status.as_u16(),
351 body: body.chars().take(256).collect(),
352 });
353 }
354 // Bound the response body before deserialization. A malicious or
355 // misbehaving collector could otherwise blow process memory by
356 // returning a multi-gigabyte JSON. 4 MiB comfortably fits even a
357 // dense poll batch (≤100 interactions × ~16 KiB raw_payload each
358 // base64-expanded ≈ 2 MiB) with headroom.
359 let body = read_capped_bytes(resp, MAX_POLL_BODY_BYTES).await?;
360 let parsed: PollResponse = serde_json::from_slice(&body)
361 .map_err(|e| InteractshError::BadResponse(e.to_string()))?;
362 if parsed.data.is_empty() {
363 return Ok(Vec::new());
364 }
365 let aes_key_b64 = parsed.aes_key.ok_or_else(|| {
366 InteractshError::BadResponse("data present but aes_key missing".into())
367 })?;
368 let aes_key = self.unwrap_aes_key(&aes_key_b64)?;
369 if aes_key.len() != 32 {
370 return Err(InteractshError::AesUnwrap(format!(
371 "expected 32-byte AES-256 key, got {}",
372 aes_key.len()
373 )));
374 }
375
376 let mut out = Vec::with_capacity(parsed.data.len());
377 for entry in parsed.data {
378 match super::decrypt::decrypt_entry(&aes_key, &entry) {
379 Ok(Some(interaction)) => out.push(interaction),
380 Ok(None) => {} // unparseable JSON - skip, don't fail the batch
381 Err(e) => {
382 warn!(target: "keyhog::oob", error = %e, "interactsh entry decrypt failed; skipping")
383 }
384 }
385 }
386 Ok(out)
387 }
388
389 /// Tear down the registration. Idempotent on the server side; a failure
390 /// to deregister is non-fatal - the server prunes inactive sessions
391 /// after its retention window.
392 pub async fn deregister(&self) -> Result<(), InteractshError> {
393 #[derive(Serialize)]
394 struct DeregisterRequest<'a> {
395 #[serde(rename = "correlation-id")]
396 correlation_id: &'a str,
397 #[serde(rename = "secret-key")]
398 secret_key: &'a str,
399 }
400 // See `register` for the rate-limiter rationale.
401 crate::rate_limit::get_rate_limiter()
402 .wait(OOB_SERVICE)
403 .await;
404 let _ = self
405 .http
406 .post(format!("{}/deregister", self.server))
407 .json(&DeregisterRequest {
408 correlation_id: &self.correlation_id,
409 secret_key: &self.secret_key,
410 })
411 .send()
412 .await?;
413 Ok(())
414 }
415
416 pub fn correlation_id(&self) -> &str {
417 &self.correlation_id
418 }
419
420 /// `oast.fun` from `https://oast.fun/`.
421 fn server_host(&self) -> &str {
422 // strip scheme; we normalized at register time so no path component.
423 self.server
424 .split_once("://")
425 .map(|(_, rest)| rest)
426 .unwrap_or(&self.server)
427 .trim_end_matches('/')
428 }
429
430 fn unwrap_aes_key(&self, b64: &str) -> Result<Vec<u8>, InteractshError> {
431 let wrapped = B64
432 .decode(b64.as_bytes())
433 .map_err(|e| InteractshError::AesUnwrap(format!("base64: {e}")))?;
434 let padding = Oaep::new::<Sha256>();
435 self.private_key
436 .decrypt(padding, &wrapped)
437 .map_err(|e| InteractshError::AesUnwrap(format!("rsa-oaep: {e}")))
438 }
439}
440
441/// One per-finding callback URL, returned from `InteractshClient::mint_url`.
442#[derive(Debug, Clone)]
443pub struct MintedUrl {
444 /// Full 33-char id; the value the service will reflect in DNS/HTTP host.
445 pub unique_id: String,
446 /// `<unique_id>.<server-host>` - bare host without scheme.
447 pub host: String,
448 /// `https://<host>` - convenience for HTTP-shaped probes.
449 pub url: String,
450}
451
452/// SSRF guard for the OOB collector host. Mirrors the protection
453/// `resolved_client_for_url` applies to every credential-verify URL, which the
454/// OOB register/poll/deregister path otherwise bypasses entirely.
455///
456/// Two layers, both pure validation (idempotent - re-running refuses or passes
457/// identically with no side effects):
458///
459/// 1. URL-string check (`is_private_url`): rejects literal private/loopback/
460/// link-local/multicast IPs, integer/hex/octal-encoded IPs, and the
461/// `localhost` / `.internal` / `.local` / `.localdomain` suffixes before any
462/// network I/O.
463/// 2. Post-resolution IP check: resolves the host once and rejects if ANY
464/// answer is a private address. This is the DNS-rebinding defense - a
465/// hostname an operator believes is a benign collector can still resolve to
466/// `169.254.169.254` or `127.0.0.1`, and the unattended poller embeds the
467/// session secret in its query string, so an internal target is both an
468/// SSRF and a secret-disclosure sink.
469///
470/// `normalize_server` has already force-upgraded to `https://` and stripped any
471/// trailing slash, so `server` here is `https://<host>` with no path.
472async fn ssrf_check_collector(server: &str) -> Result<(), InteractshError> {
473 if crate::ssrf::is_private_url(server) {
474 return Err(InteractshError::BlockedCollector(format!(
475 "{server} resolves to a private/loopback/link-local address"
476 )));
477 }
478
479 // Resolve once and re-check every answer to defeat DNS rebinding. A
480 // resolution failure here is NOT fatal: the subsequent register POST will
481 // surface the real transport/DNS error with the engine's own diagnostics.
482 // We only refuse when we positively observe a private resolved IP.
483 let url = match url::Url::parse(server) {
484 Ok(u) => u,
485 // `normalize_server` cannot produce an unparseable URL, but if it
486 // somehow does, refuse rather than fall through to an unchecked POST.
487 Err(_) => {
488 return Err(InteractshError::BlockedCollector(format!(
489 "{server} is not a parseable collector URL"
490 )))
491 }
492 };
493 if let Some(host) = url.host_str() {
494 let port = url.port_or_known_default().unwrap_or(443);
495 if let Ok(addrs) = crate::ssrf::resolve_dns_cached(&format!("{host}:{port}")).await {
496 if addrs
497 .iter()
498 .any(|addr| crate::ssrf::is_private_ip_addr(&addr.ip()))
499 {
500 return Err(InteractshError::BlockedCollector(format!(
501 "{server} resolves to a private/loopback/link-local address"
502 )));
503 }
504 }
505 }
506 Ok(())
507}
508
509/// Accept `oast.fun`, `oast.fun/`, `https://oast.fun`, `https://oast.fun/`.
510/// Always return `https://<host>` with no trailing slash. HTTP-only is
511/// rejected because the AES key flowing back must travel TLS-wrapped.
512fn normalize_server(s: &str) -> String {
513 let s = s.trim().trim_end_matches('/');
514 if let Some(rest) = s.strip_prefix("http://") {
515 // Force-upgrade. We never speak plaintext to a collector - the
516 // wrapped AES key would leak otherwise.
517 format!("https://{rest}")
518 } else if s.starts_with("https://") {
519 s.to_string()
520 } else {
521 format!("https://{s}")
522 }
523}