CVE-2026-81638 Overview
CVE-2026-81638 is an Improper Handling of Alternate Encoding vulnerability [CWE-173] in the ash-project/ash_double_entry library. The AshDoubleEntry.ULID type renders a 128-bit ULID as 26 Crockford base-32 characters, but the first character encodes only 3 bits. The decode/1 function in lib/ulid.ex masks the first character to its low 3 bits, and valid?/1 accepts all 32 characters in that position. As a result, ULIDs starting with 0, 8, G, or R decode to the identical 16-byte value and resolve to the same database row. Affected versions include ash_double_entry from 0.1.0 before 1.0.19.
Critical Impact
Attackers can submit multiple distinct string spellings of the same identifier, desynchronizing idempotency keys, deduplication logic, deny-lists, audit correlation, and signatures computed over the submitted ID.
Affected Products
- ash-project/ash_double_entry versions 0.1.0 through 1.0.18
- Elixir/Ash applications exposing AshDoubleEntry.ULID as a public ID over HTTP or API boundaries
- Any downstream system relying on string equality of these IDs for security decisions
Discovery Timeline
- 2026-09-07 - CVE-2026-81638 published to NVD
- 2026-09-08 - Last updated in NVD database
Technical Details for CVE-2026-81638
Vulnerability Analysis
The AshDoubleEntry.ULID type encodes a 128-bit ULID into 26 Crockford base-32 characters. Each base-32 character normally encodes 5 bits, yielding 130 bits total. To fit 128 bits, the encoder writes only the low 3 bits of the first character. The decoder in lib/ulid.ex mirrors this by masking the first character to 3 bits during decoding, but the valid?/1 check accepts any of the 32 Crockford characters in that leading position. This asymmetry means four different characters (0, 8, G, R) map to bit pattern 000 after masking, and similarly for values 1/9/H/S, and so on. Each canonical ULID therefore has three non-canonical alternate spellings that decode to the same 16-byte binary and resolve to the same record.
Root Cause
The root cause is a violation of the invariant encode(decode(x)) == x. Non-canonical inputs are accepted by validation but silently normalized during decoding. This is a textbook alternate encoding flaw [CWE-173], where security-relevant logic operating on the string form of an identifier sees a different value than the storage layer operating on the decoded form.
Attack Vector
An attacker submits an ID with a modified leading character over an HTTP or API boundary. String-level checks such as idempotency keys, deduplication tables, deny-lists, HMAC signatures over the ID string, or audit-log correlation compare the raw submitted string. The database layer decodes the ID and returns the same row as the canonical spelling. This allows replay of previously-processed operations, bypass of deny-listed IDs, forged audit trails, or signature reuse across alternate spellings of the same resource.
c13::8, c14::8, c15::8, c16::8, c17::8, c18::8, c19::8, c20::8, c21::8, c22::8, c23::8,
c24::8, c25::8, c26::8>>
) do
+ # The first Crockford character encodes only 3 bits (encode/1 writes
+ # `b1::3`). Reject any input whose first character sets the unused high
+ # bits (decodes above 7) so that each ULID has exactly one canonical
+ # spelling and `encode(decode(x)) == x` for every accepted value.
+ if d(c1) > 7, do: throw(:error)
+
<<d(c1)::3, d(c2)::5, d(c3)::5, d(c4)::5, d(c5)::5, d(c6)::5, d(c7)::5, d(c8)::5, d(c9)::5,
d(c10)::5, d(c11)::5, d(c12)::5, d(c13)::5, d(c14)::5, d(c15)::5, d(c16)::5, d(c17)::5,
d(c18)::5, d(c19)::5, d(c20)::5, d(c21)::5, d(c22)::5, d(c23)::5, d(c24)::5, d(c25)::5,
Source: GitHub Commit d3e688d. The patch rejects any input whose first character decodes above 7, enforcing a single canonical spelling per ULID.
Detection Methods for CVE-2026-81638
Indicators of Compromise
- Requests containing ULID path or body parameters whose first character is 8, 9, A-H, J-N, P-R, S-Z (any character where d(c1) > 7).
- Multiple audit-log entries referencing distinct ULID strings that resolve to the same database record.
- Idempotency-key hits followed by successful reprocessing of an apparently new request with a differently-spelled ID.
Detection Strategies
- Log the raw submitted ID string alongside the canonical re-encoded form and alert when they differ.
- Compare application-layer ID strings against database primary-key strings to surface encoding drift.
- Run static analysis on Elixir dependencies to flag ash_double_entry versions below 1.0.19.
Monitoring Recommendations
- Instrument HTTP handlers that accept ULIDs to record request rate by first-character class and alert on non-0-7 prefixes.
- Monitor idempotency and deduplication stores for entries whose decoded binary matches an existing key but whose string form differs.
- Correlate signature verification failures with subsequent successful retries using the same underlying resource.
How to Mitigate CVE-2026-81638
Immediate Actions Required
- Upgrade ash_double_entry to version 1.0.19 or later across all Elixir applications.
- Audit existing idempotency, deduplication, and deny-list stores for entries with non-canonical first characters and normalize them.
- Review audit logs for operations referencing ULIDs whose first character exceeds 7 and validate that no unauthorized reprocessing occurred.
Patch Information
The fix is committed in GitHub Commit d3e688d and released in ash_double_entry1.0.19. Full details are in the GitHub Security Advisory GHSA-qxp2-vgp9-268q, the CNA CVE-2026-81638 Details, and the OSV Vulnerability EEF-CVE-2026-81638 record. The patch adds a guard in decode/1 that rejects inputs whose first character decodes above 7.
Workarounds
- If patching is not immediately possible, add an application-layer validator that rejects ULIDs whose first character is not in the set 0-7.
- Canonicalize all inbound ULIDs by decoding and re-encoding before using them in idempotency keys, signatures, or deny-list lookups.
- Enforce a database-level constraint or check that stored ULID primary keys always match their canonical string form.
# Update the dependency in mix.exs and fetch the patched release
# mix.exs
# {:ash_double_entry, "~> 1.0.19"}
mix deps.update ash_double_entry
mix deps.get
mix compile --force
Disclaimer: This content was generated using AI. While we strive for accuracy, please verify critical information with official sources.