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:
@@ -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) };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user