Skip to content

Assertion failure in cram_index_build() on a malformed CRAM file #2060

Description

@CrJyyy

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions