Skip to content

CWE-494: Download of Code Without Integrity Check

Overview

An application downloads code or an executable from an external source and runs it without verifying its integrity, so anyone able to tamper with the response decides what the application executes.

Relationship to Other CWEs

Report a finding that fits this page here rather than against its parent CWE-345 (Insufficient Verification of Data Authenticity), which MITRE marks Discouraged: CWE-345 covers unverified data of any kind, and this page is the case where the data is code about to be executed. MITRE's other parent for it, CWE-669 (Incorrect Resource Transfer Between Spheres), has no page here.

The pages around it differ by which check was missing:

OWASP Classification

A08:2025 - Software or Data Integrity Failures

Risk

Critical: A tampered download is remote code execution. Nothing distinguishes the publisher's code from anyone else's, so the substituted bytes run with the application's privileges.

Remediation Steps

Core Principle: Do not run downloaded code until it has been verified against something the download server does not control - a signature made with a publisher key you already hold, or a hash pinned in your own source.

Locate Code Download Without Integrity Checks

  • Start at the reported line and identify which code or executable is fetched there, and from where
  • Find the other download paths in the same codebase: HTTP downloads, package installations, plugin loading, script fetching
  • Establish what verification each one performs - a hash check, a signature check, or nothing
  • Note where the bytes come from: URLs, package repositories, CDNs, third-party sites
  • Establish what happens to them: executed, imported, or loaded as a plugin

Common patterns:

  • Downloading and executing scripts: exec(requests.get(url).text)
  • Installing packages without verification
  • Loading plugins from untrusted sources
  • Fetching libraries without hash validation

Verify a Publisher Signature (Primary Defense)

// TRUSTED_PUBLIC_KEY ships with the application - it is never fetched at download time

function verify_and_load(code_url, signature_url, installed_version):
    code = http_get(code_url, timeout = 30)
    signature = http_get(signature_url)  // detached signature; same host is fine

    if not public_key_verify(TRUSTED_PUBLIC_KEY, signature, code):
        raise IntegrityError("signature verification failed")

    manifest = parse_signed_manifest(code)  // version read from inside the signed bytes
    if manifest.version <= installed_version:
        raise IntegrityError("refusing to install a superseded version")

    load(code)

Why this works: Only the holder of the private key can produce a signature that verifies against the public key, so the signature binds the bytes to a publisher rather than to a value the download server also happens to serve. That is what makes it the right answer for code you have not seen before - the next release an updater fetches, or a plugin published after your build - where there is no hash to pin in advance.

What the check depends on:

  • The public key must reach the client independently of the download. A key fetched from the host serving the artefact is not a root of trust: whoever tampered with the code can serve a matching key and signature. Ship it in the installer, embed it in the binary, or pin its fingerprint. This is the same question to ask of a package manager's keyring or a gpg --verify step - a signature is only worth the provenance of the key it was checked against.
  • Only the bytes inside the signature are verified. Version numbers, filenames or URLs carried alongside the artefact are attacker-controlled; read them from the signed payload.
  • A valid signature does not mean a current version. Signatures do not stop verifying when a release is superseded, so anyone able to serve responses can replay a genuine older build with a known vulnerability. Refuse anything not newer than what is installed.

Pin a Hash Where the Artefact Is Fixed

Where the download is one specific, unchanging artefact - a pinned dependency, a vendored installer, a script fetched during a build - a hash recorded in your own source gives the same protection without key management. It covers exactly one version, so every upgrade becomes a code change, which is why it does not fit auto-update or plugin flows:

EXPECTED_HASH = "..."  // committed next to the code that downloads, not fetched with it

function download_and_verify(url, expected_hash):
    code = http_get(url, timeout = 30)
    actual_hash = sha256(code)
    if actual_hash != expected_hash:
        raise IntegrityError("hash mismatch - possible tampering")
    return code

code = download_and_verify(url, EXPECTED_HASH)
// only execute/load after verification succeeds

Why this works: A cryptographic hash is a fingerprint of exactly one artefact, and producing different bytes with the same SHA-256 digest is not feasible. All of the protection comes from where the expected value is stored: a checksum file served by the host that serves the download, or a digest read out of the downloaded file itself, only tells you the host was consistent with itself. The expected value has to come from somewhere an attacker who controls the download cannot also change - your repository, your configuration management, or a signed manifest.

Hash algorithms to use:

  • SHA-256 or higher (SHA-384, SHA-512)
  • SHA-3 family
  • Never use MD5 or SHA-1 - practical collision attacks mean a digest no longer identifies a single artefact

Use Secure Channels and Restrict Sources

TRUSTED_SOURCES = ['cdn.example.com', 'plugins.example.com']

function download_from_trusted_source(url, expected_hash):
    parsed = parse_url(url)
    if parsed.scheme != 'https':
        raise ConfigError("must use HTTPS for code downloads")
    if parsed.hostname not in TRUSTED_SOURCES:
        raise ConfigError("untrusted source: " + parsed.hostname)

    code = http_get(url, verify_tls = true, timeout = 30)
    if sha256(code) != expected_hash:
        raise IntegrityError("hash mismatch")
    return code

