Files
apk-reverse/scripts/tls_check.py
T
inliver233 1a5cb6245f Broaden skill with unpacking, TLS triage, third-party build auditing, runtime probe
references: recon gains packer identification + unpacking workflow (payload/plaintext size mapping, dump validation, hash de-duplication, business-dex heuristic); repack-and-sign gains the de-shelled repack pipeline, why apktool over binary AXML, and the apksigner scheme-selection caveat; new tls-and-cert documents feature-scoped network failures, dual trust chains and the fix template; new third-party-builds covers auditing a cracked/modded APK before trusting it; dynamic-frida gains version pinning, remote-device path, the four-layer probe and hook-never-fires triage; environment gains UI automation pitfalls; ad-removal gains the single convergence point, dynamic-plugin SDKs and the global timestamp gate; pitfalls gains P13-P17; verification gains rung 7 and the three-signal ad check.

scripts: new frida_probe.js (four-layer runtime probe), run_probe.py and tls_check.py; repack.py fixes two crash-on-default bugs and a misleading META-INF note; datastore_inject.py, PatchMethodExample.java and build_scripts.py lose hardcoded values - the example class name did not match its file and could not compile.
2026-09-19 21:44:00 +08:00

324 lines
13 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""TLS certificate health check for one or more hosts.
WHY THIS EXISTS
---------------
When a repacked app's login/registration fails with
`Chain validation failed` / `SSLHandshakeException`, the cause is often the
*server* certificate, not your patch. This script answers that question
independently of the app, so you can rule the server side in or out **before**
touching the APK. It is step 2 of the three-step TLS triage:
1. take the real request URL / host out of the runtime probe (see frida_probe.js)
2. verify that host's certificate out-of-band <-- this script
3. compare with another host of the same app (usually one of them is fine),
which proves the failure is host-specific and not your clock/network
WHY IT STILL PRINTS THE CERTIFICATE WHEN VERIFICATION FAILS
-----------------------------------------------------------
`ssl.SSLSocket.getpeercert()` returns an empty dict whenever the handshake was
not validated, and `verification_mode=CERT_NONE` never validates. So on failure
we re-read the peer certificate in DER form and decode it locally. That is what
turns "it failed" into "it failed because notAfter is 39 days in the past".
WHAT IT REPORTS
---------------
Per host: strict verification result, protocol/cipher, subject, issuer,
notBefore, notAfter, days remaining, and a classification of the failure:
EXPIRED certificate has expired
NOT-YET-VALID certificate is not valid yet
HOSTNAME-MISMATCH chain is fine, the name does not match
UNTRUSTED-CA self-signed, or issuer not in the trust store
CHAIN-BROKEN bad signature / invalid CA / incomplete chain
REVOKED revoked by the issuer
OTHER anything else, with the raw OpenSSL message
Exit code: 0 if every host verified strictly, 2 if any host failed.
Pure standard library, Python 3.9+.
USAGE
-----
python tls_check.py api.example.com
python tls_check.py api.example.com cdn.example.com
python tls_check.py api.example.com --port 8443
python tls_check.py 203.0.113.10 --sni api.example.com # IP + real SNI name
python tls_check.py api.example.com --json # machine-readable
"""
import argparse
import hashlib
import json
import os
import socket
import ssl
import sys
import tempfile
from datetime import datetime, timezone
DEFAULT_PORT = 443
DEFAULT_TIMEOUT = 10
# OpenSSL X509_V_ERR_* -> (classification, human readable)
VERIFY_CODE_CLASS = {
7: ('CHAIN-BROKEN', 'certificate signature failure'),
9: ('NOT-YET-VALID', 'certificate is not yet valid'),
10: ('EXPIRED', 'certificate has expired'),
18: ('UNTRUSTED-CA', 'self-signed certificate'),
19: ('UNTRUSTED-CA', 'self-signed certificate in certificate chain'),
20: ('UNTRUSTED-CA', 'unable to get local issuer certificate'),
21: ('UNTRUSTED-CA', 'unable to verify the first certificate'),
24: ('CHAIN-BROKEN', 'invalid CA certificate'),
27: ('REVOKED', 'certificate revoked'),
62: ('HOSTNAME-MISMATCH', 'hostname mismatch'),
}
# What a failure means for the reverse-engineering workflow.
CLASS_ACTION = {
'EXPIRED': 'server-side problem: the certificate chain is not trustworthy '
'any more. Re-signing the APK cannot fix this, and a client patch '
'is the only way to keep the app usable.',
'NOT-YET-VALID': 'either the server is misconfigured or the device clock is '
'wrong. Check the device date before blaming the app.',
'HOSTNAME-MISMATCH': 'the chain is valid but the connection is using the '
'wrong name. Check for a proxy, a hosts entry, or a '
'hardcoded IP + wrong SNI.',
'UNTRUSTED-CA': 'the presented chain is not rooted in the system trust store. '
'Expected for a MITM/proxy setup; suspicious for a public API.',
'CHAIN-BROKEN': 'the server sent an incomplete or internally inconsistent '
'chain. Often a missing intermediate certificate.',
'REVOKED': 'the certificate was revoked by its issuer.',
'OTHER': 'read the raw OpenSSL message below; this is not one of the '
'well-known failure classes.',
}
def fmt_name(parts):
"""Flatten ((('commonName','x'),),) into 'commonName=x'."""
if not parts:
return None
flat = []
for rdn in parts:
for key, val in rdn:
flat.append('%s=%s' % (key, val))
return ', '.join(flat)
def parse_asn1_time(text):
"""'Nov 29 23:59:59 2026 GMT' -> aware UTC datetime (or None)."""
if not text:
return None
cleaned = text.strip()
if cleaned.endswith(' GMT') or cleaned.endswith(' UTC'):
cleaned = cleaned[:-4].rstrip()
try:
return datetime.strptime(cleaned, '%b %d %H:%M:%S %Y').replace(tzinfo=timezone.utc)
except ValueError:
return None
def decode_der(der):
"""Decode a DER certificate without ever verifying it.
Uses the CPython private helper that backs `getpeercert()`; if it is not
available we still return what we can (length + fingerprint), because a
partially decoded certificate beats no certificate at all.
"""
info = {'der_bytes': len(der),
'sha256': hashlib.sha256(der).hexdigest(),
'sha1': hashlib.sha1(der).hexdigest()}
helper = getattr(getattr(ssl, '_ssl', None), '_test_decode_cert', None)
if helper is None:
return info
path = None
try:
pem = ssl.DER_cert_to_PEM_cert(der)
with tempfile.NamedTemporaryFile('w', suffix='.pem', delete=False) as fh:
path = fh.name
fh.write(pem)
info.update(helper(path))
except Exception:
pass
finally:
if path and os.path.exists(path):
try:
os.remove(path)
except OSError:
pass
return info
def fetch_peer_cert(host, port, sni, timeout):
"""Read the peer certificate even when the chain cannot be validated."""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((host, port), timeout=timeout) as raw:
with ctx.wrap_socket(raw, server_hostname=sni) as tls:
der = tls.getpeercert(binary_form=True)
return decode_der(der) if der else {}
def strict_connect(host, port, sni, timeout):
"""Full strict verification: system trust store + hostname match."""
ctx = ssl.create_default_context()
with socket.create_connection((host, port), timeout=timeout) as raw:
with ctx.wrap_socket(raw, server_hostname=sni) as tls:
return {
'cert': tls.getpeercert() or {},
'protocol': tls.version(),
'cipher': (tls.cipher() or ('', '', ''))[0],
}
def check_host(host, port, sni, timeout):
"""Return one result dict; never raises for network/TLS problems."""
res = {
'host': host,
'port': port,
'sni': sni,
'ok': False,
'classification': None,
'reason': None,
'verify_code': None,
'protocol': None,
'cipher': None,
'subject': None,
'issuer': None,
'not_before': None,
'not_after': None,
'days_remaining': None,
'fingerprint_sha256': None,
'action': None,
'error': None,
}
try:
hit = strict_connect(host, port, sni, timeout)
except ssl.SSLCertVerificationError as exc:
code = getattr(exc, 'verify_code', None)
cls, human = VERIFY_CODE_CLASS.get(code, ('OTHER', getattr(exc, 'verify_message', str(exc))))
res['classification'] = cls
res['reason'] = human
res['verify_code'] = code
res['action'] = CLASS_ACTION.get(cls)
res['error'] = '%s: %s' % (type(exc).__name__, exc)
except (ssl.SSLError, socket.error, OSError) as exc:
res['classification'] = 'OTHER'
res['reason'] = 'connection or handshake failed before certificate validation'
res['action'] = CLASS_ACTION['OTHER']
res['error'] = '%s: %s' % (type(exc).__name__, exc)
return res
else:
cert = hit['cert']
res.update({'ok': True, 'protocol': hit['protocol'], 'cipher': hit['cipher'],
'subject': fmt_name(cert.get('subject')),
'issuer': fmt_name(cert.get('issuer')),
'not_before': cert.get('notBefore'),
'not_after': cert.get('notAfter')})
res['days_remaining'] = days_left(res['not_after'])
return res
# Verification failed: still try to show the real certificate.
try:
cert = fetch_peer_cert(host, port, sni, timeout)
except Exception as exc:
res['error'] = (res['error'] or '') + ' | peek failed: %s' % exc
return res
res.update({'subject': fmt_name(cert.get('subject')),
'issuer': fmt_name(cert.get('issuer')),
'not_before': cert.get('notBefore'),
'not_after': cert.get('notAfter'),
'fingerprint_sha256': cert.get('sha256')})
res['days_remaining'] = days_left(res['not_after'])
return res
def days_left(not_after):
dt = parse_asn1_time(not_after)
if dt is None:
return None
return (dt - datetime.now(timezone.utc)).days
def report(res, out=sys.stdout):
w = out.write
w('== %s:%d%s\n' % (res['host'], res['port'],
'' if res['sni'] == res['host'] else ' (SNI: %s)' % res['sni']))
if res['ok']:
w(' status : OK (strict verification passed)\n')
else:
w(' status : FAIL - %s\n' % res['classification'])
w(' reason : %s\n' % res['reason'])
if res['verify_code'] is not None:
w(' verify_code : %d\n' % res['verify_code'])
if res['protocol']:
w(' protocol : %s cipher: %s\n' % (res['protocol'], res['cipher']))
w(' subject : %s\n' % (res['subject'] or '<undecoded>'))
w(' issuer : %s\n' % (res['issuer'] or '<undecoded>'))
w(' notBefore : %s\n' % (res['not_before'] or '<unknown>'))
w(' notAfter : %s\n' % (res['not_after'] or '<unknown>'))
days = res['days_remaining']
if days is not None:
if days < 0:
w(' validity : EXPIRED %d days ago\n' % (-days))
else:
w(' days remaining : %d\n' % days)
if res['fingerprint_sha256']:
w(' sha256 : %s\n' % res['fingerprint_sha256'])
if res['action'] and not res['ok']:
w(' action : %s\n' % res['action'])
if res['error']:
w(' raw error : %s\n' % res['error'])
w('\n')
def main():
ap = argparse.ArgumentParser(
description='Verify TLS certificates for one or more hosts, strictly, '
'and classify any failure (expired / hostname mismatch / untrusted CA).')
ap.add_argument('hosts', nargs='+', metavar='HOST',
help='hostname or IP to check, e.g. api.example.com')
ap.add_argument('--port', type=int, default=DEFAULT_PORT,
help='TCP port (default %d)' % DEFAULT_PORT)
ap.add_argument('--sni', default=None,
help='override the SNI/hostname to validate against; useful when '
'HOST is a bare IP, e.g. --sni api.example.com')
ap.add_argument('--timeout', type=float, default=DEFAULT_TIMEOUT,
help='connect timeout in seconds (default %d)' % DEFAULT_TIMEOUT)
ap.add_argument('--json', action='store_true', help='emit JSON instead of a report')
args = ap.parse_args()
results = []
for host in args.hosts:
sni = args.sni or host
results.append(check_host(host, args.port, sni, args.timeout))
if args.json:
json.dump(results, sys.stdout, indent=2, sort_keys=True)
sys.stdout.write('\n')
else:
for res in results:
report(res)
bad = [r for r in results if not r['ok']]
good = [r for r in results if r['ok']]
if len(results) > 1:
if bad and good:
print('SUMMARY: %d/%d hosts failed. At least one host on this app '
'verifies fine, so the failure is host-specific (server side), '
'not a clock/network problem on your side.'
% (len(bad), len(results)))
elif bad:
print('SUMMARY: every host failed. Suspect the network path, a proxy, '
'or the device/host clock before blaming the app.')
else:
print('SUMMARY: all %d hosts verified strictly.' % len(results))
for r in bad:
print('FAIL %s -> %s' % (r['host'], r['classification']))
return 0 if all(r['ok'] for r in results) else 2
if __name__ == '__main__':
sys.exit(main())