diff --git a/package.xml b/package.xml index 524a51e1..fadf9352 100644 --- a/package.xml +++ b/package.xml @@ -97,6 +97,9 @@ http://pear.php.net/dtd/package-2.0.xsd"> + + + diff --git a/php_memcached.c b/php_memcached.c index 47ed2d33..af89f64a 100644 --- a/php_memcached.c +++ b/php_memcached.c @@ -3713,12 +3713,26 @@ memcached_return s_server_cursor_version_cb(const memcached_st *ptr, php_memcach } +/* Most uncompressed bytes a single compressed byte can produce, so a stored + * length that no input of this size could reach is rejected before it is + * allocated: + * - zlib: a 258-byte match costs at least two bits, a limit of 1032:1 + * (https://zlib.net/zlib_tech.html). + * - FastLZ: a level 2 match grows by at most 255 bytes per input byte, and a + * level 1 match yields at most 264 bytes from three (fastlz/fastlz.c). + * - zstd: an RLE block repeats one byte up to 128 KiB behind a three-byte + * block header (RFC 8878, section 3.1.1.2). */ +#define MEMC_ZLIB_MAX_RATIO 1032 +#define MEMC_FASTLZ_MAX_RATIO 255 +#define MEMC_ZSTD_MAX_RATIO ((128 * 1024) / 4) + static zend_string *s_decompress_value (const char *payload, size_t payload_len, uint32_t flags) { zend_string *buffer; uint32_t stored_length; + uint32_t max_ratio; unsigned long length; zend_bool decompress_status = 0; zend_bool is_fastlz = 0, is_zlib = 0, is_zstd = 0; @@ -3748,27 +3762,53 @@ zend_string *s_decompress_value (const char *payload, size_t payload_len, uint32 payload += sizeof (uint32_t); payload_len -= sizeof (uint32_t); - buffer = zend_string_alloc (stored_length, 0); + if (is_zstd) { + max_ratio = MEMC_ZSTD_MAX_RATIO; + } else if (is_fastlz) { + max_ratio = MEMC_FASTLZ_MAX_RATIO; + } else { + max_ratio = MEMC_ZLIB_MAX_RATIO; + } + + if ((uint64_t) stored_length > (uint64_t) payload_len * max_ratio) { + php_error_docref(NULL, E_WARNING, "could not decompress value: stored length does not match the compressed data"); + return NULL; + } #ifdef HAVE_ZSTD_H if (is_zstd) { - length = ZSTD_getFrameContentSize(payload, payload_len); - if (length == ZSTD_CONTENTSIZE_ERROR) { + unsigned long long frame_length = ZSTD_getFrameContentSize(payload, payload_len); + if (frame_length == ZSTD_CONTENTSIZE_ERROR) { php_error_docref(NULL, E_WARNING, "value was not compressed by zstd"); - zend_string_release (buffer); return NULL; - } else if (length == ZSTD_CONTENTSIZE_UNKNOWN) { + } else if (frame_length == ZSTD_CONTENTSIZE_UNKNOWN) { php_error_docref(NULL, E_WARNING, "zstd streaming decompression not supported"); - zend_string_release (buffer); + return NULL; + } else if (frame_length != stored_length) { + php_error_docref(NULL, E_WARNING, "could not decompress value: stored length does not match the compressed data"); return NULL; } - decompress_status = !ZSTD_isError(ZSTD_decompress(&buffer->val, buffer->len, payload, payload_len)); + } +#endif + + buffer = zend_string_alloc (stored_length, 0); +#ifdef HAVE_ZSTD_H + if (is_zstd) { + size_t zstd_length = ZSTD_decompress(&buffer->val, buffer->len, payload, payload_len); + + decompress_status = !ZSTD_isError(zstd_length); + if (decompress_status) { + buffer->len = zstd_length; + } } else #endif if (is_fastlz) { decompress_status = ((length = fastlz_decompress(payload, payload_len, &buffer->val, buffer->len)) > 0); + if (decompress_status) { + buffer->len = length; + } } else if (is_zlib) { unsigned long ds = buffer->len; @@ -3777,14 +3817,13 @@ zend_string *s_decompress_value (const char *payload, size_t payload_len, uint32 buffer->len = ds; } - ZSTR_VAL(buffer)[stored_length] = '\0'; - if (!decompress_status) { php_error_docref(NULL, E_WARNING, "could not decompress value"); zend_string_release (buffer); return NULL; } + ZSTR_VAL(buffer)[ZSTR_LEN(buffer)] = '\0'; zend_string_forget_hash_val(buffer); return buffer; } diff --git a/tests/compression_length.inc b/tests/compression_length.inc new file mode 100644 index 00000000..4090f9ca --- /dev/null +++ b/tests/compression_length.inc @@ -0,0 +1,60 @@ +setOption(Memcached::OPT_COMPRESSION, true); + $m->setOption(Memcached::OPT_COMPRESSION_TYPE, $type); + $m->set('length_test', $value); + list($flags, $data) = memc_raw_get('length_test'); + $real = unpack('V', substr($data, 0, 4))[1]; + $body = substr($data, 4); + + $cases = array( + 'unchanged' => $real, + 'larger by 4096' => $real + 4096, + 'smaller by 1' => $real - 1, + '1 GiB' => 1 << 30, + '4 GiB - 1' => 0xFFFFFFFF, + ); + foreach ($cases as $label => $length) { + memc_raw_set('length_test', $flags, pack('V', $length) . $body); + error_clear_last(); + $result = @$m->get('length_test'); + $error = error_get_last(); + printf("%s %s: %s%s\n", $name, $label, + $result === $value ? 'original value' : var_export($result, true), + $error ? ' (' . preg_replace('/^Memcached::get\(\): /', '', $error['message']) . ')' : ''); + } +} diff --git a/tests/compression_length_header.phpt b/tests/compression_length_header.phpt new file mode 100644 index 00000000..fa9e5d84 --- /dev/null +++ b/tests/compression_length_header.phpt @@ -0,0 +1,28 @@ +--TEST-- +Compressed item with inconsistent length header (fastlz, zlib) +--SKIPIF-- + +--INI-- +memory_limit=128M +--FILE-- + Memcached::SERIALIZER_PHP)); +$value = str_repeat('abcdefgh', 500); + +memc_compressed_length_test($m, 'fastlz', Memcached::COMPRESSION_FASTLZ, $value); +memc_compressed_length_test($m, 'zlib', Memcached::COMPRESSION_ZLIB, $value); +?> +--EXPECT-- +fastlz unchanged: original value +fastlz larger by 4096: original value +fastlz smaller by 1: false (could not decompress value) +fastlz 1 GiB: false (could not decompress value: stored length does not match the compressed data) +fastlz 4 GiB - 1: false (could not decompress value: stored length does not match the compressed data) +zlib unchanged: original value +zlib larger by 4096: original value +zlib smaller by 1: false (could not decompress value) +zlib 1 GiB: false (could not decompress value: stored length does not match the compressed data) +zlib 4 GiB - 1: false (could not decompress value: stored length does not match the compressed data) diff --git a/tests/compression_length_header_zstd.phpt b/tests/compression_length_header_zstd.phpt new file mode 100644 index 00000000..b0234f10 --- /dev/null +++ b/tests/compression_length_header_zstd.phpt @@ -0,0 +1,25 @@ +--TEST-- +Compressed item with inconsistent length header (zstd) +--SKIPIF-- + +--INI-- +memory_limit=128M +--FILE-- + Memcached::SERIALIZER_PHP)); +$value = str_repeat('abcdefgh', 500); + +memc_compressed_length_test($m, 'zstd', Memcached::COMPRESSION_ZSTD, $value); +?> +--EXPECT-- +zstd unchanged: original value +zstd larger by 4096: false (could not decompress value: stored length does not match the compressed data) +zstd smaller by 1: false (could not decompress value: stored length does not match the compressed data) +zstd 1 GiB: false (could not decompress value: stored length does not match the compressed data) +zstd 4 GiB - 1: false (could not decompress value: stored length does not match the compressed data)