Skip to content

avformat/http: http_buf_read_compressed returns 0 on non-progressing inflate, causing an infinite busy loop #51

Description

@ronag

Summary

When inflate() makes no output progress and consumes no input, http_buf_read_compressed() returns size - avail_out == 0. A 0 return from a protocol url_read is neither EOF nor an error, so retry_transfer_wrapper() in libavformat/avio.c simply retries immediately (len += 0, no sleep — only AVERROR(EAGAIN) enters the backoff path). Two server-controlled situations make zlib permanently non-progressing: (a) a corrupt or raw-deflate body (a common interop case: servers sending raw deflate for Content-Encoding: deflate) puts zlib into its BAD state, after which every inflate() call returns Z_DATA_ERROR consuming nothing; (b) trailing bytes after the end of the gzip/zlib stream (e.g. multi-member gzip or appended padding) leave avail_in > 0 while zlib is in its DONE state, so every call returns Z_STREAM_END with no output. In both cases ffurl_read spins at 100% CPU forever; only the interrupt callback can break out.

Location

  • http_buf_read_compressed():

    FFmpeg/libavformat/http.c

    Lines 1852 to 1880 in 9a83bff

    static int http_buf_read_compressed(URLContext *h, uint8_t *buf, int size)
    {
    HTTPContext *s = h->priv_data;
    int ret;
    if (!s->inflate_buffer) {
    s->inflate_buffer = av_malloc(DECOMPRESS_BUF_SIZE);
    if (!s->inflate_buffer)
    return AVERROR(ENOMEM);
    }
    if (s->inflate_stream.avail_in == 0) {
    int read = http_buf_read(h, s->inflate_buffer, DECOMPRESS_BUF_SIZE);
    if (read <= 0)
    return read;
    s->inflate_stream.next_in = s->inflate_buffer;
    s->inflate_stream.avail_in = read;
    }
    s->inflate_stream.avail_out = size;
    s->inflate_stream.next_out = buf;
    ret = inflate(&s->inflate_stream, Z_SYNC_FLUSH);
    if (ret != Z_OK && ret != Z_STREAM_END)
    av_log(h, AV_LOG_WARNING, "inflate return value: %d, %s\n",
    ret, s->inflate_stream.msg);
    return size - s->inflate_stream.avail_out;
    }
    (the problematic return path is

    FFmpeg/libavformat/http.c

    Lines 1874 to 1879 in 9a83bff

    ret = inflate(&s->inflate_stream, Z_SYNC_FLUSH);
    if (ret != Z_OK && ret != Z_STREAM_END)
    av_log(h, AV_LOG_WARNING, "inflate return value: %d, %s\n",
    ret, s->inflate_stream.msg);
    return size - s->inflate_stream.avail_out;
    )
  • Looping caller retry_transfer_wrapper():

    FFmpeg/libavformat/avio.c

    Lines 509 to 552 in 9a83bff

    static inline int retry_transfer_wrapper(URLContext *h, uint8_t *buf,
    const uint8_t *cbuf,
    int size, int size_min,
    int read)
    {
    int ret, len;
    int fast_retries = 5;
    int64_t wait_since = 0;
    len = 0;
    while (len < size_min) {
    if (ff_check_interrupt(&h->interrupt_callback))
    return AVERROR_EXIT;
    ret = read ? h->prot->url_read (h, buf + len, size - len):
    h->prot->url_write(h, cbuf + len, size - len);
    if (ret == AVERROR(EINTR))
    continue;
    if (h->flags & AVIO_FLAG_NONBLOCK)
    return ret;
    if (ret == AVERROR(EAGAIN)) {
    ret = 0;
    if (fast_retries) {
    fast_retries--;
    } else {
    if (h->rw_timeout) {
    if (!wait_since)
    wait_since = av_gettime_relative();
    else if (av_gettime_relative() > wait_since + h->rw_timeout)
    return AVERROR(EIO);
    }
    av_usleep(1000);
    }
    } else if (ret == AVERROR_EOF)
    return (len > 0) ? len : AVERROR_EOF;
    else if (ret < 0)
    return ret;
    if (ret) {
    fast_retries = FFMAX(fast_retries, 2);
    wait_since = 0;
    }
    len += ret;
    }
    return len;
    }
  • Compressed-read routing:

    FFmpeg/libavformat/http.c

    Lines 1903 to 1906 in 9a83bff

    #if CONFIG_ZLIB
    if (s->compressed)
    return http_buf_read_compressed(h, buf, size);
    #endif /* CONFIG_ZLIB */
  • parse_content_encoding():

    FFmpeg/libavformat/http.c

    Lines 974 to 992 in 9a83bff

    static int parse_content_encoding(URLContext *h, const char *p)
    {
    if (!av_strncasecmp(p, "gzip", 4) ||
    !av_strncasecmp(p, "deflate", 7)) {
    #if CONFIG_ZLIB
    HTTPContext *s = h->priv_data;
    s->compressed = 1;
    inflateEnd(&s->inflate_stream);
    if (inflateInit2(&s->inflate_stream, 32 + 15) != Z_OK) {
    av_log(h, AV_LOG_WARNING, "Error during zlib initialisation: %s\n",
    s->inflate_stream.msg);
    return AVERROR(ENOSYS);
    }
    if (zlibCompileFlags() & (1 << 17)) {
    av_log(h, AV_LOG_WARNING,
    "Your zlib was compiled without gzip support.\n");
    return AVERROR(ENOSYS);
    }

Details

libavformat/http.c, lines 1852–1880:

static int http_buf_read_compressed(URLContext *h, uint8_t *buf, int size)
{
    HTTPContext *s = h->priv_data;
    int ret;

    if (!s->inflate_buffer) {
        s->inflate_buffer = av_malloc(DECOMPRESS_BUF_SIZE);
        if (!s->inflate_buffer)
            return AVERROR(ENOMEM);
    }

    if (s->inflate_stream.avail_in == 0) {
        int read = http_buf_read(h, s->inflate_buffer, DECOMPRESS_BUF_SIZE);
        if (read <= 0)
            return read;
        s->inflate_stream.next_in  = s->inflate_buffer;
        s->inflate_stream.avail_in = read;
    }

    s->inflate_stream.avail_out = size;
    s->inflate_stream.next_out  = buf;

    ret = inflate(&s->inflate_stream, Z_SYNC_FLUSH);
    if (ret != Z_OK && ret != Z_STREAM_END)
        av_log(h, AV_LOG_WARNING, "inflate return value: %d, %s\n",
               ret, s->inflate_stream.msg);

    return size - s->inflate_stream.avail_out;
}

Step-by-step trace, case (a) — corrupt / raw-deflate body:

  1. Server responds with Content-Encoding: deflate but sends a raw-deflate body (no zlib header). parse_content_encoding() (http.c:974–992) sets s->compressed = 1 and calls inflateInit2(&s->inflate_stream, 32 + 15), which only accepts zlib- or gzip-wrapped streams.
  2. http_read_stream() (http.c:1903–1906) routes all reads to http_buf_read_compressed().
  3. First call: avail_in == 0, so http_buf_read() fills inflate_buffer (lines 1863–1869).
  4. inflate() at line 1874 fails header validation and returns Z_DATA_ERROR, consuming no input and producing no output. zlib's internal mode becomes BAD, so every subsequent inflate() call also returns Z_DATA_ERROR consuming nothing.
  5. Lines 1875–1877 only log a warning; line 1879 returns size - avail_out = 0.
  6. Caller chain: http_readffurl_readretry_transfer_wrapper() (avio.c:509–552). ret == 0 matches no terminating branch: it is not AVERROR(EINTR), not AVERROR(EAGAIN) (so no av_usleep backoff), not AVERROR_EOF, and not < 0; if (ret) is false; len stays 0 < size_min; the loop immediately re-invokes url_read. Since avail_in is still non-zero, the refill at line 1863 is skipped and step 4 repeats forever — a 100% CPU busy loop on dead zlib state, breakable only by the interrupt callback.

Case (b) — trailing bytes after stream end: the gzip/zlib stream ends (Z_STREAM_END, zlib mode DONE) but the HTTP body contains extra bytes after it (multi-member gzip, appended padding/garbage). avail_in > 0 skips the refill, and inflate() on a DONE stream returns Z_STREAM_END with no output and no input consumed. Return value is again 0, and the same infinite spin occurs.

Note: the benign "gzip header split across reads" case also returns 0, but there inflate() consumes its input, so avail_in drops to 0 and the next call refills and makes progress — that case must keep working.

Impact

Severity: medium. A malicious or merely misconfigured HTTP server (raw deflate for Content-Encoding: deflate is a well-known real-world interop issue; trailing junk / multi-member gzip likewise) can make any ffmpeg/libavformat client hang forever at 100% CPU inside ffurl_read. No data is delivered, no error is surfaced (only a log warning on each spin iteration for case (a)), and rw_timeout does not help because the timeout logic in retry_transfer_wrapper is only reached via the AVERROR(EAGAIN) path. Only an application-supplied interrupt callback can abort. This is effectively a remotely triggerable denial of service against the client. It is distinct from the previously reported EAGAIN-escape issue: the loop here is driven by the 0-byte non-progress return from the inflate handling, not by EAGAIN propagation.

Suggested fix

Turn non-progress into a proper error/EOF (verified against current code at lines 1874–1879):

     ret = inflate(&s->inflate_stream, Z_SYNC_FLUSH);
-    if (ret != Z_OK && ret != Z_STREAM_END)
-        av_log(h, AV_LOG_WARNING, "inflate return value: %d, %s\n",
-               ret, s->inflate_stream.msg);
-
-    return size - s->inflate_stream.avail_out;
+    if (ret != Z_OK && ret != Z_STREAM_END) {
+        av_log(h, AV_LOG_WARNING, "inflate return value: %d, %s\n",
+               ret, s->inflate_stream.msg);
+        return AVERROR_INVALIDDATA;
+    }
+    if (ret == Z_STREAM_END && s->inflate_stream.avail_out == (unsigned)size)
+        return AVERROR_EOF; /* stream done; ignore trailing input */
+    return size - s->inflate_stream.avail_out;

The benign split-header case still returns 0 with avail_in fully consumed and recovers on the next refill. (Returning EOF for trailing input intentionally ignores extra gzip members; a fuller fix could inflateReset() and continue decoding concatenated members, as curl does, but EOF is the minimal safe behavior compared to the current infinite loop.)

Upstream status

The identical code exists in upstream FFmpeg (upstream/master:libavformat/http.c, inflate() call at line 1810 of the current upstream file) — this is not a fork regression and is worth reporting/fixing upstream as well.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinghttplibavformat/http.csecuritySecurity-relevant defectupstreamAlso present in upstream FFmpeg

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions