197 lines
7.0 KiB
Python
197 lines
7.0 KiB
Python
#!/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()
|