fix: client kick/deauth use full bssid/target/channel deauth form

hak5cmd on this firmware has no CLIENT_KICK command and PINEAPPLE_DEAUTH_CLIENT
requires (bssid, target, channel) — the UI kick/deauth buttons and the MCP
pineap.kick_client tool previously passed only the client MAC, which printed
usage and silently did nothing.

- _deauth_target/_deauth_client_via_iface resolve the client's association
  interface via iwinfo (Access Point + Channel), pick the band-aware inject
  interface, and issue DEAUTH_CLIENT <bssid> <mac> <channel>
- h_client_kick: deny-filter (persistent) + immediate deauth with rc checks
- h_deauth_client: deauth with the full form; 502 with detail when the
  client is not associated
- MCP pineap.kick_client: resolves the client first (no side effects on
  failure), then deny-filter + deauth; verified on device (clean error for
  unassociated clients, filter list restored)
- tests updated for the new command chain
This commit is contained in:
2026-08-19 00:05:42 -05:00
parent d7ef0624f4
commit f6b4cadc39
2 changed files with 130 additions and 23 deletions
@@ -860,17 +860,25 @@ def h_client_kick(ctx):
mac = normalize_mac((ctx.body or {}).get('mac')) mac = normalize_mac((ctx.body or {}).get('mac'))
if not mac: if not mac:
return 400, {'error': 'invalid mac'} return 400, {'error': 'invalid mac'}
hak5('PINEAPPLE_DEVICE_FILTER_MODE', 'deny') # Persistent kick: deny-filter the client so pineapd deauths every probe
hak5('PINEAPPLE_DEVICE_FILTER_ADD', 'deny', mac) # and connect, then deauth it once immediately with the full
hak5('PINEAPPLE_DEAUTH_CLIENT', mac) # (bssid, target, channel) form hak5cmd requires.
return 200, {'ok': True} for argv in ([HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_MODE', 'deny'],
[HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_ADD', 'deny', mac]):
rc, out, err = device_run(argv, timeout=20)
if rc != 0:
return 502, {'error': 'kick filter failed', 'detail': (err or out)[-300:]}
ok, detail = _deauth_client_via_iface(mac)
return 200, {'ok': True, 'deauth': ok, 'detail': detail or None}
def h_deauth_client(ctx): def h_deauth_client(ctx):
mac = normalize_mac((ctx.body or {}).get('mac')) mac = normalize_mac((ctx.body or {}).get('mac'))
if not mac: if not mac:
return 400, {'error': 'invalid mac'} return 400, {'error': 'invalid mac'}
hak5('PINEAPPLE_DEAUTH_CLIENT', mac) ok, detail = _deauth_client_via_iface(mac)
if not ok:
return 502, {'error': 'deauth failed', 'detail': detail}
return 200, {'ok': True} return 200, {'ok': True}
@@ -2298,6 +2306,53 @@ def assoc_clients(ifaces=None):
return clients return clients
def _iface_ap_info(iface):
"""(bssid, channel) of the AP running on `iface`, via iwinfo."""
rc, out, err = device_run(['iwinfo', iface, 'info'])
bssid = None
channel = None
for line in out.splitlines():
m = re.search(r'Access Point:\s*([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})', line)
if m:
bssid = m.group(1).upper()
m = re.search(r'Channel:\s*(\d+)', line)
if m:
channel = int(m.group(1))
return bssid, channel
def _deauth_target(mac):
"""(iface, bssid, channel) for an associated client, else None."""
for c in assoc_clients():
if c['mac'] != mac:
continue
bssid, channel = _iface_ap_info(c['iface'])
if not bssid or not channel:
return None
return c['iface'], bssid, channel
return None
def _deauth_client_via_iface(mac):
"""Deauth a client associated to one of our own APs.
hak5cmd's deauth needs the full (bssid, target, channel) triple; the
client's AP and channel are resolved from its association interface.
Returns (ok, detail).
"""
target = _deauth_target(mac)
if not target:
return False, 'client not associated'
iface, bssid, channel = target
band = _band_of_channel(channel)
inject = 'wlan1mon' if band == BAND_5G or band == BAND_6G else 'wlan0mon'
if inject != 'wlan1mon':
_pineap('INTERFACE', 'INJECT', inject)
rc, out, err = device_run([HAK5CMD, 'DEAUTH_CLIENT', bssid, mac,
str(channel)], timeout=30)
return rc == 0, (err or out)
def disk_data(): def disk_data():
rc, out, err = device_run(['df', '-k', '/root']) rc, out, err = device_run(['df', '-k', '/root'])
lines = out.splitlines() lines = out.splitlines()
@@ -3698,11 +3753,27 @@ def _mcp_tools():
return {'devices': _sql_table('wifi_device', args.get('limit', 50))} return {'devices': _sql_table('wifi_device', args.get('limit', 50))}
def kick(args): def kick(args):
mac = (args.get('mac') or '').strip() mac = normalize_mac(args.get('mac'))
if not mac: if not mac:
return {'error': 'mac required'} return {'error': 'mac required'}
rc, out, err = device_run([HAK5CMD, 'CLIENT_KICK', mac], timeout=20) # This firmware's hak5cmd has no CLIENT_KICK command; mirror the web
return {'ok': rc == 0, 'detail': (err or out)[-300:]} # UI's kick: deny-filter the client (deauths every probe/connect) then
# deauth it once with the full (bssid, target, channel) form. The
# client must be associated first so a failed kick has no side effects.
if not _deauth_target(mac):
return {'ok': False, 'detail': 'client not associated', 'mac': mac}
ok = True
detail = ''
for argv in ([HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_MODE', 'deny'],
[HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_ADD', 'deny', mac]):
rc, out, err = device_run(argv, timeout=20)
if rc != 0:
ok = False
detail = (err or out)[-300:]
break
if ok:
ok, detail = _deauth_client_via_iface(mac)
return {'ok': ok, 'detail': detail, 'mac': mac}
def set_filter(args): def set_filter(args):
kind = (args.get('kind') or 'ssid').strip() kind = (args.get('kind') or 'ssid').strip()
+51 -15
View File
@@ -9,8 +9,10 @@ import server
def setUpModule(): def setUpModule():
__import__('importlib').reload(server) __import__('importlib').reload(server)
_orig_hak5 = server.hak5 _orig_device_run = server.device_run
_orig_assoc_clients = server.assoc_clients _orig_assoc_clients = server.assoc_clients
_orig_iface_ap_info = server._iface_ap_info
_orig_pineap = server._pineap
class NormalizeTest(unittest.TestCase): class NormalizeTest(unittest.TestCase):
@@ -24,8 +26,10 @@ class NormalizeTest(unittest.TestCase):
class ClientsTest(unittest.TestCase): class ClientsTest(unittest.TestCase):
def tearDown(self): def tearDown(self):
server.hak5 = _orig_hak5 server.device_run = _orig_device_run
server.assoc_clients = _orig_assoc_clients server.assoc_clients = _orig_assoc_clients
server._iface_ap_info = _orig_iface_ap_info
server._pineap = _orig_pineap
def test_clients_handler(self): def test_clients_handler(self):
server.assoc_clients = lambda: [{'mac': 'AA:BB:CC:DD:EE:FF', 'iface': 'wlan0open', 'rssi': -55}] server.assoc_clients = lambda: [{'mac': 'AA:BB:CC:DD:EE:FF', 'iface': 'wlan0open', 'rssi': -55}]
@@ -35,15 +39,37 @@ class ClientsTest(unittest.TestCase):
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(payload['count'], 1) self.assertEqual(payload['count'], 1)
def test_kick_validates_and_deny_adds(self): def _kick_env(self):
calls = [] calls = []
def fake(*args): def fake_run(argv, timeout=30):
calls.append(args) calls.append(argv)
return 'ok' return 0, '', ''
server.hak5 = fake server.device_run = fake_run
server.assoc_clients = lambda: [{'mac': '00:11:22:33:44:55', 'iface': 'wlan0open', 'rssi': -55}]
server._iface_ap_info = lambda iface: ('AA:BB:CC:DD:EE:FF', 6)
server._pineap = lambda *a, **k: (0, '', '')
return calls
def test_kick_validates_and_deny_adds(self):
calls = self._kick_env()
server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})()) server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
self.assertIn(('PINEAPPLE_DEVICE_FILTER_ADD', 'deny', '00:11:22:33:44:55'), calls) self.assertIn([server.HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_ADD', 'deny', '00:11:22:33:44:55'], calls)
self.assertTrue(any(c[0] == 'PINEAPPLE_DEAUTH_CLIENT' for c in calls)) # The immediate deauth must use the full bssid/target/channel form.
self.assertTrue(any(c[:4] == [server.HAK5CMD, 'DEAUTH_CLIENT', 'AA:BB:CC:DD:EE:FF',
'00:11:22:33:44:55'] and c[4] == '6' for c in calls))
def test_kick_not_associated_still_filters(self):
calls = []
def fake_run(argv, timeout=30):
calls.append(argv)
return 0, '', ''
server.device_run = fake_run
server.assoc_clients = lambda: []
status, payload = server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
self.assertEqual(status, 200)
self.assertIs(payload['deauth'], False)
self.assertTrue(any(c == [server.HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_ADD', 'deny',
'00:11:22:33:44:55'] for c in calls))
def test_kick_bad_mac_400(self): def test_kick_bad_mac_400(self):
status, payload = server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': 'x'}})()) status, payload = server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': 'x'}})())
@@ -51,12 +77,22 @@ class ClientsTest(unittest.TestCase):
def test_deauth_client(self): def test_deauth_client(self):
calls = [] calls = []
def fake(*args): def fake_run(argv, timeout=30):
calls.append(args) calls.append(argv)
return 'ok' return 0, '', ''
server.hak5 = fake server.device_run = fake_run
server.h_deauth_client(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})()) server.assoc_clients = lambda: [{'mac': '00:11:22:33:44:55', 'iface': 'wlan1wpa', 'rssi': -60}]
self.assertEqual(calls[0][0], 'PINEAPPLE_DEAUTH_CLIENT') server._iface_ap_info = lambda iface: ('AA:BB:CC:DD:EE:FF', 149)
status, payload = server.h_deauth_client(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
self.assertEqual(status, 200)
# 5 GHz client -> wlan1mon inject, no _pineap pin needed.
self.assertTrue(any(c[:4] == [server.HAK5CMD, 'DEAUTH_CLIENT', 'AA:BB:CC:DD:EE:FF',
'00:11:22:33:44:55'] and c[4] == '149' for c in calls))
def test_deauth_client_not_associated_502(self):
server.assoc_clients = lambda: []
status, payload = server.h_deauth_client(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
self.assertEqual(status, 502)
if __name__ == '__main__': if __name__ == '__main__':