feat(deauth,evilportal,capture): bulk deauth UX, Hak5-compatible Evil Portal, monitor capture fixes

- Recon AP focus sidebar: 'Deauth All Clients' with engagement-scope confirm
- Deauth Targeting card: 'Deauth All' behind the same scope confirmation
- New POST /api/attacks/deauth/bulk (max 32 targets, per-target results)
- Evil Portal tab: import EvilPortalNano-format portal zips into
  /mmc/mk8/portals, serve active portal on port 80 to unauthenticated
  clients via a minimal PHP shim, capture all form POSTs (.logs in stock
  MyPortal.php format + captures.jsonl), dnsmasq address=/#/ DNS hijack
- OpenAP: Evil Portal template dropdown (greyed when none), activated with
  the attack and stopped with it
- Monitor Capture fix: iface-less status now reports whichever monitor is
  actually capturing; pcap dir mkdir'd; tcpdump stderr surfaced instead of
  discarded
This commit is contained in:
c4ch3c4d3
2026-08-23 19:50:41 -06:00
parent f9eccd8030
commit 88d7141d45
7 changed files with 1304 additions and 29 deletions
@@ -684,6 +684,7 @@ views.pineap_open = attackLauncher('open', {
title: 'OpenAP',
bssid: true,
country: true,
portal: true,
tabHash: '#/pineap/open'
});
@@ -1186,6 +1187,9 @@ function attackLauncher(kind, opts) {
text: 'Prefilled from Recon (' + (prefill.source || 'target') + '). Set the passphrase, verify the settings, then Deploy.' }));
}
let portalSel = null;
if (opts.portal) portalSel = h('select', { id: 'atk-portal' });
f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' },
h('div', {}, (function () {
const deployBtn = btn('Deploy Attack', () => {
@@ -1197,6 +1201,7 @@ function attackLauncher(kind, opts) {
if (pskIn) { body.passphrase = pskIn.value; body.enctype = encSel.value; }
if (bssidIn) body.bssid = bssidIn.value.trim();
if (coSel) body.country = coSel.value;
if (portalSel) body.portal = portalSel.value || '';
runAction(deployBtn, () => PagerAPI.post('/api/attacks/deploy', body)
.then((r) => { verifiedToast(r.data || {}); load(); }), 'Deploying…');
});
@@ -1215,6 +1220,34 @@ function attackLauncher(kind, opts) {
const status = attackStatusCard();
box.appendChild(status.card);
if (opts.portal) {
const pCard = h('div', { class: 'pineap-title-card' });
pCard.appendChild(h('div', { class: 'pineap-card-title' }, 'Evil Portal'));
const pBody = h('div', { style: 'font-size:13px' });
pCard.appendChild(pBody);
box.appendChild(pCard);
PagerAPI.get('/api/portals').then((r) => {
const d = r.data || {};
const portals = d.portals || [];
portalSel.innerHTML = '';
if (!portals.length) {
portalSel.disabled = true;
portalSel.appendChild(h('option', { value: '', text: 'No portal templates imported' }));
pBody.appendChild(h('label', {}, 'Portal template', portalSel));
pBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px',
text: 'Import a Hak5-format portal zip in the Evil Portal tab to enable credential capture.' }));
return;
}
portalSel.appendChild(h('option', { value: '', text: 'None' }));
portals.forEach((p) => portalSel.appendChild(
h('option', { value: p.name, text: p.name + (p.name === d.active ? ' (active)' : '') })));
if (d.active && portals.some((p) => p.name === d.active)) portalSel.value = d.active;
pBody.appendChild(h('label', {}, 'Portal template', portalSel));
pBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px',
text: 'The selected portal is activated when this attack deploys and stopped with it.' }));
}).catch(() => {});
}
const hsBox = h('div', {});
const captureBox = h('div', { class: 'pineap-title-card' });
captureBox.appendChild(h('div', { class: 'pineap-card-title' }, 'Monitor Capture'));
@@ -1226,6 +1259,9 @@ function attackLauncher(kind, opts) {
function capRow(st) {
capBody.innerHTML = '';
const run = !!(st && st.running);
// Adopt the iface the backend reports as actually capturing: the
// status poll may target a different monitor than the form default.
if (run && st.iface) capIface = st.iface;
capBody.appendChild(h('div', { class: 'row' },
h('span', { text: run ? ('Capturing on ' + (st.iface || capIface)) : 'Not capturing' }),
h('div', {}, (function () {
@@ -1384,6 +1420,24 @@ function deauthPanel(ssidRef) {
const apSel = h('select', {});
const clTable = h('div', {});
let lastLookup = '';
let lastClients = [];
const deauthAllBtn = btn('Deauth All', () => {
const parts = apSel.value.split('|');
if (!lastClients.length) { App.toast('No devices to deauth — run Find first', 'error'); return; }
if (!parts[0]) { App.toast('Pick an AP first', 'error'); return; }
if (!window.confirm('Deauthenticate ALL ' + lastClients.length +
' listed device(s) against ' + parts[0] + '?\n\nConfirm this target is IN SCOPE for your engagement.')) return;
runAction(deauthAllBtn, () => PagerAPI.post('/api/attacks/deauth/bulk', {
targets: lastClients.map((mac) => ({
bssid: parts[0], client: mac,
channel: parseInt(parts[1], 10) || null
}))
}).then((r) => {
const d = r.data || {};
App.toast('Deauth frames sent to ' + (d.sent != null ? d.sent : '?') +
'/' + ((d.results || []).length) + ' devices');
}), 'Sending…');
}, 'danger');
function lookup(q) {
if (!q || q === lastLookup) return Promise.resolve();
lastLookup = q;
@@ -1397,6 +1451,7 @@ function deauthPanel(ssidRef) {
if (!(d.aps || []).length) apSel.appendChild(h('option', { value: '|1', text: 'No APs found — check SSID' }));
clTable.innerHTML = '';
const cl = (d.clients || []).slice(0, 30);
lastClients = cl.map((c) => c.mac || c.client_mac || '').filter(Boolean);
if (!cl.length) {
clTable.appendChild(h('div', { class: 'empty', text: 'No devices in recon yet.' }));
return;
@@ -1431,8 +1486,10 @@ function deauthPanel(ssidRef) {
})())));
body.appendChild(apSel);
body.appendChild(clTable);
body.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px',
text: 'Only deauth targets you are authorized to test.' }));
body.appendChild(h('div', { class: 'row', style: 'margin-top:6px' },
h('div', {}, deauthAllBtn),
h('div', { class: 'muted', style: 'font-size:12px;align-self:center',
text: 'Only deauth targets you are authorized to test.' })));
if (ssidRef) {
ssidRef.tick = () => {
const liveSsid = ssidRef.current && ssidRef.current.trim();
@@ -2113,6 +2170,26 @@ views.recon = (root) => {
.then(() => App.toast('Examining channel ' + ap.channel + ' — check the Pager screen')), 'Examining…');
});
actions.appendChild(exC);
const focusClients = (ap.clients || []).filter((client) => client && client.mac);
if (focusClients.length && ap.bssid) {
const deauthAll = h('button', { class: 'btn danger recon-focus-action-button',
text: 'Deauth All Clients (' + focusClients.length + ')' });
deauthAll.addEventListener('click', () => {
const ssidLabel = ap.ssid || 'hidden network';
if (!window.confirm('Deauthenticate ' + focusClients.length + ' client(s) of "' +
ssidLabel + '"?\n\nConfirm this target is IN SCOPE for your engagement.')) return;
runAction(deauthAll, () => PagerAPI.post('/api/attacks/deauth/bulk', {
targets: focusClients.map((c) => ({
bssid: ap.bssid, client: c.mac, channel: ap.channel == null ? null : ap.channel
}))
}).then((r) => {
const d = r.data || {};
App.toast('Deauth frames sent to ' + (d.sent != null ? d.sent : '?') +
'/' + ((d.results || []).length) + ' clients');
}), 'Sending…');
});
actions.appendChild(deauthAll);
}
const details = h('div', { class: 'recon-focus-body' });
focusSidebar.appendChild(details);
@@ -4045,3 +4122,175 @@ views.settings_help = (root) => {
license.appendChild(h('p', { class: 'muted', text: 'This community WebUI runs alongside the licensed WiFi Pineapple Pager firmware. Third-party component notices remain available in their distributed source files.' }));
return { destroy: () => {} };
};
// ---------------------------------------------------------------------------
// Evil Portal — Hak5 EvilPortalNano-compatible captive portal manager.
// ---------------------------------------------------------------------------
views.evilportal = (root) => {
root.appendChild(h('h1', { class: 'page-title', text: 'Evil Portal' }));
const box = h('div', { style: 'display:flex;flex-direction:column;gap:16px' });
root.appendChild(box);
function portalCard(title) {
const card = h('div', { class: 'pineap-title-card' });
card.appendChild(h('div', { class: 'pineap-card-title' }, title));
box.appendChild(card);
return card;
}
// ---- Active portal status ----
const statusCard = portalCard('Active Portal');
const statusBody = h('div', { style: 'font-size:13px;line-height:1.9' });
statusCard.appendChild(statusBody);
let activeName = null;
// ---- Templates ----
const tplCard = portalCard('Portal Templates');
const tplBody = h('div', { style: 'font-size:13px' });
tplCard.appendChild(tplBody);
// ---- Import ----
const importCard = portalCard('Import Portal');
const importBody = h('div', { style: 'font-size:13px' });
importCard.appendChild(importBody);
const nameIn = h('input', { placeholder: 'Portal name (optional override)' });
const fileIn = h('input', { type: 'file', accept: '.zip,application/zip' });
importBody.appendChild(h('div', { class: 'row' }, nameIn,
h('div', {}, fileIn)));
importBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px',
text: 'Upload a zip of a Hak5 Evil Portal (index.php + assets). Compatible with kleo/evilportals and other EvilPortalNano portals.' }));
function refresh() {
return PagerAPI.get('/api/portals').then((r) => {
const d = r.data || {};
activeName = d.active || null;
renderStatus();
renderTemplates(d.portals || []);
}).catch(() => App.toast('Failed to load portals', 'error'));
}
function renderStatus() {
statusBody.innerHTML = '';
const on = !!activeName;
statusBody.appendChild(h('div', { class: 'row' },
h('div', { style: 'min-width:130px', text: 'Status' }),
badge(on)));
statusBody.appendChild(h('div', { class: 'row' },
h('div', { style: 'min-width:130px', text: 'Portal' }),
h('span', { text: on ? activeName : '—' })));
if (on) {
statusBody.appendChild(h('div', { class: 'row' },
h('div', { style: 'min-width:130px', text: '' }),
h('span', { class: 'muted', style: 'font-size:12px',
text: 'Serving on http://<device-ip>/ with DNS hijack (all hostnames resolve to the Pager).' })));
statusBody.appendChild(h('div', { class: 'row' },
h('div', { style: 'min-width:130px', text: '' }),
h('div', {}, btn('Stop Portal', () => {
runAction(null, () => PagerAPI.post('/api/portals/' + encodeURIComponent(activeName) + '/deactivate', {})
.then(refresh), 'Stopping…');
}, 'danger'))));
}
}
function renderTemplates(portals) {
tplBody.innerHTML = '';
if (!portals.length) {
tplBody.appendChild(h('div', { class: 'empty',
text: 'No portal templates imported. Import a zip below to get started.' }));
return;
}
tplBody.appendChild(table(
[{ key: 'name', label: 'Name' }, { key: 'size', label: 'Size' },
{ key: 'captures', label: 'Captures' }, { key: '_actions', label: '' }],
portals.map((p) => ({
name: p.name, size: fmtBytes(p.bytes), captures: String(p.captures || 0),
_actions: (function () {
const wrapRow = h('div', { style: 'display:flex;gap:6px' });
if (p.name === activeName) {
wrapRow.appendChild(btn('Stop', () => {
runAction(null, () => PagerAPI.post('/api/portals/' + encodeURIComponent(p.name) + '/deactivate', {})
.then(refresh), 'Stopping…');
}, 'danger'));
} else {
wrapRow.appendChild(btn('Activate', () => {
runAction(null, () => PagerAPI.post('/api/portals/' + encodeURIComponent(p.name) + '/activate', {})
.then(() => App.toast('Portal active — DNS hijack on'))
.then(refresh), 'Activating…');
}, 'danger'));
}
wrapRow.appendChild(btn('Download', () => {
window.open('/api/portals/' + encodeURIComponent(p.name) + '/download', '_blank');
}, 'ghost'));
wrapRow.appendChild(btn('Delete', () => {
if (!window.confirm('Delete portal "' + p.name + '"?')) return;
runAction(null, () => PagerAPI.del('/api/portals/' + encodeURIComponent(p.name))
.then(refresh), 'Deleting…');
}, 'danger'));
return wrapRow;
})()
}))));
}
fileIn.addEventListener('change', () => {
const f = fileIn.files && fileIn.files[0];
fileIn.value = '';
if (!f) return;
if (f.size > 10 * 1024 * 1024) { App.toast('Zip too large (max 10 MB)', 'error'); return; }
const fr = new FileReader();
fr.onload = () => {
const b64 = String(fr.result).split(',')[1] || '';
runAction(null, () => PagerAPI.post('/api/portals/import', {
name: nameIn.value.trim() || undefined, data: b64
}).then((r) => {
App.toast('Imported portal "' + ((r.data || {}).name || '?') + '"');
nameIn.value = '';
return refresh();
}), 'Importing…');
};
fr.readAsDataURL(f);
});
// ---- Captured credentials ----
const capCard = portalCard('Captured Credentials');
const capBody = h('div', { style: 'font-size:13px' });
capCard.appendChild(capBody);
function loadCaptures() {
return PagerAPI.get('/api/portals/captures?limit=200').then((r) => {
const d = r.data || {};
const caps = d.captures || [];
capBody.innerHTML = '';
capBody.appendChild(h('div', { class: 'row' },
h('span', { text: caps.length ? (caps.length + ' capture(s)' +
(d.total > caps.length ? ' (of ' + d.total + ')' : '')) : 'No credentials captured yet.' }),
h('div', {},
btn('Refresh', () => { loadCaptures(); }, 'ghost'),
caps.length ? btn('Clear All', () => {
if (!window.confirm('Delete ALL captured credentials?')) return;
runAction(null, () => PagerAPI.del('/api/portals/captures')
.then(loadCaptures), 'Clearing…');
}, 'danger') : null,
caps.length ? h('a', { class: 'btn ghost', href: '#', onclick: (e) => {
e.preventDefault();
downloadText('evil-portal-captures.json', JSON.stringify(caps, null, 2));
}, text: 'Export JSON', style: 'text-decoration:none' }) : null)));
if (!caps.length) return;
capBody.appendChild(table(
[{ key: 'when', label: 'When' }, { key: 'portal', label: 'Portal' },
{ key: 'ident', label: 'Client' }, { key: 'creds', label: 'Fields' }],
caps.map((c) => ({
when: c.time || fmtTime(c.ts),
portal: c.portal || '—',
ident: [c.mac, c.ip, c.hostname].filter(Boolean).join(' · ') || '—',
creds: Object.keys(c.fields || {}).map((k) =>
k + ': ' + String(c.fields[k]).slice(0, 40)).join(' | ') || '(no fields)'
}))));
}).catch(() => {});
}
refresh();
loadCaptures();
const iv = setInterval(loadCaptures, 10000);
return { destroy: () => clearInterval(iv) };
};