projects/vpn: Heron rootlos auf macOS (GlobalProtect/Duo-SAML via WKWebView + HIP), Reiher-Icon
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PoC: GlobalProtect SAML-POST (Duo) auf macOS rootlos.
|
||||
|
||||
Holt den GP-Prelogin, fuehrt das Duo-SAML in einem eingebetteten WKWebView durch,
|
||||
greift den `prelogin-cookie`/`saml-username` aus den HTTP-Antwort-Headern ab
|
||||
(was QtWebEngine nicht kann, das native WKWebView ueber den Navigation-Response-
|
||||
Delegate aber schon) und startet damit openconnect rootlos ueber ocproxy.
|
||||
|
||||
Start: /Library/Developer/CommandLineTools/usr/bin/python3 ~/apps/heron_gp_saml.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import objc
|
||||
from Cocoa import (
|
||||
NSApplication, NSObject, NSWindow, NSURL, NSMakeRect,
|
||||
NSBackingStoreBuffered,
|
||||
)
|
||||
from Foundation import NSHTTPURLResponse, NSHTTPCookie
|
||||
from WebKit import WKWebView, WKWebViewConfiguration
|
||||
|
||||
SERVER = "ssl.heron.at"
|
||||
PORT = 11090
|
||||
GP_UA = "PAN GlobalProtect" # GP liefert den prelogin-cookie nur dem Client-UA
|
||||
NSWindowStyleMaskTitled = 1
|
||||
NSWindowStyleMaskClosable = 2
|
||||
NSWindowStyleMaskResizable = 8
|
||||
WKNavigationResponsePolicyAllow = 1
|
||||
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
|
||||
def _tool(n: str) -> str:
|
||||
return shutil.which(n) or (
|
||||
f"/opt/homebrew/bin/{n}" if os.path.exists(f"/opt/homebrew/bin/{n}") else n
|
||||
)
|
||||
|
||||
|
||||
def _hipreport() -> str | None:
|
||||
import glob
|
||||
for p in ["/opt/homebrew/opt/openconnect/libexec/openconnect/hipreport.sh",
|
||||
*glob.glob("/opt/homebrew/Cellar/openconnect/*/libexec/openconnect/hipreport.sh"),
|
||||
"/usr/local/opt/openconnect/libexec/openconnect/hipreport.sh"]:
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def prelogin() -> tuple[str, str]:
|
||||
"""Liefert (saml_html, sessid)."""
|
||||
# Gateway-Prelogin direkt (nicht das Portal) -> liefert den Gateway-Cookie.
|
||||
url = (f"https://{SERVER}/ssl-vpn/prelogin.esp"
|
||||
"?tmp=tmp&clientVer=4100&clientos=Mac")
|
||||
req = urllib.request.Request(url, method="POST", data=b"",
|
||||
headers={"User-Agent": GP_UA})
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
xml = r.read().decode("utf-8", "replace")
|
||||
sessid = ""
|
||||
for c in r.headers.get_all("Set-Cookie") or []:
|
||||
m = re.search(r"SESSID=([^;]+)", c)
|
||||
if m:
|
||||
sessid = m.group(1)
|
||||
m = re.search(r"<saml-request>(.*?)</saml-request>", xml, re.S)
|
||||
if not m:
|
||||
raise SystemExit("Kein <saml-request> im Prelogin.")
|
||||
return base64.b64decode(m.group(1)).decode("utf-8", "replace"), sessid
|
||||
|
||||
|
||||
class Delegate(NSObject):
|
||||
def webView_decidePolicyForNavigationAction_decisionHandler_(
|
||||
self, webView, navAction, handler):
|
||||
u = str(navAction.request().URL().absoluteString())
|
||||
print("NAV ->", u[:120], file=sys.stderr)
|
||||
if u.startswith("globalprotectcallback:"):
|
||||
payload = u[len("globalprotectcallback:"):]
|
||||
try:
|
||||
dec = base64.b64decode(payload).decode("utf-8", "replace")
|
||||
except Exception:
|
||||
dec = payload
|
||||
print("CALLBACK-Payload:", dec[:500], file=sys.stderr)
|
||||
for key in ("prelogin-cookie", "saml-username",
|
||||
"portal-userauthcookie"):
|
||||
m = re.search(rf"{key}[=:>]+\s*([^<&\"']+)", dec)
|
||||
if m:
|
||||
captured[key] = m.group(1).strip()
|
||||
handler(0) # Cancel
|
||||
if "prelogin-cookie" in captured:
|
||||
NSApplication.sharedApplication().stop_(None)
|
||||
return
|
||||
handler(1) # Allow
|
||||
|
||||
def webView_decidePolicyForNavigationResponse_decisionHandler_(
|
||||
self, webView, navResponse, handler):
|
||||
resp = navResponse.response()
|
||||
if resp.isKindOfClass_(NSHTTPURLResponse):
|
||||
for k, v in resp.allHeaderFields().items():
|
||||
lk = str(k).lower()
|
||||
if lk in ("prelogin-cookie", "saml-username",
|
||||
"portal-userauthcookie", "saml-auth-status"):
|
||||
captured[lk] = str(v)
|
||||
handler(WKNavigationResponsePolicyAllow)
|
||||
if "prelogin-cookie" in captured and "saml-username" in captured:
|
||||
NSApplication.sharedApplication().stop_(None)
|
||||
|
||||
def webView_didFinishNavigation_(self, webView, nav):
|
||||
# Nur die SAML-Weiterleitungsform (id=myform) anstossen, sonst wuerde
|
||||
# auch das Duo-Login-Formular dauernd neu abgesendet (Flackern).
|
||||
webView.evaluateJavaScript_completionHandler_(
|
||||
"var f=document.getElementById('myform'); if(f){f.submit();}", None)
|
||||
|
||||
|
||||
def run_saml(html: str, sessid: str):
|
||||
app = NSApplication.sharedApplication()
|
||||
app.setActivationPolicy_(0) # Regular -> Fenster bekommt sauber den Fokus
|
||||
rect = NSMakeRect(0, 0, 540, 720)
|
||||
style = (NSWindowStyleMaskTitled | NSWindowStyleMaskClosable
|
||||
| NSWindowStyleMaskResizable)
|
||||
win = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
|
||||
rect, style, NSBackingStoreBuffered, False)
|
||||
win.setTitle_("Heron SSO-Login")
|
||||
|
||||
cfg = WKWebViewConfiguration.alloc().init()
|
||||
wv = WKWebView.alloc().initWithFrame_configuration_(rect, cfg)
|
||||
wv.setCustomUserAgent_(GP_UA)
|
||||
delegate = Delegate.alloc().init()
|
||||
wv.setNavigationDelegate_(delegate)
|
||||
win.setContentView_(wv)
|
||||
win.makeKeyAndOrderFront_(None)
|
||||
|
||||
base = NSURL.URLWithString_(f"https://{SERVER}/")
|
||||
|
||||
def load(_=None):
|
||||
wv.loadHTMLString_baseURL_(html, base)
|
||||
|
||||
if sessid:
|
||||
store = cfg.websiteDataStore().httpCookieStore()
|
||||
cookie = NSHTTPCookie.cookieWithProperties_({
|
||||
"Name": "SESSID", "Value": sessid, "Domain": SERVER,
|
||||
"Path": "/", "Secure": "TRUE",
|
||||
})
|
||||
store.setCookie_completionHandler_(cookie, load)
|
||||
else:
|
||||
load()
|
||||
|
||||
app.activateIgnoringOtherApps_(True)
|
||||
app.run()
|
||||
|
||||
|
||||
def main():
|
||||
auth_only = "--auth-only" in sys.argv
|
||||
html, sessid = prelogin()
|
||||
run_saml(html, sessid)
|
||||
|
||||
if "prelogin-cookie" not in captured:
|
||||
print(f"Kein prelogin-cookie erhalten. Erfasst: {list(captured)}",
|
||||
file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
if auth_only:
|
||||
# Nur den Cookie als JSON auf stdout -> die Tray-App liest ihn.
|
||||
print(json.dumps({
|
||||
"prelogin-cookie": captured["prelogin-cookie"],
|
||||
"saml-username": captured.get("saml-username", ""),
|
||||
}))
|
||||
return
|
||||
|
||||
print("Erfasst:", {k: v[:20] + "…" for k, v in captured.items()},
|
||||
file=sys.stderr)
|
||||
cmd = [
|
||||
_tool("openconnect"), "--protocol=gp",
|
||||
"--user", captured.get("saml-username", ""),
|
||||
"--usergroup=gateway:prelogin-cookie", "--passwd-on-stdin",
|
||||
"--script", f"{_tool('ocproxy')} -D {PORT}", "--script-tun",
|
||||
]
|
||||
hip = _hipreport()
|
||||
if hip:
|
||||
cmd += ["--csd-wrapper", hip] # HIP-Report, sonst trennt das Gateway
|
||||
cmd.append(SERVER)
|
||||
print("Starte:", " ".join(cmd), file=sys.stderr)
|
||||
p = subprocess.Popen(cmd, stdin=subprocess.PIPE)
|
||||
p.stdin.write((captured["prelogin-cookie"] + "\n").encode())
|
||||
p.stdin.flush()
|
||||
p.wait()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user