DEV Community

mattleeee
mattleeee

Posted on Originally published at hkcode.dpdns.org

Surviving Corporate HTTPS Interception: Life Behind Sangfor aTrust

Corporate laptops are strange machines. You have admin rights on paper, but every outbound TLS connection is terminated and re-originated by a local agent. Sangfor aTrust is one of the more common zero-trust clients in APAC enterprises, and it does exactly this: it installs a root CA into the OS trust store, proxies traffic through a local service, and re-signs certificates on the fly. Your browser shows a green padlock. pip install does not.

This post is a field guide to living with that setup without getting your laptop quarantined.

How the interception actually works

aTrust runs a local proxy (typically on 127.0.0.1 on some port) and pushes a PAC file or system proxy setting. When you connect to pypi.org, the agent completes the TLS handshake with the real server using its own credentials, then presents you a certificate signed by the corporate root CA. The OS trust store contains that CA, so anything using the system store is fine. Anything shipping its own bundle is not.

The tell is always in the chain. Run:

openssl s_client -connect pypi.org:443 -servername pypi.org -showcerts </dev/null 2>/dev/null \
  | openssl x509 -noout -issuer -subject
Enter fullscreen mode Exit fullscreen mode

On a clean network you get something like issuer=C=US, O=Let's Encrypt, CN=R3. Behind aTrust you get issuer=C=CN, O=Sangfor Technologies, CN=Sangfor SSL Proxy CA or an internal enterprise CA name. That single line is the difference between a working environment and a day of debugging.

Distinguishing MITM from a normal corporate proxy

A plain forward proxy does not terminate TLS — it just tunnels bytes and the issuer stays untouched. MITM means the issuer changed. To confirm it's the agent and not a rogue AP:

  1. Compare the issuer seen by openssl s_client against the issuer shown in the browser's certificate viewer for the same host. If they match and both are internal, it's the agent.
  2. Check the local proxy port: netstat -ano | findstr LISTENING on Windows, then match the PID to the aTrust process.
  3. Hit a host with a known public chain, e.g. openssl s_client -connect github.com:443. If GitHub is also re-signed, everything is being intercepted.

Once you know the CA's subject and fingerprint, you can decide per-tool what to do.

Which tools tolerate the corporate CA

The dividing line is where the trust anchor comes from.

Tolerates it (uses OS trust store):

  • Chrome, Edge, Firefox (with security.enterprise_roots.enabled=true)
  • Windows-native HTTP stacks, .NET, WinHTTP
  • curl on Windows when built against Schannel
  • macOS curl and NSURLSession-based tools
  • Python requests only if you point it at the system store or a bundle containing the corporate CA

