Retire the Lock, Skip the Cooldown: Ten CVEs Rebuild the BEAM Toolchain’s Threat Model in One Afternoon

Between 13:15Z and 18:23Z on 12 October 2026, the Erlang Ecosystem Foundation — the CVE Numbering Authority for the BEAM ecosystem since 2025 — published ten records covering Hex (the Elixir package client), hex_core (the shared Erlang client library), rebar3 (the Erlang build tool) and Gleam (the compiler for the ecosystem’s newest language). Nine were found by one person, Peter Ullrich. The releases that fix them landed the same day: Gleam 1.19.2 at 10:30:24Z, hex_core 0.20.0 at 13:06:54Z, rebar3 3.27.2 at 14:25:38Z, Hex 2.5.2 at 14:33:42Z, rebar3 3.27.3 at 18:20:04Z. We have not found a single piece of analytical coverage of the batch; search returns CVE-mirror pages and nothing else.

Two of the ten deserve to outlive the batch. One is a single Hex registry field that three build tools, written in three languages by three teams, each independently decided to use as a directory name — and then delete. The other is the release-age cooldown: the control this ecosystem shipped specifically to blunt compromised-maintainer attacks, with a documented escape hatch that the attacker can open for themselves.

The cooldown turns itself off

Hex 2.5.0, released 29 June 2026, added a feature whose changelog entry states the threat model plainly. We quote it in full because the CVE is best read against it:

Add a configurable release-age cooldown to dependency resolution that withholds freshly published versions until they reach a minimum age, mitigating supply-chain attacks where a compromised release is pulled into projects before it can be detected and retired.

This is the npm-ecosystem lesson — from Shai-Hulud onward, the exposure window is the hours between a malicious publish and the community noticing — implemented as a client-side resolution filter. The same entry documents the exemptions in the next sentence: “Versions already in mix.lock, and locked versions that are retired or carry an advisory, bypass the cooldown so existing projects and security fixes are never held back.”

That second clause is CVE-2026-105668 (CVSS 4.0 7.1, CWE-693 Protection Mechanism Failure, hex 2.5.0 through 2.5.1). The advisory states the problem in one sentence: “Retiring a release needs the same package owner permission as publishing one.” So the actor who can publish the malicious version can also retire the version your lockfile currently pins. Hex.RemoteConverger.build_cooldown_bypass/3 then lifts the cooldown for every version of that package and of every package the locked release depends on, and the next mix deps.update that unlocks the package takes the new release regardless of its age. A second defect compounds it: Hex.Registry.Cooldown.versions/2 matches the bypass by package name alone, so lifting it for an organisation’s private package also lifts it for a same-named package on hexpm.