HTTPS and a source allowlist decide who you are talking to; they say nothing about what was served. A compromised mirror, a hijacked CDN account, and a malicious release from the genuine publisher all arrive over a valid certificate from an allowlisted host. That is why the hash check stays inside the function above rather than being replaced by the transport checks.

Security controls:

  • Always use HTTPS (never HTTP for code downloads)
  • Leave certificate and hostname verification on - they are separate settings in most clients, and turning off either one authenticates nobody
  • Allowlist the hosts you download from
  • Set download timeouts
  • Validate URLs before downloading

Restrict Code Execution Permissions

function save_downloaded_code_safely(code, expected_hash):
    if sha256(code) != expected_hash:
        raise IntegrityError("integrity check failed")
    write_file(plugin_path, code, mode = 0550, group = "plugins", exclusive = true)
    // owner and group may read and execute, nobody may write; fail if the path already exists

run_process(plugin_path, as_user = "plugin_runner", timeout = 30)  // in group "plugins": can run the file, cannot modify it

Least privilege:

  • Write downloaded code so the account that runs it cannot change it afterwards: it stays owned by the account that installed it, with no write bit for anyone else. Set the mode in the create call rather than adjusting it after the bytes are on disk. Mode 600 is the common reflex and breaks the example above twice over - it leaves no execute bit, and it gives a separate runtime account no access at all
  • Run it in a sandbox or container, isolated from the rest of the host
  • Use a separate user account, not root
  • Apply resource limits (CPU, memory, time)

Monitor and Audit Code Downloads

Record what was fetched and whether it verified, without copying credentials into the log:

// log what identifies the artifact, not the string that fetched it
function audit_label(url):
    parsed = parse_url(url)
    return parsed.host + parsed.path        // no userinfo, no query string

function download_code_with_audit(url, expected_hash):
    label = audit_label(url)
    log_info("downloading code from " + label)
    code = http_get(url)
    actual_hash = sha256(code)

    if actual_hash == expected_hash:
        log_info("integrity verified for " + label + " sha256=" + actual_hash)
        return code
    else:
        log_error("INTEGRITY FAILURE: " + label
                  + " expected=" + expected_hash + " actual=" + actual_hash)
        alert_security_team("code integrity check failed for " + label)
        raise IntegrityError("integrity check failed")

Why the URL is not the thing to log: a download URL is a whole request target, and the credential is routinely part of it - a registry token in the userinfo (https://user:token@registry.example.com/...), a presigned S3 signature in X-Amz-Signature, a release-asset ?token=, a CI artifact URL with a job secret. Logging it copies that credential into every log aggregator, alert and support ticket the line reaches, which is CWE-532 and, for the query-string case, CWE-598 - and it converts a finding about integrity into a credential disclosure. Host and path identify the artifact for every diagnostic purpose an auditor has; the hashes are what make the record worth keeping, and neither is secret. Where the full URL is genuinely needed to reproduce a failure, log an opaque event id and keep the URL out of the log entirely.

Monitoring:

  • Log all code download attempts, identified by origin, artifact and version - with the URL redacted to host and path, never including userinfo or the query string
  • Log integrity check results (pass/fail) with the expected and actual hashes
  • Alert on integrity check failures
  • Track download sources and frequencies
  • Monitor for downloads from unexpected sources

Testing:

  • Test with a genuine artefact and the correct hash or signature (should succeed - a check that rejects everything passes every tamper test)
  • Test with tampered code (should fail integrity check)
  • Test with wrong hash (should reject)
  • Test with a genuine but superseded version (should reject where the client enforces a minimum version)
  • Test with a signature made by a key other than the pinned one (should reject)
  • Test with HTTP instead of HTTPS (should reject)
  • Test with untrusted source (should reject)
  • Verify logging captures all downloads

Common Vulnerable Patterns

  • Downloading and executing code without verification
  • Using HTTP or insecure channels for code downloads
  • Checking the download against a checksum served by the same host as the download

Unverified Remote Code Execution

// VULNERABLE - no integrity check before executing
code = http_get(url)
execute(code)

Why this is vulnerable: Nothing here distinguishes the publisher's code from anyone else's. An attacker on the network path, on a compromised server, or holding a hijacked DNS record substitutes their own bytes, and those bytes run with the application's privileges.

Secure Patterns

Cryptographic Hash Verification Before Execution

// SECURE - verifies integrity before executing
code = http_get(url)
expected_hash = load_from_trusted_source()  // not from the download itself
if sha256(code) == expected_hash:
    execute(code)
else:
    raise IntegrityError("code integrity check failed - possible tampering")

Why this works:

  • The comparison fails on any change to the downloaded bytes, so tampering in transit, at the origin, or on a mirror is caught before anything runs
  • Nothing runs on the failure path: the download is discarded rather than executed with a warning logged
  • The assurance comes entirely from where expected_hash was loaded. Shipped with the application, held in configuration management, or read from a signed manifest, it is outside the reach of an attacker who controls the download; fetched from the download host, it is worth nothing
  • A pinned hash is not authentication. It says the bytes are the ones you recorded, not who produced them, and it only covers the one version you recorded. Where the artefact changes between releases, verify a publisher signature instead

Additional Resources