Does not tolerate it (ships its own bundle or pins):

  • Python certifi (bundled, frozen at release time)
  • Node.js (bundles Mozilla's CA set)
  • Git for Windows (ships its own ca-bundle.crt)
  • Rust reqwest default roots
  • Anything doing certificate pinning: mobile SDKs, some CLI tools, go binaries with embedded roots

The fix is almost always the same: build a bundle that contains both the Mozilla roots and the corporate root, then point the tool at it.

Exporting the corporate root CA

On Windows:

# List candidate roots, then export the one you identified
Get-ChildItem Cert:\LocalMachine\Root | Where-Object { $_.Subject -like "*Sangfor*" } |
  ForEach-Object { Export-Certificate -Cert $_ -FilePath "$env:USERPROFILE\corp-root.cer" }
Enter fullscreen mode Exit fullscreen mode

Then convert to PEM. If you have OpenSSL or Git's bundled one:

openssl x509 -inform DER -in corp-root.cer -out corp-root.pem
Enter fullscreen mode Exit fullscreen mode

Keep this file somewhere stable, e.g. ~/.certs/corp-root.pem.

Fixing Python: certifi, requests, and pip

certifi is a vendored snapshot. The clean approach is to append the corporate root to a copy and set REQUESTS_CA_BUNDLE / SSL_CERT_FILE, not to mutate the installed certifi package (which breaks on every upgrade).

import os
import shutil
from pathlib import Path

import certifi

CORP_ROOT = Path.home() / ".certs" / "corp-root.pem"
BUNDLE = Path.home() / ".certs" / "combined-ca.pem"


def build_combined_bundle() -> Path:
    """Concatenate certifi's roots with the corporate root CA."""
    if not CORP_ROOT.exists():
        raise FileNotFoundError(f"missing corporate root: {CORP_ROOT}")

    with BUNDLE.open("wb") as out:
        out.write(Path(certifi.where()).read_bytes())
        out.write(b"\n")
        out.write(CORP_ROOT.read_bytes())

    return BUNDLE


if __name__ == "__main__":
    bundle = build_combined_bundle()
    os.environ["REQUESTS_CA_BUNDLE"] = str(bundle)
    os.environ["SSL_CERT_FILE"] = str(bundle)

    import requests

    r = requests.get("https://pypi.org/simple/", timeout=10)
    print(r.status_code, len(r.content))
Enter fullscreen mode Exit fullscreen mode

Set the two environment variables globally (REQUESTS_CA_BUNDLE for requests, SSL_CERT_FILE for the stdlib ssl module and most other tools) and most Python breakage disappears. For pip specifically, either rely on SSL_CERT_FILE or set it explicitly:

pip config set global.cert ~/.certs/combined-ca.pem
Enter fullscreen mode Exit fullscreen mode

If a package still fails, it's probably pinning. Check with:

import ssl, socket

host = "pypi.org"
ctx = ssl.create_default_context(cafile=str(BUNDLE))
with socket.create_connection((host, 443), timeout=10) as sock:
    with ctx.wrap_socket(sock, server_hostname=host) as ssock:
        print(ssock.getpeercert()["issuer"])
Enter fullscreen mode Exit fullscreen mode

If that succeeds but the package fails, the package is doing its own pinning or bundling — you'll need its own override (e.g. NODE_EXTRA_CA_CERTS for Node, SSL_CERT_DIR for Go).

Fixing Git

Git for Windows ignores the Windows store by default. Two options: point it at the combined bundle, or tell it to use Schannel.

Option A — explicit bundle:

git config --global http.sslCAInfo ~/.certs/combined-ca.pem
git config --global http.sslBackend openssl
Enter fullscreen mode Exit fullscreen mode

Option B — use the OS store (Windows only, often the cleanest):

git config --global http.sslBackend schannel
Enter fullscreen mode Exit fullscreen mode

With Schannel, Git trusts whatever Windows trusts, including the aTrust root. This is my default on corporate Windows machines. On macOS, http.sslBackend doesn't exist; use the combined bundle approach there.

Verify:

GIT_CURL_VERBOSE=1 git ls-remote https://github.com/git/git HEAD 2>&1 | grep -i "issuer\|SSL certificate"
Enter fullscreen mode Exit fullscreen mode

If you see the corporate issuer in the verbose output and the command succeeds, you're done.

SSH is usually untouched

aTrust typically proxies HTTP/HTTPS. SSH over port 22 or 443 is usually passed through or blocked outright, not MITM'd. If git clone git@github.com:... works, prefer it — SSH keys don't care about CA chains. If port 22 is blocked, GitHub's ssh.github.com:443 endpoint is the standard workaround:

Host github.com
  HostName ssh.github.com
  Port 443
  User git
Enter fullscreen mode Exit fullscreen mode

Routing around blocked hosts, politely

Some hosts are blocked by policy, not by the proxy's technical limits. The wrong move is to spin up a personal VPN and tunnel everything — that's the fastest way to a security incident. The right move depends on the reason for the block.

If it's a category block (e.g. a package registry mirrors the security team hasn't approved): file a ticket. Internal mirrors of PyPI, npm, and Go modules are common in these environments, and using them is both faster and sanctioned. Ask for the internal index URL; it's usually already configured for some team.

If it's an allowlist model: the block is on the destination, and the proxy will happily pass traffic to approved hosts. Route around it by using an approved equivalent. For container images, that often means the company's Harbor or Artifactory instance rather than Docker Hub.

If you need to reach a host for legitimate work and it's genuinely missing from the allowlist: the polite ask is specific. "I need registry.npmjs.org for build reproducibility on project X; the internal mirror at npm.internal.corp is missing package Y as of version Z." Concrete, scoped, and easy for a security engineer to approve.

What you should not do: install a third-party VPN, use a public SOCKS proxy, or disable the aTrust agent. All three are detectable and all three turn a five-minute ticket into a meeting with your manager.

A note on certificate pinning

Some internal tools pin their own certs and will break the moment the corporate CA changes. If you maintain such a tool, don't pin the leaf; pin the corporate root's public key and make it configurable. A hardcoded SPKI hash is a time bomb.

import hashlib
import ssl

def spki_sha256(host: str, port: int = 443) -> str:
    """Return the base64 SHA-256 of the leaf's SubjectPublicKeyInfo."""
    der = ssl.get_server_certificate((host, port), ca_certs=str(BUNDLE)).encode()
    # In practice, parse with cryptography.x509 and hash the SPKI, not the whole cert.
    return hashlib.sha256(der).hexdigest()
Enter fullscreen mode Exit fullscreen mode

The point isn't the exact hash — it's that pinning should be a config value, not a constant buried in source.

A short checklist

When something breaks behind aTrust:

  1. Confirm the issuer with openssl s_client. If it's the corporate CA, you know the cause.
  2. Build ~/.certs/combined-ca.pem from certifi plus the corporate root.
  3. Export SSL_CERT_FILE and REQUESTS_CA_BUNDLE to that path.
  4. For Git, set http.sslBackend=schannel on Windows or http.sslCAInfo everywhere.
  5. Prefer SSH where possible; it sidesteps the whole problem.
  6. For blocked hosts, use internal mirrors or file a scoped ticket — don't tunnel.

The interception isn't going away. Once you know where the trust anchors come from, most of the friction disappears.

More notes like this ship every week on this site.


Daily Picks

The following pairs are selected from the multi-timeframe trend scanner (Gate.io futures) and are for technical-analysis study only — not investment advice.
Data updated: 2026-10-06 12:36:33

Long

Pair Signal Price Take Profit Stop Loss R/R
SKYAI $0.0416 $0.0433 $0.0406 1:1.6
RE $0.4991 $0.5181 $0.4866 1:1.5

2 picks selected. Scanner runs every 15 minutes.

Top comments (0)