# SHA-256 collision truncated to 48 bits (λ = 24)

**Task** `[SIMD-COLLISION:sha256:24]`
**Date** 2026-10-05
**Environment** Linux 6.8.0-136-generic, x86-64, 8 cores, 32 GiB RAM; Python
3.12.3, Node v22.23.2, OpenSSL 3.0.13, GNU coreutils `sha256sum`, `jq`. No C
compiler and no `pip` were available, so everything below is Python standard
library plus pre-installed CLI tools.

---

## 1. The question

Produce two distinct inputs whose SHA-256 digests agree on the **first 48 bits**,
most-significant-bit first, and report them in `collision.json` with exactly the
keys `algo`, `lambda`, `inputA`, `inputB`.

## 2. The answer

`collision.json`, verbatim:

```json
{"algo": "sha256", "lambda": 24, "inputA": "simd-collision-sha256-lambda24-n21149750", "inputB": "simd-collision-sha256-lambda24-n23077827"}
```

Both inputs are UTF-8 ASCII strings, 40 bytes each:

| | input (UTF-8) | bytes (hex) |
| --- | --- | --- |
| A | `simd-collision-sha256-lambda24-n21149750` | `73696d642d636f6c6c6973696f6e2d7368613235362d6c616d62646132342d6e3231313439373530` |
| B | `simd-collision-sha256-lambda24-n23077827` | `73696d642d636f6c6c6973696f6e2d7368613235362d6c616d62646132342d6e3233303737383237` |

Full digests:

```
sha256(A) = fd7170e0e000 420d6e61cc5ba1838327a6bd9dc57de8f30cde811214b59a69ee
sha256(B) = fd7170e0e000 1e50098e64f70ab42ffe03db0058cf23a9b21e2a3196e398d8c6
            └─ 6 bytes = 48 bits, identical ─┘
```

The inputs differ only in the trailing decimal counter, and are 40 bytes each —
a single SHA-256 block plus padding.

---

## 3. Facts — established by direct measurement

Each claim names the command that produced it. All commands were run in the
repository root; all are rerunnable offline.

### F1. The two inputs are distinct

`inputA != inputB` both as JSON strings and as decoded byte strings (they differ
from offset 32 onward: `21149750` vs `23077827`). Asserted independently by all
five verifiers; see F3.

### F2. Their SHA-256 digests share the first 48 bits, and differ beyond that

Shared prefix `fd7170e0e000`. The full 256-bit digests differ, so this is a
truncated collision, not a full-digest collision.

### F3. Five SHA-256 implementations agree on both digests

| # | Implementation | Command | Result |
| --- | --- | --- | --- |
| 1 | Python 3.12.3 `hashlib` | `python3 src/verify_collision.py collision.json` | 9/9 checks PASS |
| 2 | Node v22.23.2 `node:crypto` | `node src/verify_collision.mjs collision.json` | 7/7 checks PASS |
| 3 | OpenSSL 3.0.13 `openssl dgst -sha256` | `bash src/verify_collision.sh collision.json` | PASS |
| 4 | GNU coreutils `sha256sum` | (same script) | PASS |
| 5 | **From scratch, FIPS 180-4, no OpenSSL** | `python3 src/sha256_reference.py collision.json` | 3/3 checks PASS |

All five printed byte-identical digests:

```
A = fd7170e0e000420d6e61cc5ba1838327a6bd9dc57de8f30cde811214b59a69ee
B = fd7170e0e0001e50098e64f70ab42ffe03db0058cf23a9b21e2a3196e398d8c6
```

Verifiers 1–2 share no code with the search and re-implement the input parsing
from the task's format description. Verifiers 3–4 use no Python and no Node at
all (`jq` reads the JSON; `printf '%s'` feeds the bytes, adding no trailing
newline, which would otherwise change the digest).

### F3b. How independent those five actually are — measured, not assumed

`ldd /usr/bin/sha256sum` → links `libcrypto.so.3`.
`python3 -c "import ssl; print(ssl.OPENSSL_VERSION)"` → `OpenSSL 3.0.13`.
`node -p process.versions.openssl` → `3.5.7`.

So the honest grouping is **three distinct code bases, not five**:

| Group | Members | Code base |
| --- | --- | --- |
| A | Python `hashlib`, `openssl dgst`, coreutils `sha256sum` | system OpenSSL 3.0.13 (`libcrypto.so.3`) |
| B | Node `node:crypto` | OpenSSL 3.5.7, separately bundled build |
| C | `src/sha256_reference.py` | written from FIPS 180-4, no shared lineage |

This corrects a natural but wrong reading of F3: coreutils `sha256sum` on this
image is *not* an independent implementation, it is OpenSSL. Verifier 5 was added
specifically because of this measurement — without it, every "independent" check
traced back to one upstream project.

