Summary
A 42-byte file makes sam_index_build2() abort on an assertion instead of returning an error.
cram_index_build() is documented to return -1 on read failure, but a container whose first
block is not a compression header trips assert() and kills the process.
$ base64 -d <<< 'Q1JBTQEAAAAAAAAAAAAAZm9vb29vAQAAAAAAAAAAAABmYUAAAABvAAAA' > poc.cram
$ ./repro poc.cram
repro: cram/cram_index.c:823: cram_index_build: Assertion
`c->comp_hdr_block->content_type == COMPRESSION_HEADER' failed.
Aborted (core dumped)
repro.c is eleven lines and does only what samtools index <file> does:
#include <stdio.h>
#include "htslib/sam.h"
int main(int argc, char **argv)
{
if (argc < 2) { fprintf(stderr, "usage: %s <file.cram>\n", argv[0]); return 2; }
int r = sam_index_build2(argv[1], NULL, 0);
printf("sam_index_build2 returned %d\n", r);
return 0;
}
Version
Reproduced on the 1.24 release tarball (latest, 2026-07-09) and on current develop; the
assertion is at cram/cram_index.c:823 in both.
$ ./configure --disable-bz2 --disable-lzma --disable-libcurl --disable-gcs --disable-s3
$ make lib-static
$ gcc -O2 -g -I. -o repro repro.c libhts.a -lz -lpthread -lm
No sanitizer, no fuzzing engine, no special flags. HTSlib's own configure does not define
NDEBUG, so the assertion is live in a default build.
Host: Linux x86-64, GCC.
Cause
cram_index_build() (cram/cram_index.c:787) is documented as
/*
* Builds an index file.
* ...
* Returns 0 on success,
* negative on failure (-1 for read failure, -4 for write failure)
*/
but the container loop asserts on the content type of a block that comes straight from the file:
while ((c = cram_read_container(fd))) {
...
if (!(c->comp_hdr_block = cram_read_block(fd)))
goto err;
assert(c->comp_hdr_block->content_type == COMPRESSION_HEADER); /* line 823 */
c->comp_hdr = cram_decode_compression_header(fd, c->comp_hdr_block);
if (!c->comp_hdr)
goto err;
The block immediately before and the one immediately after are both handled with goto err.
Only the content-type check is an assertion, and nothing upstream of it guarantees the invariant
— cram_read_block() returns whatever type the file declares.
Impact
Assertion abort only. Rebuilding all of HTSlib with -DNDEBUG and running the same input gives
$ ./repro_ndebug poc.cram
sam_index_build2 returned -1
and an ASan+UBSan build of the same configuration is clean on it, so cram_decode_compression_header()
rejects the block through the normal error path and there is no memory-safety consequence behind the
assertion. The practical effect is that any program indexing an untrusted CRAM with a default-built
HTSlib aborts rather than reporting a bad file.
Possible fix
Treating it like the surrounding failures fixes it:
--- a/cram/cram_index.c
+++ b/cram/cram_index.c
@@ -820,7 +820,10 @@
if (!(c->comp_hdr_block = cram_read_block(fd)))
goto err;
- assert(c->comp_hdr_block->content_type == COMPRESSION_HEADER);
+ if (c->comp_hdr_block->content_type != COMPRESSION_HEADER) {
+ hts_log_error("Expected a compression header block");
+ goto err;
+ }
With that applied:
$ ./repro_fixed poc.cram
[E::cram_index_build] Expected a compression header block
sam_index_build2 returned -1
test/test.pl gives 349 passed / 7 failed out of 356 — byte-identical to the unpatched 1.24 tree
in this environment (the 7 failures are pre-existing here and come from configuring without bz2 /
lzma / libcurl). I have not checked whether this is the fix you would prefer; you may want the
container reader to reject the block earlier instead.
Note on coverage
test/fuzz/hts_open_fuzzer.c never calls sam_index_build* or any other index-building entry
point, so this path is not reachable from the OSS-Fuzz target — which is presumably why it has
survived.
How it was found
Automated fuzzing that called sam_index_build2() on a temporary file filled with fuzzer bytes.
The 162-byte original was minimised to the 42 bytes above and re-verified against a stock 1.24
release build, so the reproducer involves no fuzzing harness.
Summary
A 42-byte file makes
sam_index_build2()abort on an assertion instead of returning an error.cram_index_build()is documented to return-1on read failure, but a container whose firstblock is not a compression header trips
assert()and kills the process.repro.cis eleven lines and does only whatsamtools index <file>does:Version
Reproduced on the 1.24 release tarball (latest, 2026-07-09) and on current
develop; theassertion is at
cram/cram_index.c:823in both.No sanitizer, no fuzzing engine, no special flags. HTSlib's own configure does not define
NDEBUG, so the assertion is live in a default build.Host: Linux x86-64, GCC.
Cause
cram_index_build()(cram/cram_index.c:787) is documented asbut the container loop asserts on the content type of a block that comes straight from the file:
The block immediately before and the one immediately after are both handled with
goto err.Only the content-type check is an assertion, and nothing upstream of it guarantees the invariant
—
cram_read_block()returns whatever type the file declares.Impact
Assertion abort only. Rebuilding all of HTSlib with
-DNDEBUGand running the same input givesand an ASan+UBSan build of the same configuration is clean on it, so
cram_decode_compression_header()rejects the block through the normal error path and there is no memory-safety consequence behind the
assertion. The practical effect is that any program indexing an untrusted CRAM with a default-built
HTSlib aborts rather than reporting a bad file.
Possible fix
Treating it like the surrounding failures fixes it:
With that applied:
test/test.plgives 349 passed / 7 failed out of 356 — byte-identical to the unpatched 1.24 treein this environment (the 7 failures are pre-existing here and come from configuring without bz2 /
lzma / libcurl). I have not checked whether this is the fix you would prefer; you may want the
container reader to reject the block earlier instead.
Note on coverage
test/fuzz/hts_open_fuzzer.cnever callssam_index_build*or any other index-building entrypoint, so this path is not reachable from the OSS-Fuzz target — which is presumably why it has
survived.
How it was found
Automated fuzzing that called
sam_index_build2()on a temporary file filled with fuzzer bytes.The 162-byte original was minimised to the 42 bytes above and re-verified against a stock 1.24
release build, so the reproducer involves no fuzzing harness.