Skip to main content

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}