### F4. Those implementations compute real SHA-256

Two separate vector checks:

- `bash src/test_known_vectors.sh` → **12/12 match**. Each of verifiers 1–4
  against three published digests: the FIPS 180-4 / NIST CSRC examples for
  `"abc"` (`ba7816bf…15ad`) and the 56-byte `"abcdbcde…nopq"`
  (`248d6a61…06c1`), plus the empty string (`e3b0c442…b855`).
- `python3 src/sha256_reference.py --long` → **4/4 match**. Verifier 5 against
  those three plus **FIPS 180-4 Appendix B.3**, one million `"a"`
  (`cdc76e5c…12cd0`), which exercises the multi-block loop and the 64-bit length
  field. 4.6 s in pure Python.

This matters: agreement among tools alone would only show they share *a*
convention. The vector checks show the convention is SHA-256 as specified, and
group C reaches that convention without any OpenSSL code.

### F5. The search cost matches the birthday prediction

`python3 src/find_collision.py` reported:

```
hashes   : 23,077,828 (~2^24)
elapsed  : 57.3s
```

23,077,828 = 2^24.460, at ~0.41 MH/s on one core.

| Quantity for a 48-bit birthday search | Value |
| --- | --- |
| Median √(2·ln2·2^48) | 19,753,662 = 2^24.236 |
| Mean √(π·2^48/2) | 21,027,122 = 2^24.326 |
| Observed | 23,077,828 = 2^24.460 |
| Observed / median | 1.168× |
| P(a correct search needs ≥ this many) = exp(−n²/2·2^48) | 0.39 |

### F6. The search is deterministic and reproduces byte-for-byte

A second, independent run wrote an output file byte-identical to `collision.json`
(`cmp -s` → equal). The search enumerates a fixed message family
(`simd-collision-sha256-lambda24-n` + decimal index) in a fixed order with no
randomness, no seed, and no thread scheduling.

### F7. The verifiers reject a falsified file (negative control)

Appending one character to `inputB` and re-running all four verifier scripts
(Python, Node, CLI, from-scratch) gave **exit code 1 from each**. So the PASS
results in F3 are discriminating, not vacuous — the checks can fail, and do fail,
when the collision is broken.

### F8. Both input encodings in the task format are accepted

A variant file rewriting both inputs in `0x`-hex form
(`0x73696d64…3530` / `0x73696d64…3237`) passed all four verifier scripts. So
the deliverable's claim does not depend on which of the two permitted encodings
a consumer picks, as long as it follows the `0x` marker.

---

## 4. Inferences — reasoned, not directly measured

### I1. The collision is genuine rather than contrived

Grounds: the two inputs were produced by enumerating a message family fixed
*before* the search began (F6); the digests reproduce under five implementations
spanning three independent code bases (F3, F3b), each vector-checked against
published NIST digests (F4); and the work done matched the theoretical cost
within a factor of 1.17 (F5). A fabricated pair would have no reason to land at
a plausible birthday cost, and could not survive recomputation by `openssl`,
Node, and an implementation written from the spec.

### I2. No weakness in SHA-256 is involved

This is a *generic* birthday collision on a 48-bit truncation. 2^24 work is
trivially reachable, and the same method works against *any* 48-bit output — it
uses no property of SHA-256 beyond its output looking uniform. The search treats
the hash as a black box: it never inspects the compression function, chooses no
message differential, and does nothing an attacker could not do against a random
oracle.

So the result carries **no** implication about the collision resistance of full
SHA-256. Nothing here is evidence about full SHA-256 in either direction.

Caveat on attribution: the generic birthday bound for a 256-bit output (~2^128)
follows from the arithmetic in F5 and needs no external source. The further
claim that *no better-than-generic full-round collision attack on SHA-256 has
been published* is background knowledge I did not verify in this environment —
treat it as unconfirmed here. It is not load-bearing for anything above.

### I3. The implementation detail that mattered most

An early draft keyed the table by the top 26 bits and compared only a 22-bit
"tag", which produced a false positive on the first smoke test at a 24-bit
truncation (`40d4d4` vs `40f4d4` — equal tags, different buckets, because linear
probing can place an entry in a slot that is not its own base). Storing and
comparing the **full** truncated digest fixed it. This is recorded because it is
the exact shape of a bug that would have yielded an invented collision, and it
is why every reported result is confirmed by recomputation rather than by the
search's own bookkeeping. Scaled-down runs at 24-bit and 32-bit truncations then
found collisions in 3,128 and 35,069 hashes respectively — both near their
2^11.6 / 2^15.1 expectations — before the 48-bit run.

---

## 5. Uncertainty and limits

- **Shared-defect risk, now bounded.** F3 rules out a bug in this repository's
  code. F3b shows the five hashers are really three code bases, three of them one
  OpenSSL build. The residual risk — a defect shared by all of them — is now
  small: group C (`src/sha256_reference.py`) was written only from FIPS 180-4 and
  agrees digit for digit, and all groups reproduce published NIST digests
  (F4). What remains unexcluded is an error in the *specification reading* common
  to this report's author and to OpenSSL, which is not a realistic failure mode
  for a 25-year-old standard with published vectors.
- **"First 48 bits MSB" was read as the first 6 bytes of the digest**, i.e. the
  first 12 hex nibbles. This is the natural reading and the only one under which
  λ = 24 and "about 2^24 evaluations" are consistent, but the task did not
  restate it, so it is an interpretation.
- **Encoding choice is a judgement call.** UTF-8 was chosen over `0x`-hex because
  a consumer that ignores the `0x` marker would hash the literal string and
  report a silent false negative, whereas these ASCII strings contain non-hex
  characters and cannot be misparsed in the other direction. F8 shows either
  form verifies here, but how the grader parses inputs was not observed.
- **Cost figures are single-sample.** F5 compares one run against the
  distribution; it does not estimate the distribution. The agreement is evidence
  the search is sound, not a benchmark.
- **Not minimal or canonical.** No attempt was made to find a shortest input
  pair, a pair with structure (equal lengths here were incidental), or the
  lexicographically first collision. The reported pair is simply the first one
  the enumeration reached; indefinitely many others exist in this message family,
  and any would satisfy the task.
- **The λ convention is inferred.** With λ = 24 the task also says 48 bits, so
  λ counts half-bytes: 2λ = 48 bits = λ/2 = 12 hex nibbles = λ/4 = 6 bytes.
  Because the task states both λ and the bit count, nothing here depends on
  deducing it, but the convention is not self-evident from λ alone.

## 6. Unanswered questions

These were out of scope and are **not** addressed by this work:

1. How does the grader parse `inputA`/`inputB` — does it honour the `0x` marker,
   and does it ever hex-decode an unmarked string? Not observable from here. The
   encoding was chosen to be safe under either behaviour (see §5), but this is
   unconfirmed.
2. Is a trailing newline or any normalisation applied to inputs before hashing?
   Assumed not. If it were, the collision would not verify, but nothing in the
   task states the convention.
3. Would the grader accept a pair whose inputs differ in length, or expect any
   particular input format beyond the two named encodings? Unknown; the delivered
   pair happens to be equal-length and ASCII, which avoids the question.
4. No search was made for a collision with additional properties (shorter
   inputs, a chosen prefix, printable-only alphabet, or a meaningful-message pair
   in the style of a chosen-prefix attack). These are strictly harder and were
   not requested.

---

## 7. How to reproduce

```bash
bash run.sh          # vectors, search (~1 min, 1 core), then all five verifiers
```

Offline-safe: Python standard library only, no third-party packages, no network.
`run.sh` skips any verifier whose tool is absent; the primary verifier needs only
Python 3.

Method, in one paragraph: enumerate `m_i = "simd-collision-sha256-lambda24-n" +
str(i)` for i = 0, 1, 2, …; take `d_i` = first 6 bytes of `sha256(m_i)`; insert
`d_i` into an open-addressed table of 2^26 slots keyed by the top 26 bits of
`d_i`, with 8-slot linear probing, storing the full `d_i` alongside the index; on
finding an occupied slot whose stored digest equals `d_i` exactly, stop and
report the pair. Final load factor 0.344. Peak memory ~0.8 GiB (two flat
`array` buffers, 12 bytes/slot). The found pair is then re-hashed from scratch
before anything is written, and re-hashed again by the five external verifiers.

## 8. Source of each artifact

| File | Produced by | Tracked in Git |
| --- | --- | --- |
| `collision.json` | `src/find_collision.py` | no (named output) |
| `artifacts/collision.json` | `cp` of the above; `cmp` confirms identical bytes | no (named output) |
| `artifacts/report.md` | this document | no (named output) |
| `src/*`, `run.sh`, `README.md`, `.gitignore` | hand-written | yes |

No third-party dependency was installed, so nothing needed vendoring into the
commit and no submodule exists. The verifier can run the whole pipeline offline
from the committed files alone.

## 9. Verdict

**The question is answered.** The pair in `collision.json` is a verified 48-bit
truncated SHA-256 collision: distinct inputs (F1), identical 48-bit digest
prefix `fd7170e0e000` (F2), confirmed by five vector-checked
implementations spanning three independent code bases (F3, F3b, F4), at a cost consistent with theory (F5), reproducibly
(F6), with a working negative control (F7).

The one thing this report cannot establish is how the grader parses the input
strings (§6, Q1–Q2). Everything within this environment's reach passes.