We read the commit that introduced the feature — b4e665d4, “Add release-age cooldown to dependency resolution (#1160)”, 20 May 2026, adding lib/hex/cooldown.ex and lib/hex/registry/cooldown.ex — and the bypass is present in it. The control and its defeat shipped together, and the exemption was never a bug in the ordinary sense: it was a usability requirement (a retired or advisory-carrying lock must not trap you on a bad version) that nobody priced against an attacker who holds the same permission. The fix, eed2bdf8 at 13:55:02Z on 12 October, is named for exactly that distinction: “Only lift the cooldown for locked releases with advisories.” Retirement alone no longer lifts it; an advisory still does, for the package and the direct dependencies of the locked release, and the lift is now scoped to the repository the package is locked from.

The CVSS vector carries PR:H, privileges required high, because you must own the package. We think that understates it to the point of being misleading. Owner privilege is not a barrier in this threat model — it is the premise. Every supply-chain compromise the cooldown exists to mitigate begins with an attacker holding a maintainer’s credentials: that is what “compromised release” means in the changelog sentence that justified the feature. A control whose bypass requires precisely the capability the attacker is assumed to have scores as if the bypass were hard.

One field, three build tools

A release in a Hex registry declares, for each of its dependencies, an app field naming the dependency’s OTP application. It is metadata — a label chosen by the publisher. Three tools read it and used it as a filesystem path:

  • CVE-2026-96612 (7.5, CWE-22, hex 0.6.0 through 2.5.1). Hex takes the label from the signed registry, Mix derives the checkout directory as deps/<app>, and Hex.SCM.update/1 runs File.rm_rf!/1 on it before unpacking. A label of ../../target deletes and replaces that directory. The advisory notes hex.pm “only checks the label’s length.”
  • CVE-2026-103060 (7.5, CWE-22, rebar3 3.7.0-rc1 through 3.27.1). Same field, different code: rebar_packages:parse_deps/1 does not validate it, rebar_app_utils:dep_to_app/7 builds the directory from it, rebar_fetch:download_source_online/2 deletes it with rm -rf and moves the package in.
  • CVE-2026-103061 (5.9, CWE-22, gleam 0.32.4 through 1.19.1). hex_manifest_package in compiler-cli/src/dependencies.rs reads the field from the hex.pm release API and ProjectPaths::build_directory_for_package makes it a build directory; when a dependency fails to compile, Gleam deletes it with remove_dir_all. Absolute paths work too.

The detail that makes this a registry story rather than three client stories is in all three advisories: hex.pm did not restrict the field to a directory name before 2026-10-12. We confirmed the server-side change first-hand — hexpm/hexpm commit f738c5bd, “Limit app names to letters, digits, underscores and hyphens (#2026)”, 12 October at 13:35:11Z, twenty-two minutes before Hex 2.5.2 was tagged. For nearly a decade the registry accepted a path where every consumer expected a name, and each consumer, reasonably, trusted the registry.

Three properties are worth stating precisely, because they determine who is actually exposed. First, the trigger is fetching or building, not running: Hex deletes the directory during mix deps.get, rebar3 during get-deps (which, the advisory notes, “compiles nothing”), Gleam on a failed compile during gleam build, check, run, test or export. No publisher code executes; for a pure Gleam package none ever does. Second, it reaches you transitively — the hostile app label lives in some dependency’s requirements, not in your mix.exs. Third, the primitive is delete-then-write for Hex and rebar3, and delete-only for Gleam, which is why Gleam scores VA:H with no confidentiality or integrity impact while the other two carry VC:H/VI:H/VA:H.

That shape — hostile input consumed by a tool that is merely preparing code, before anything is executed — is the same one we traced through Emacs TRAMP’s filename handler last week, where a symlink target in a checkout was enough. The population that walks, fetches and indexes code it did not author has grown by an order of magnitude — CI runners, dependency bots, and now coding agents that clone and build a repository as a first step. None of them need to be tricked into running anything. mix deps.get is the exploit.

The convenience path lost the integrity check

CVE-2026-105280 (6.9, CWE-354, hex 0.20.2 through 2.5.1) is the batch’s neatest small lesson. Hex compares a fetched tarball’s inner and outer checksums against the signed registry before writing it to a file or unpacking it. The standard-output branch of mix hex.package fetch PACKAGE VERSION --output - skipped that comparison entirely — and Hex.SCM.fetch/3 does not verify freshly downloaded tarballs itself. The same command with --output FILE fails with a checksum mismatch; the pipe-friendly form printed the mirror’s bytes and exited zero.

The second half is better. Hex also printed diagnostics to standard output while the command ran: the body of a failed HTTP response, and API warnings from the x-hex-message header. A mirror that answers the update check with an error whose body is a tar archive gets that archive written to stdout ahead of the real tarball — so a shell pipeline that untars stdout extracts the mirror’s files. As the advisory notes, this happens even when the checksum check subsequently fails, because the bytes are already on the pipe. The 2.5.2 fix is two changes that read like a design rule: verify the checksum on the stdout path, and move every message to standard error so stdout carries nothing but the artifact.

The warning that told you to do what you were already doing

The two certificate records are a study in how a documented caveat stops being a caveat. CVE-2026-103502 (5.9, CWE-295 plus CWE-636 “Not Failing Securely”, hex_core before 0.20.0): the built-in hex_http_httpc adapter builds TLS options that verify the server certificate against public_key:cacerts_get/0; when that call raises, it prints a warning and sends the request with no TLS options at all, which before OTP 26 means no certificate verification. The function does not exist on OTP 24, so every HTTPS request fell through; on OTP 25 it raises whenever the system CA store cannot be loaded.

What makes it an advisory rather than a footnote is the EEF’s own account of why the existing notices failed. The README had warned since 0.8.2 that the adapter “has insecure default settings”; the adapter printed “using default ssl options which are insecure” on every fallback. But neither said that the server certificate was not verified, the README did not say which OTP versions were affected, and — the detail worth keeping — on OTP 25 without a CA store, the warning advised users to upgrade to OTP 25 or later, which they were already running. The advisory closes with an unusually candid statement of purpose: it is published “so that users who took those notices as general advice know their HTTPS requests weren’t authenticated.” That is a CNA correcting a documentation failure with a CVE, and it is the right call.

CVE-2026-103503 (5.9, CWE-295, hex 0.20.2 through 2.5.1) is the mirror image: a user who sets cacerts_path/HEX_CACERTS_PATH to a restricted store still got Hex’s bundled store through the partial_chain callback, which accepted any chain containing a certificate with the same public key as a bundled CA. Pinning that silently un-pins itself is worse than no pinning, because it is chosen deliberately. The 2.5.2 changelog adds a second fix under the same CVE that we have not seen called out elsewhere: Hex also accepted a server-sent copy of a CA certificate in place of the store’s own, so a server could present an expired bundled CA with a later expiry date.

The rest of the batch, and what it is worth

  • CVE-2026-81856 (2.3, CWE-150, hex 0.0.1 through 2.5.1) — terminal escape injection. Hex printed repository- and docs-service-supplied strings straight to the terminal: package metadata and config snippets in mix hex.info, search results, owner and organisation listings, publish and sponsor URLs, the username in mix hex.user, policy identities, API error bodies. ANSI cursor movement, line erase, OSC 52 clipboard writes and Unicode bidi overrides all reached the terminal unchanged — enough to forge a displayed URL or a suggested command. Requires only the ability to publish. Reported by Zach Daniel; this is the one record in the batch Peter Ullrich did not find. Note that 2.5.0 had already escaped control sequences in the x-hex-message header — one sink hardened, the dozen beside it missed.
  • CVE-2026-96618 (2.1) and CVE-2026-105274 (2.1, both CWE-409) — data amplification. Hex treats a configured mirror as untrusted (no credentials, signature and checksum checks it cannot forge) but consumed memory and disk before those checks ran: gzip responses decompressed without limit, tarballs downloaded without a size cap, docs tarballs extracted without the 16 MiB/128 MiB limits applied elsewhere. In hex_core the registry envelope is decompressed and decoded before its signature is verified, and binary_to_term(Binary, [safe]) accepts compressed terms with no size bound — the advisory states that the earlier fix for CVE-2026-21619 added the safe option without bounding the decoded size. CVE-2026-105274 is the batch’s only three-package record: hex_core before 0.20.0, hex 2.1.0 through 2.5.1, and rebar3 3.9.1 through 3.27.2, because both tools vendor the same code. That is why rebar3 needed a second release, 3.27.3, four hours after the first.

The low scores are defensible — these are availability and display issues against a mirror you chose to configure — but the ordering principle is worth naming, because it recurs across the batch: every one of these bugs is a check that runs after the work it was supposed to gate. Decompress then verify the signature. Read the whole body then validate the format. Delete the directory then discover the name was a path. Write to stdout then compare the checksum. The remediation in each case is not a new control; it is the existing control moved earlier.

What to do

  • Upgrade the toolchain, not the dependencies. Hex 2.5.2, hex_core 0.20.0, rebar3 3.27.3 (3.27.2 does not carry the hex_core fix), Gleam 1.19.2. All released 12 October 2026. Note that Hex is a Mix archive, not a project dependency — mix local.hex is the upgrade, and it is the step most CI images skip.
  • Rebuild your container images. Any image that baked in a Hex archive, a rebar3 escript or a gleam binary before 12 October ships the vulnerable client regardless of what your lockfile says. The Gleam advisory enumerates the affected ghcr.io tags explicitly (-erlang, -elixir, -node, -alpine, -slim, -scratch).
  • If you enabled the cooldown, re-read what it now promises. A retired lock no longer lifts it; an advisory-carrying lock still does, scoped to the package, its direct dependencies and the repository it is locked from. If you adopted HEX_COOLDOWN as a supply-chain control in the last three months, your exposure window was wider than the configured value for any package whose owner could retire your pinned release.
  • Treat dependency fetch as a privileged operation. The three path traversals need no code execution and no prompt injection. Running mix deps.get, rebar3 get-deps or gleam build over an untrusted dependency graph should happen in a container without credentials, as should any agent or bot that clones and builds repositories it did not author.
  • Stop piping mix hex.package fetch --output - into tar. On 2.5.2 the checksum is verified and messages go to stderr. On anything older, the pipeline has no integrity check and the first archive on the pipe may not be the package.

A note on the CNA

The EEF’s CVE programme published 2 records in Q2 2025 and 208 in Q3 2026. That trajectory is not a measure of the BEAM ecosystem getting worse; it is what a language community looks like after it acquires the ability to issue identifiers for its own packages. What this batch demonstrates is the second-order benefit: the records are written by people who read the code. The hex_core advisory analyses why its own README’s warning did not work. The Gleam record names five Rust functions and the file each lives in. The Hex cooldown record explains the permission equivalence that makes the bypass reachable. Compare that with the automated 5.3-everywhere batches this site has documented repeatedly — PraisonAI’s 24 advisories naming a version that was never published, or nginx-ui’s advisories pointing at git hashes — and the difference is not the severity of the bugs. It is whether the record tells a defender what to do. Ten CVEs, ten fixed versions that exist, published the day the releases shipped. That is the bar.

Verification note. All ten CVE identifiers, titles, CVSS 4.0 scores and vectors, CWE assignments, affected and fixed version ranges, credits and publication timestamps were read first-hand from the Erlang Ecosystem Foundation CNA’s own CVE records (cna.erlef.org JSON index and per-record JSON, CVE 5.1 format) and cross-checked against the NVD 2.0 API and the GitHub Security Advisories API for the hexpm/hex, hexpm/hex_core, erlang/rebar3 and gleam-lang/gleam repositories. Release tags and timestamps, and the commits b4e665d4, eed2bdf8, 1b3e2ae8, 72279a8e (hexpm/hex), f738c5bd and 07954d11 (hexpm/hexpm), e35282b2 (erlang/rebar3) and d15c981f (gleam-lang/gleam) were read first-hand from the GitHub API, including diffs where quoted. The Hex v2.5.0, v2.5.1 and v2.5.2 changelog text is quoted verbatim from CHANGELOG.md at tag v2.5.2. Quoted advisory sentences are verbatim from the CNA records. We did not publish a package, configure a mirror, construct a hostile app label, or execute any proof of concept; no request was sent to hex.pm beyond reading public metadata. The reading that the cooldown’s PR:H understates the threat, the “check runs after the work” framing, the fetch-is-the-exploit argument and the CNA-quality comparison are our editorial analysis. We have no evidence that any of these issues were exploited and imply none.

Sources: