From 3c1e2782e06d7d8e6d51ea01659d2b229d294e32 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Thu, 15 Oct 2026 18:12:13 -0400 Subject: [PATCH 1/5] Add phpser serializer support Memcached::SERIALIZER_PHPSER (memcached.serializer=phpser) stores arrays, objects, and null with phpser and flags them with the new MEMC_VAL_IS_PHPSER type, as the igbinary serializer does. Scalars stay native. The module requires phpser and refuses to start unless phpser is 0.9.0 or later and exports the expected API version. Build with --enable-memcached-phpser; PECL and PIE expose the same option. --- composer.json | 7 +- config.m4 | 44 ++++++++++++- config.w32 | 12 ++++ package.xml | 5 ++ php_memcached.c | 138 +++++++++++++++++++++++++++++++++++++--- php_memcached_private.h | 3 +- 6 files changed, 197 insertions(+), 12 deletions(-) diff --git a/composer.json b/composer.json index 3356c89d..d9822ae3 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,8 @@ }, "suggest": { "ext-igbinary": "igbinary is a faster and more compact binary serializer for PHP data structures.", - "ext-msgpack": "msgpack is a faster and more compact data structure representation that is interoperable with msgpack implementations for other languages." + "ext-msgpack": "msgpack is a faster and more compact data structure representation that is interoperable with msgpack implementations for other languages.", + "ext-phpser": "phpser is a binary serializer for PHP data structures that can sign values with phpser.key." }, "php-ext": { "extension-name": "memcached", @@ -30,6 +31,10 @@ "name": "enable-memcached-igbinary", "description": "Enable memcached igbinary serializer support" }, + { + "name": "enable-memcached-phpser", + "description": "Enable memcached phpser serializer support" + }, { "name": "enable-memcached-json", "description": "Enable memcached json serializer support" diff --git a/config.m4 b/config.m4 index 0e4ef8cf..d711e889 100644 --- a/config.m4 +++ b/config.m4 @@ -12,6 +12,9 @@ PHP_ARG_ENABLE(memcached-session, whether to enable memcached session handler su PHP_ARG_ENABLE(memcached-igbinary, whether to enable memcached igbinary serializer support, [ --enable-memcached-igbinary Enable memcached igbinary serializer support], no, no) +PHP_ARG_ENABLE(memcached-phpser, whether to enable memcached phpser serializer support, +[ --enable-memcached-phpser Enable memcached phpser serializer support], no, no) + PHP_ARG_ENABLE(memcached-json, whether to enable memcached json serializer support, [ --enable-memcached-json Enable memcached json serializer support], no, no) @@ -160,6 +163,31 @@ if test "$PHP_MEMCACHED" != "no"; then fi fi + if test "$PHP_MEMCACHED_PHPSER" != "no"; then + AC_MSG_CHECKING([for phpser includes]) + phpser_inc_path="" + + if test -f "$abs_srcdir/include/php/ext/phpser/php_phpser_api.h"; then + phpser_inc_path="$abs_srcdir/include/php" + elif test -f "$abs_srcdir/ext/phpser/php_phpser_api.h"; then + phpser_inc_path="$abs_srcdir" + elif test -f "$phpincludedir/ext/phpser/php_phpser_api.h"; then + phpser_inc_path="$phpincludedir" + else + for i in php php4 php5 php6; do + if test -f "$prefix/include/$i/ext/phpser/php_phpser_api.h"; then + phpser_inc_path="$prefix/include/$i" + fi + done + fi + + if test "$phpser_inc_path" = ""; then + AC_MSG_ERROR([Cannot find php_phpser_api.h]) + else + AC_MSG_RESULT([$phpser_inc_path]) + fi + fi + if test "$PHP_MEMCACHED_MSGPACK" != "no"; then AC_MSG_CHECKING([for msgpack includes]) msgpack_inc_path="" @@ -215,6 +243,20 @@ if test "$PHP_MEMCACHED" != "no"; then AC_MSG_RESULT([disabled]) fi + AC_MSG_CHECKING([for memcached phpser support]) + if test "$PHP_MEMCACHED_PHPSER" != "no"; then + AC_MSG_RESULT([enabled]) + AC_DEFINE(HAVE_MEMCACHED_PHPSER,1,[Whether memcache phpser serializer is enabled]) + PHPSER_INCLUDES="-I$phpser_inc_path" + ifdef([PHP_ADD_EXTENSION_DEP], + [ + PHP_ADD_EXTENSION_DEP(memcached, phpser) + ]) + else + PHPSER_INCLUDES="" + AC_MSG_RESULT([disabled]) + fi + AC_MSG_CHECKING([for memcached msgpack support]) if test "$PHP_MEMCACHED_MSGPACK" != "no"; then AC_MSG_RESULT([enabled]) @@ -405,7 +447,7 @@ if test "$PHP_MEMCACHED" != "no"; then export PKG_CONFIG_PATH="$ORIG_PKG_CONFIG_PATH" PHP_SUBST(MEMCACHED_SHARED_LIBADD) - PHP_NEW_EXTENSION(memcached, $PHP_MEMCACHED_FILES, $ext_shared,,$SESSION_INCLUDES $IGBINARY_INCLUDES $LIBEVENT_INCLUDES $MSGPACK_INCLUDES) + PHP_NEW_EXTENSION(memcached, $PHP_MEMCACHED_FILES, $ext_shared,,$SESSION_INCLUDES $IGBINARY_INCLUDES $PHPSER_INCLUDES $LIBEVENT_INCLUDES $MSGPACK_INCLUDES) if test "ac_cv_have_fastlz" != "yes"; then PHP_ADD_BUILD_DIR($ext_builddir/fastlz, 1) fi diff --git a/config.w32 b/config.w32 index 76d4ee3c..e1a3ee6c 100644 --- a/config.w32 +++ b/config.w32 @@ -4,6 +4,7 @@ ARG_ENABLE('memcached', 'libmemcached extension', 'no'); ARG_ENABLE('memcached-session', 'whether to enable memcached session handler support', 'no'); ARG_ENABLE('memcached-igbinary', 'whether to enable memcached igbinary serializer support', 'no'); +ARG_ENABLE('memcached-phpser', 'whether to enable memcached phpser serializer support', 'no'); ARG_ENABLE('memcached-json', 'whether to enable memcached json serializer support', 'no'); ARG_ENABLE('memcached-msgpack', 'whether to enable memcached msgpack serializer support', 'no'); @@ -36,6 +37,17 @@ if (PHP_MEMCACHED == "yes") { ERROR("memcached: header 'igbinary.h' not found"); } } + if (PHP_MEMCACHED_PHPSER != "no"){ + AC_DEFINE("HAVE_MEMCACHED_PHPSER",1); + ADD_EXTENSION_DEP("memcached", "phpser", true); + if (!CHECK_HEADER_ADD_INCLUDE("php_phpser_api.h", "CFLAGS_MEMCACHED", configure_module_dirname + "\\..\\phpser")) { + ERROR("memcached: header 'php_phpser_api.h' not found"); + } + // php_phpser_api.h selects dllimport only when COMPILE_DL_PHPSER is defined. + if (typeof(PHP_PHPSER_SHARED) != "undefined" && PHP_PHPSER_SHARED) { + ADD_FLAG("CFLAGS_MEMCACHED", "/D COMPILE_DL_PHPSER"); + } + } if (PHP_MEMCACHED_MSGPACK != "no"){ AC_DEFINE("HAVE_MEMCACHED_MSGPACK",1); ADD_EXTENSION_DEP("memcached", "msgpack", true); diff --git a/package.xml b/package.xml index 524a51e1..89ffdcac 100644 --- a/package.xml +++ b/package.xml @@ -206,6 +206,10 @@ http://pear.php.net/dtd/package-2.0.xsd"> msgpack 2.0 + + phpser + 0.9.0 + memcached @@ -216,6 +220,7 @@ http://pear.php.net/dtd/package-2.0.xsd"> + diff --git a/php_memcached.c b/php_memcached.c index 47ed2d33..cca6f8d5 100644 --- a/php_memcached.c +++ b/php_memcached.c @@ -59,6 +59,16 @@ # include "ext/msgpack/php_msgpack.h" #endif +#ifdef HAVE_MEMCACHED_PHPSER +# include "ext/standard/php_versioning.h" +#ifdef PHP_WIN32 +# include "php_phpser_api.h" +#else +# include "ext/phpser/php_phpser_api.h" +#endif +# define MEMC_PHPSER_CONTEXT "memcached" +#endif + # include "ext/spl/spl_exceptions.h" static int le_memc; @@ -109,6 +119,7 @@ static int php_memc_list_entry(void) { #define MEMC_VAL_IS_IGBINARY 5 #define MEMC_VAL_IS_JSON 6 #define MEMC_VAL_IS_MSGPACK 7 +#define MEMC_VAL_IS_PHPSER 8 #define MEMC_VAL_COMPRESSED (1<<0) #define MEMC_VAL_COMPRESSION_ZLIB (1<<1) @@ -336,6 +347,10 @@ static PHP_INI_MH(OnUpdateSerializer) } else if (!strcmp(ZSTR_VAL(new_value), "msgpack")) { MEMC_G(serializer_type) = SERIALIZER_MSGPACK; #endif // msgpack +#ifdef HAVE_MEMCACHED_PHPSER + } else if (!strcmp(ZSTR_VAL(new_value), "phpser")) { + MEMC_G(serializer_type) = SERIALIZER_PHPSER; +#endif // phpser } else { return FAILURE; } @@ -1026,6 +1041,25 @@ zend_bool s_serialize_value (php_memc_serializer_type serializer, zval *value, s break; #endif + /* + phpser serialization + */ +#ifdef HAVE_MEMCACHED_PHPSER + case SERIALIZER_PHPSER: + { + zend_string *encoded; + + /* phpser raises a warning or an exception on failure */ + if (phpser_api_serialize(value, MEMC_PHPSER_CONTEXT, sizeof(MEMC_PHPSER_CONTEXT) - 1, &encoded) != PHPSER_API_OK) { + return 0; + } + smart_str_append(buf, encoded); + zend_string_release(encoded); + MEMC_VAL_SET_TYPE(*flags, MEMC_VAL_IS_PHPSER); + } + break; +#endif + /* JSON serialization */ @@ -1079,6 +1113,18 @@ zend_bool s_serialize_value (php_memc_serializer_type serializer, zval *value, s return 1; } +static +zend_string *s_serialize_payload(php_memc_serializer_type serializer, zval *value, uint32_t *flags) +{ + smart_str buffer = {0}; + + if (!s_serialize_value (serializer, value, &buffer, flags)) { + smart_str_free(&buffer); + return NULL; + } + return buffer.s; +} + static zend_string *s_zval_to_payload(php_memc_object_t *intern, zval *value, uint32_t *flags) { @@ -1128,18 +1174,14 @@ zend_string *s_zval_to_payload(php_memc_object_t *intern, zval *value, uint32_t break; default: - { - smart_str buffer = {0}; - - if (!s_serialize_value (memc_user_data->serializer, value, &buffer, flags)) { - smart_str_free(&buffer); - return NULL; - } - payload = buffer.s; - } + payload = s_serialize_payload(memc_user_data->serializer, value, flags); break; } + if (!payload) { + return NULL; + } + /* turn off compression for values below the threshold */ if (ZSTR_LEN(payload) == 0 || ZSTR_LEN(payload) < MEMC_G(compression_threshold)) { should_compress = 0; @@ -3187,6 +3229,12 @@ int php_memc_set_option(php_memc_object_t *intern, long option, zval *value) if (lval == SERIALIZER_MSGPACK) { memc_user_data->serializer = SERIALIZER_MSGPACK; } else +#endif + /* phpser serializer */ +#ifdef HAVE_MEMCACHED_PHPSER + if (lval == SERIALIZER_PHPSER) { + memc_user_data->serializer = SERIALIZER_PHPSER; + } else #endif /* php serializer */ if (lval == SERIALIZER_PHP) { @@ -3827,6 +3875,26 @@ zend_bool s_unserialize_value (memcached_st *memc, int val_type, zend_string *pa #endif break; + case MEMC_VAL_IS_PHPSER: +#ifdef HAVE_MEMCACHED_PHPSER + { + int rc = phpser_api_unserialize(ZSTR_VAL(payload), ZSTR_LEN(payload), MEMC_PHPSER_CONTEXT, sizeof(MEMC_PHPSER_CONTEXT) - 1, return_value); + + if (rc != PHPSER_API_OK) { + ZVAL_FALSE(return_value); + if (rc == PHPSER_API_ERR && !EG(exception)) { + php_error_docref(NULL, E_WARNING, "could not unserialize value with phpser"); + } + return 0; + } + } +#else + ZVAL_FALSE(return_value); + php_error_docref(NULL, E_WARNING, "could not unserialize value, no phpser support"); + return 0; +#endif + break; + case MEMC_VAL_IS_JSON: #ifdef HAVE_JSON_API { @@ -3915,6 +3983,7 @@ zend_bool s_memcached_result_to_zval(memcached_st *memc, memcached_result_st *re case MEMC_VAL_IS_IGBINARY: case MEMC_VAL_IS_JSON: case MEMC_VAL_IS_MSGPACK: + case MEMC_VAL_IS_PHPSER: retval = s_unserialize_value (memc, MEMC_VAL_GET_TYPE(flags), data, return_value); break; @@ -4056,6 +4125,9 @@ static const zend_module_dep memcached_deps[] = { #endif #ifdef HAVE_MEMCACHED_MSGPACK ZEND_MOD_REQUIRED("msgpack") +#endif +#ifdef HAVE_MEMCACHED_PHPSER + ZEND_MOD_REQUIRED("phpser") #endif ZEND_MOD_REQUIRED("spl") ZEND_MOD_END @@ -4187,6 +4259,15 @@ static void php_memc_register_constants(INIT_FUNC_ARGS) REGISTER_MEMC_CLASS_CONST_BOOL(HAVE_MSGPACK, 0); #endif + /* + * Indicate whether phpser serializer is available + */ +#ifdef HAVE_MEMCACHED_PHPSER + REGISTER_MEMC_CLASS_CONST_BOOL(HAVE_PHPSER, 1); +#else + REGISTER_MEMC_CLASS_CONST_BOOL(HAVE_PHPSER, 0); +#endif + /* * Indicate whether set_encoding_key is available */ @@ -4334,6 +4415,7 @@ static void php_memc_register_constants(INIT_FUNC_ARGS) REGISTER_MEMC_CLASS_CONST_LONG(SERIALIZER_JSON, SERIALIZER_JSON); REGISTER_MEMC_CLASS_CONST_LONG(SERIALIZER_JSON_ARRAY, SERIALIZER_JSON_ARRAY); REGISTER_MEMC_CLASS_CONST_LONG(SERIALIZER_MSGPACK, SERIALIZER_MSGPACK); + REGISTER_MEMC_CLASS_CONST_LONG(SERIALIZER_PHPSER, SERIALIZER_PHPSER); /* * Compression types @@ -4398,11 +4480,43 @@ static void php_memc_register_constants(INIT_FUNC_ARGS) } /* }}} */ +#ifdef HAVE_MEMCACHED_PHPSER +/* phpser_api_version() does not exist before phpser 0.9.0, so check the + * module version before calling into the API. */ +static +int php_memc_check_phpser(void) +{ + const char *version = zend_get_module_version("phpser"); + + if (version == NULL || php_version_compare(version, "0.9.0") < 0) { + php_error_docref(NULL, E_CORE_WARNING, + "Memcached::SERIALIZER_PHPSER requires phpser 0.9.0 or later, found %s", + version ? version : "none"); + return FAILURE; + } + + if (phpser_api_version() != PHPSER_API_VERSION) { + php_error_docref(NULL, E_CORE_WARNING, + "Memcached::SERIALIZER_PHPSER requires phpser API version %d, found %u", + PHPSER_API_VERSION, phpser_api_version()); + return FAILURE; + } + + return SUCCESS; +} +#endif + /* {{{ PHP_MINIT_FUNCTION */ PHP_MINIT_FUNCTION(memcached) { zend_class_entry ce; +#ifdef HAVE_MEMCACHED_PHPSER + if (php_memc_check_phpser() == FAILURE) { + return FAILURE; + } +#endif + memcpy(&memcached_object_handlers, zend_get_std_object_handlers(), sizeof(zend_object_handlers)); memcached_object_handlers.offset = offsetof(php_memc_object_t, zo); memcached_object_handlers.clone_obj = NULL; @@ -4506,6 +4620,12 @@ PHP_MINFO_FUNCTION(memcached) php_info_print_table_row(2, "msgpack support", "no"); #endif +#ifdef HAVE_MEMCACHED_PHPSER + php_info_print_table_row(2, "phpser support", "yes"); +#else + php_info_print_table_row(2, "phpser support", "no"); +#endif + #ifdef HAVE_ZSTD_H php_info_print_table_row(2, "zstd support", "yes"); #else diff --git a/php_memcached_private.h b/php_memcached_private.h index b7d2a5e1..8c2975e5 100644 --- a/php_memcached_private.h +++ b/php_memcached_private.h @@ -97,7 +97,8 @@ typedef enum { SERIALIZER_IGBINARY = 2, SERIALIZER_JSON = 3, SERIALIZER_JSON_ARRAY = 4, - SERIALIZER_MSGPACK = 5 + SERIALIZER_MSGPACK = 5, + SERIALIZER_PHPSER = 6 } php_memc_serializer_type; typedef enum { From cd2369cdb46db4352ee1e4cbb8260ac852080e59 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Thu, 15 Oct 2026 18:12:25 -0400 Subject: [PATCH 2/5] Sign phpser values and gate reads when phpser.key is set Items are decoded by the type in their stored flags, not by the client's serializer, so anyone who can write to the memcached servers can store an item flagged as PHP-serialized and have unserialize() run on it. When the serializer is phpser and phpser.key is set, strings, booleans, null, arrays, and objects are now written as signed phpser values; integers and floats stay native so increment() and decrement() keep working. Reads accept only phpser items, which phpser verifies before decoding, and native integers and floats. Every other type reads back as false with a warning, the same result as a value that fails to unserialize. The check sits in s_memcached_result_to_zval(), which every read path goes through, and runs before decompression. append() and prepend() are refused in this mode, as they are with compression, because they would break the signature. A non-empty key shorter than phpser accepts also enables the gate; phpser then rejects every phpser value. --- php_memcached.c | 66 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/php_memcached.c b/php_memcached.c index cca6f8d5..5dc3dcd2 100644 --- a/php_memcached.c +++ b/php_memcached.c @@ -917,6 +917,31 @@ zend_bool s_invoke_cache_callback(zval *zobject, zend_fcall_info *fci, zend_fcal Wrapper for setting from zval ****************************************/ +#ifdef HAVE_MEMCACHED_PHPSER +/* phpser has no API that reports whether signing is active, so apply its own + * rule: a non-empty phpser.key means values must verify. A key shorter than + * phpser accepts also counts, because phpser then rejects every value. */ +static +zend_bool s_phpser_key_set(void) +{ + zend_ini_entry *entry = zend_hash_str_find_ptr(EG(ini_directives), "phpser.key", sizeof("phpser.key") - 1); + + return entry && entry->value && ZSTR_LEN(entry->value) > 0; +} +#endif + +/* With signed phpser, only verified phpser items and native integers and + * floats are read back; every other value type is written through phpser. */ +static +zend_bool s_phpser_keyed(php_memc_serializer_type serializer) +{ +#ifdef HAVE_MEMCACHED_PHPSER + return serializer == SERIALIZER_PHPSER && s_phpser_key_set(); +#else + return 0; +#endif +} + static zend_bool s_compress_value (php_memc_compression_type compression_type, zend_long compression_level, zend_string **payload_in, uint32_t *flags) { @@ -1131,10 +1156,15 @@ zend_string *s_zval_to_payload(php_memc_object_t *intern, zval *value, uint32_t zend_string *payload; php_memc_user_data_t *memc_user_data = memcached_get_user_data(intern->memc); zend_bool should_compress = memc_user_data->compression_enabled; + zend_bool phpser_keyed = s_phpser_keyed(memc_user_data->serializer); switch (Z_TYPE_P(value)) { case IS_STRING: + if (phpser_keyed) { + payload = s_serialize_payload(memc_user_data->serializer, value, flags); + break; + } payload = zval_get_string(value); MEMC_VAL_SET_TYPE(*flags, MEMC_VAL_IS_STRING); break; @@ -1162,12 +1192,20 @@ zend_string *s_zval_to_payload(php_memc_object_t *intern, zval *value, uint32_t break; case IS_TRUE: + if (phpser_keyed) { + payload = s_serialize_payload(memc_user_data->serializer, value, flags); + break; + } payload = zend_string_init ("1", 1, 0); MEMC_VAL_SET_TYPE(*flags, MEMC_VAL_IS_BOOL); should_compress = 0; break; case IS_FALSE: + if (phpser_keyed) { + payload = s_serialize_payload(memc_user_data->serializer, value, flags); + break; + } payload = zend_string_alloc (0, 0); MEMC_VAL_SET_TYPE(*flags, MEMC_VAL_IS_BOOL); should_compress = 0; @@ -2192,6 +2230,14 @@ static void php_memc_store_impl(INTERNAL_FUNCTION_PARAMETERS, int op, zend_bool } } + if (s_phpser_keyed(memc_user_data->serializer)) { + /* Appending to a signed value breaks its tag, so it would read back as false */ + if (op == MEMC_OP_APPEND || op == MEMC_OP_PREPEND) { + php_error_docref(NULL, E_WARNING, "cannot append/prepend with the phpser serializer when phpser.key is set"); + RETURN_NULL(); + } + } + if (!s_memc_write_zval (intern, op, server_key, key, value, expiration)) { RETURN_FALSE; } @@ -3882,7 +3928,8 @@ zend_bool s_unserialize_value (memcached_st *memc, int val_type, zend_string *pa if (rc != PHPSER_API_OK) { ZVAL_FALSE(return_value); - if (rc == PHPSER_API_ERR && !EG(exception)) { + /* With phpser.key set, phpser itself reports every failure a forged value can cause */ + if (rc == PHPSER_API_ERR && !EG(exception) && !s_phpser_key_set()) { php_error_docref(NULL, E_WARNING, "could not unserialize value with phpser"); } return 0; @@ -3929,6 +3976,7 @@ zend_bool s_memcached_result_to_zval(memcached_st *memc, memcached_result_st *re size_t payload_len; uint32_t flags; zend_bool retval = 1; + php_memc_user_data_t *memc_user_data = memcached_get_user_data(memc); payload = memcached_result_value(result); payload_len = memcached_result_length(result); @@ -3939,6 +3987,22 @@ zend_bool s_memcached_result_to_zval(memcached_st *memc, memcached_result_st *re return 0; } + /* The type comes from the stored flags, which whoever wrote the item chose, + * so the configured serializer alone does not keep other decoders out. */ + if (s_phpser_keyed(memc_user_data->serializer)) { + switch (MEMC_VAL_GET_TYPE(flags)) { + case MEMC_VAL_IS_PHPSER: + case MEMC_VAL_IS_LONG: + case MEMC_VAL_IS_DOUBLE: + break; + + default: + php_error_docref(NULL, E_WARNING, "could not unserialize value: phpser.key is set and the value is not signed phpser"); + ZVAL_FALSE(return_value); + return 0; + } + } + if (MEMC_VAL_HAS_FLAG(flags, MEMC_VAL_COMPRESSED)) { data = s_decompress_value (payload, payload_len, flags); if (!data) { From f988c5edf9344feba73c010c5dde343dd8ca0556 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Thu, 15 Oct 2026 18:12:30 -0400 Subject: [PATCH 3/5] tests: Cover the phpser serializer and its signed read gate Round trips with and without phpser.key, the stored type of each value, counters, append/prepend refusal, and user flags. Attack tests write items with forged flags over the raw protocol and through a PHP-serializer client (PHP-serialized and igbinary gadgets, compressed gadgets, strings, booleans, JSON, msgpack, unsigned, tampered, truncated, wrong-key, and wrong-context phpser) and check that each reads back as false and that no object is constructed, through get, getByKey, getMulti, getDelayed, fetch, fetchAll, result callbacks, and read-through callbacks. A short phpser.key fails closed. --- package.xml | 9 +++ tests/phpser.inc | 83 ++++++++++++++++++++ tests/phpser_keyed.phpt | 106 ++++++++++++++++++++++++++ tests/phpser_keyed_attack.phpt | 110 +++++++++++++++++++++++++++ tests/phpser_keyed_incr_initial.phpt | 37 +++++++++ tests/phpser_keyed_multi.phpt | 99 ++++++++++++++++++++++++ tests/phpser_short_key.phpt | 62 +++++++++++++++ tests/phpser_unkeyed.phpt | 71 +++++++++++++++++ tests/types_phpser.phpt | 24 ++++++ tests/types_phpser_keyed.phpt | 26 +++++++ 10 files changed, 627 insertions(+) create mode 100644 tests/phpser.inc create mode 100644 tests/phpser_keyed.phpt create mode 100644 tests/phpser_keyed_attack.phpt create mode 100644 tests/phpser_keyed_incr_initial.phpt create mode 100644 tests/phpser_keyed_multi.phpt create mode 100644 tests/phpser_short_key.phpt create mode 100644 tests/phpser_unkeyed.phpt create mode 100644 tests/types_phpser.phpt create mode 100644 tests/types_phpser_keyed.phpt diff --git a/package.xml b/package.xml index 89ffdcac..65856a91 100644 --- a/package.xml +++ b/package.xml @@ -142,6 +142,15 @@ http://pear.php.net/dtd/package-2.0.xsd"> + + + + + + + + + diff --git a/tests/phpser.inc b/tests/phpser.inc new file mode 100644 index 00000000..43f5fbb4 --- /dev/null +++ b/tests/phpser.inc @@ -0,0 +1,83 @@ +cmd = $data['cmd'] ?? 'none'; + } + + public function __destruct() { + self::$events[] = "destruct"; + } +} + +/* Writes an item with caller-chosen flags, as anyone with write access to + * the memcached server can. */ +function phpser_raw_set($key, $flags, $data) +{ + $fp = fsockopen(MEMC_SERVER_HOST, MEMC_SERVER_PORT, $errno, $errstr, 2); + fwrite($fp, "set $key $flags 0 " . strlen($data) . "\r\n" . $data . "\r\n"); + $reply = trim(fgets($fp)); + fclose($fp); + if ($reply !== "STORED") { + echo "raw set of $key failed: $reply\n"; + } +} + +/* Returns [flags, data] for a key, read without any client-side decoding. */ +function phpser_raw_get($key) +{ + $fp = fsockopen(MEMC_SERVER_HOST, MEMC_SERVER_PORT, $errno, $errstr, 2); + fwrite($fp, "get $key\r\n"); + $header = trim(fgets($fp)); + if ($header === "END") { + fclose($fp); + return null; + } + list(, , $flags, $len) = explode(' ', $header); + $data = ''; + while (strlen($data) < $len) { + $data .= fread($fp, $len - strlen($data)); + } + fclose($fp); + return [(int) $flags, $data]; +} + +function phpser_raw_type($key) +{ + $item = phpser_raw_get($key); + return $item === null ? null : ($item[0] & 0xf); +} + +/* Signs a phpser frame the way phpser does for phpser.key = $key. */ +function phpser_forge_signed($value, $key, $context = 'memcached') +{ + $frame = phpser_serialize($value); + $subkey = hash_hmac('sha256', "phpser:" . $context . ":v1\0", $key, true); + return $frame . hash_hmac('sha256', $frame, $subkey, true); +} + +function phpser_gadget_serialized() +{ + return 'O:12:"PhpserGadget":1:{s:3:"cmd";s:6:"rm -rf";}'; +} diff --git a/tests/phpser_keyed.phpt b/tests/phpser_keyed.phpt new file mode 100644 index 00000000..f3fa8be3 --- /dev/null +++ b/tests/phpser_keyed.phpt @@ -0,0 +1,106 @@ +--TEST-- +Memcached phpser serializer with phpser.key: stored types, counters, append/prepend +--SKIPIF-- + +--INI-- +phpser.key=memcached-phpser-test-key-0123456789 +--FILE-- + Memcached::SERIALIZER_PHPSER, + Memcached::OPT_COMPRESSION => false, +)); + +echo "-- stored types\n"; +$values = array( + 'str' => 'hello', + 'empty' => '', + 'true' => true, + 'false' => false, + 'null' => null, + 'arr' => array(1, 'two' => 2), + 'obj' => (object) array('a' => 1), + 'long' => 42, + 'dbl' => 1.5, +); +foreach ($values as $key => $value) { + $m->set($key, $value); + $back = $m->get($key); + printf("%-5s type=%d same=%s\n", $key, phpser_raw_type($key), + var_export(is_object($value) ? $back == $value : $back === $value, true)); +} + +echo "-- signed bytes match phpser_serialize_keyed()\n"; +var_dump(phpser_raw_get('str')[1] === phpser_serialize_keyed('hello', 'memcached')); +var_dump(phpser_raw_get('str')[1] === phpser_forge_signed('hello', PHPSER_TEST_KEY)); + +echo "-- counters stay native\n"; +var_dump($m->set('counter', 10)); +var_dump($m->increment('counter', 5)); +var_dump($m->decrement('counter', 2)); +var_dump($m->get('counter')); +var_dump(phpser_raw_type('counter')); + +echo "-- append/prepend are refused\n"; +$m->set('ap', 'abc'); +var_dump($m->append('ap', 'def')); +var_dump($m->prepend('ap', 'xyz')); +var_dump($m->appendByKey('', 'ap', 'def')); +var_dump($m->get('ap')); + +echo "-- user flags are kept and do not affect the type\n"; +$m->setOption(Memcached::OPT_USER_FLAGS, 0xBEEF); +$m->set('uf', array('x')); +$r = $m->get('uf', null, Memcached::GET_EXTENDED); +var_dump($r['value'] === array('x'), $r['flags'] === 0xBEEF, phpser_raw_type('uf')); + +echo "-- cas writes sign too\n"; +$r = $m->get('str', null, Memcached::GET_EXTENDED); +var_dump($m->cas($r['cas'], 'str', 'replaced')); +var_dump($m->get('str'), phpser_raw_type('str')); +?> +--EXPECTF-- +-- stored types +str type=8 same=true +empty type=8 same=true +true type=8 same=true +false type=8 same=true +null type=8 same=true +arr type=8 same=true +obj type=8 same=true +long type=1 same=true +dbl type=2 same=true +-- signed bytes match phpser_serialize_keyed() +bool(true) +bool(true) +-- counters stay native +bool(true) +int(15) +int(13) +int(13) +int(1) +-- append/prepend are refused + +Warning: Memcached::append(): cannot append/prepend with the phpser serializer when phpser.key is set in %s on line %d +NULL + +Warning: Memcached::prepend(): cannot append/prepend with the phpser serializer when phpser.key is set in %s on line %d +NULL + +Warning: Memcached::appendByKey(): cannot append/prepend with the phpser serializer when phpser.key is set in %s on line %d +NULL +string(3) "abc" +-- user flags are kept and do not affect the type +bool(true) +bool(true) +int(8) +-- cas writes sign too +bool(true) +string(8) "replaced" +int(8) diff --git a/tests/phpser_keyed_attack.phpt b/tests/phpser_keyed_attack.phpt new file mode 100644 index 00000000..f8ed881c --- /dev/null +++ b/tests/phpser_keyed_attack.phpt @@ -0,0 +1,110 @@ +--TEST-- +Memcached phpser serializer with phpser.key rejects items it did not sign +--SKIPIF-- + +--INI-- +phpser.key=memcached-phpser-test-key-0123456789 +--FILE-- + Memcached::SERIALIZER_PHPSER)); +$php = memc_create_instance(MEMC_SERVER_HOST, MEMC_SERVER_PORT, array(Memcached::OPT_SERIALIZER => Memcached::SERIALIZER_PHP)); + +$gadget = new PhpserGadget(); +$gadget->cmd = 'rm -rf'; +$gadget_phpser = phpser_serialize($gadget); +$gadget_igbinary = function_exists('igbinary_serialize') ? igbinary_serialize($gadget) : "\x00\x00\x00\x02\x17\x0cPhpserGadget"; +unset($gadget); + +$zlib = function ($data) { + return pack('V', strlen($data)) . gzcompress($data); +}; + +$signed = phpser_serialize_keyed(array('k' => str_repeat('v', 50)), 'memcached'); +$tampered = $signed; +$tampered[10] = chr(ord($tampered[10]) ^ 1); + +$items = array( + 'ser_gadget' => array(MEMC_TEST_IS_SERIALIZED, phpser_gadget_serialized()), + 'ser_gadget_zlib' => array(MEMC_TEST_IS_SERIALIZED | MEMC_TEST_COMPRESSED_ZLIB, $zlib(phpser_gadget_serialized())), + 'igbinary' => array(MEMC_TEST_IS_IGBINARY, $gadget_igbinary), + 'string' => array(MEMC_TEST_IS_STRING, 'attacker text'), + 'bool' => array(MEMC_TEST_IS_BOOL, '1'), + 'json' => array(MEMC_TEST_IS_JSON, '{"a":1}'), + 'msgpack' => array(MEMC_TEST_IS_MSGPACK, "\x81\xa1a\x01"), + 'unknown_type' => array(9, 'x'), + 'phpser_unsigned' => array(MEMC_TEST_IS_PHPSER, $gadget_phpser), + 'phpser_unsigned_zlib' => array(MEMC_TEST_IS_PHPSER | MEMC_TEST_COMPRESSED_ZLIB, $zlib($gadget_phpser)), + 'phpser_wrongkey' => array(MEMC_TEST_IS_PHPSER, phpser_forge_signed(array('evil'), str_repeat('z', 32))), + 'phpser_ctx' => array(MEMC_TEST_IS_PHPSER, phpser_forge_signed(array('evil'), PHPSER_TEST_KEY, 'redis')), + 'phpser_tampered' => array(MEMC_TEST_IS_PHPSER, $tampered), + 'phpser_truncated' => array(MEMC_TEST_IS_PHPSER, substr($signed, 0, -1)), + 'phpser_empty' => array(MEMC_TEST_IS_PHPSER, ''), + 'good' => array(MEMC_TEST_IS_PHPSER, phpser_forge_signed(array('ok'), PHPSER_TEST_KEY)), + 'good_zlib' => array(MEMC_TEST_IS_PHPSER | MEMC_TEST_COMPRESSED_ZLIB, $zlib($signed)), + 'native_long' => array(MEMC_TEST_IS_LONG, '7'), + 'native_double' => array(MEMC_TEST_IS_DOUBLE, '2.5'), +); + +foreach ($items as $key => $item) { + phpser_raw_set($key, $item[0], $item[1]); +} + +/* Other serializers write into the same pool */ +$php->set('php_client_array', array('from' => 'php serializer')); +$php->set('php_client_string', 'plain string'); +$php->set('php_client_bool', true); +foreach (array('php_client_array', 'php_client_string', 'php_client_bool') as $key) { + $items[$key] = null; +} + +PhpserGadget::$events = array(); + +$warnings = array(); +set_error_handler(function ($no, $str) use (&$warnings) { + $warnings[] = preg_replace('/^Memcached::\w+\(\): /', '', $str); + return true; +}); + +foreach (array_keys($items) as $key) { + $warnings = array(); + $value = $m->get($key); + printf("%-20s %s rc=%d warnings=%d%s\n", $key, + is_array($value) ? 'array(' . count($value) . ')' : var_export($value, true), + $m->getResultCode(), count($warnings), $warnings ? ' ' . $warnings[0] : ''); +} + +restore_error_handler(); +echo "gadget events: ", json_encode(PhpserGadget::$events), "\n"; +var_dump(Memcached::RES_SOME_ERRORS); +?> +--EXPECT-- +ser_gadget false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +ser_gadget_zlib false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +igbinary false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +string false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +bool false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +json false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +msgpack false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +unknown_type false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +phpser_unsigned false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded +phpser_unsigned_zlib false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded +phpser_wrongkey false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded +phpser_ctx false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded +phpser_tampered false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded +phpser_truncated false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded +phpser_empty false rc=19 warnings=1 phpser: memcached value failed HMAC verification, not decoded +good array(1) rc=0 warnings=0 +good_zlib array(1) rc=0 warnings=0 +native_long 7 rc=0 warnings=0 +native_double 2.5 rc=0 warnings=0 +php_client_array false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +php_client_string false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +php_client_bool false rc=19 warnings=1 could not unserialize value: phpser.key is set and the value is not signed phpser +gadget events: [] +int(19) diff --git a/tests/phpser_keyed_incr_initial.phpt b/tests/phpser_keyed_incr_initial.phpt new file mode 100644 index 00000000..0592ae34 --- /dev/null +++ b/tests/phpser_keyed_incr_initial.phpt @@ -0,0 +1,37 @@ +--TEST-- +Memcached phpser serializer with phpser.key: items created by increment() with an initial value carry no type +--SKIPIF-- + +--INI-- +phpser.key=memcached-phpser-test-key-0123456789 +--FILE-- + Memcached::SERIALIZER_PHPSER, + Memcached::OPT_BINARY_PROTOCOL => true, +)); + +/* The server creates the item with flags 0, the string type, so get() + * refuses it; increment() and decrement() keep working on it. */ +var_dump($m->increment('c', 1, 10)); +var_dump($m->increment('c', 5)); +var_dump(phpser_raw_type('c')); +var_dump(@$m->get('c')); + +$m->set('c2', 10); +var_dump($m->increment('c2', 1, 99)); +var_dump($m->get('c2')); +?> +--EXPECT-- +int(10) +int(15) +int(0) +bool(false) +int(11) +int(11) diff --git a/tests/phpser_keyed_multi.phpt b/tests/phpser_keyed_multi.phpt new file mode 100644 index 00000000..16ba935e --- /dev/null +++ b/tests/phpser_keyed_multi.phpt @@ -0,0 +1,99 @@ +--TEST-- +Memcached phpser serializer with phpser.key gates getMulti, getDelayed, fetch, byKey, and callbacks +--SKIPIF-- + +--INI-- +phpser.key=memcached-phpser-test-key-0123456789 +--FILE-- + Memcached::SERIALIZER_PHPSER)); + +$m->set('good1', array('one')); +$m->set('good2', 'two'); +$m->set('num', 3); +phpser_raw_set('evil_ser', MEMC_TEST_IS_SERIALIZED, phpser_gadget_serialized()); +phpser_raw_set('evil_str', MEMC_TEST_IS_STRING, 'forged'); +phpser_raw_set('evil_unsigned', MEMC_TEST_IS_PHPSER, phpser_serialize(array('forged'))); +PhpserGadget::$events = array(); + +$keys = array('good1', 'evil_ser', 'good2', 'evil_str', 'num', 'evil_unsigned'); + +/* One warning per rejected item, and the rejected values must not reach + * any caller, whatever the entry point. */ +$warnings = 0; +set_error_handler(function () use (&$warnings) { $warnings++; return true; }); + +function show($label, $value, $m, &$warnings) { + echo $label, ": ", json_encode($value), " rc=", $m->getResultCode(), " warnings=", $warnings, "\n"; + $warnings = 0; +} + +show('getMulti', $m->getMulti($keys), $m, $warnings); +show('getMultiByKey', $m->getMultiByKey('srv', $keys), $m, $warnings); + +$ext = $m->getMulti($keys, Memcached::GET_EXTENDED); +show('getMulti extended keys', array_keys($ext), $m, $warnings); + +show('getByKey evil', $m->getByKey('srv', 'evil_ser'), $m, $warnings); +show('getByKey good', $m->getByKey('srv', 'good1'), $m, $warnings); +show('get extended evil', $m->get('evil_str', null, Memcached::GET_EXTENDED), $m, $warnings); + +/* A decode failure makes fetch() return false and drops the next item, + * and makes fetchAll() return false; that is the existing contract for any + * value that does not unserialize. */ +$m->getDelayed($keys); +$seq = array(); +for ($i = 0; $i < 5; $i++) { + $r = $m->fetch(); + $seq[] = ($r ? $r['key'] . '=' . json_encode($r['value']) : 'false') . ' rc=' . $m->getResultCode(); +} +show('getDelayed+fetch', $seq, $m, $warnings); + +$m->getDelayed($keys, true); +show('getDelayed(cas)+fetchAll', $m->fetchAll(), $m, $warnings); + +$cb = array(); +$m->getDelayedByKey('srv', $keys, false, function ($memc, $r) use (&$cb) { + $cb[$r['key']] = $r['value']; +}); +ksort($cb); +show('getDelayedByKey callback', $cb, $m, $warnings); + +$called = 0; +$v = $m->get('evil_ser', function ($memc, $key, &$value) use (&$called) { + $called++; + $value = 'from callback'; + return true; +}); +show('read-through on rejected item', array($v, $called), $m, $warnings); + +$v = $m->get('missing', function ($memc, $key, &$value) { + $value = 'from callback'; + return true; +}); +show('read-through on miss', $v, $m, $warnings); +show('read-through value stored signed', array($m->get('missing'), phpser_raw_type('missing')), $m, $warnings); + +restore_error_handler(); +echo "gadget events: ", json_encode(PhpserGadget::$events), "\n"; +?> +--EXPECT-- +getMulti: {"good1":["one"],"good2":"two","num":3} rc=19 warnings=3 +getMultiByKey: {"good1":["one"],"good2":"two","num":3} rc=19 warnings=3 +getMulti extended keys: ["good1","good2","num"] rc=19 warnings=3 +getByKey evil: false rc=19 warnings=1 +getByKey good: ["one"] rc=0 warnings=0 +get extended evil: false rc=19 warnings=1 +getDelayed+fetch: ["good1=[\"one\"] rc=0","false rc=19","false rc=19","false rc=19","false rc=16"] rc=16 warnings=3 +getDelayed(cas)+fetchAll: false rc=19 warnings=3 +getDelayedByKey callback: {"good1":["one"],"good2":"two","num":3} rc=19 warnings=3 +read-through on rejected item: [false,0] rc=19 warnings=1 +read-through on miss: "from callback" rc=0 warnings=0 +read-through value stored signed: ["from callback",8] rc=0 warnings=0 +gadget events: [] diff --git a/tests/phpser_short_key.phpt b/tests/phpser_short_key.phpt new file mode 100644 index 00000000..f968f8ce --- /dev/null +++ b/tests/phpser_short_key.phpt @@ -0,0 +1,62 @@ +--TEST-- +Memcached phpser serializer with a phpser.key that is too short fails closed +--SKIPIF-- + +--INI-- +phpser.key=too-short +--FILE-- + Memcached::SERIALIZER_PHPSER)); + +$warnings = array(); +set_error_handler(function ($no, $str) use (&$warnings) { + $warnings[] = preg_replace('/^Memcached::\w+\(\): /', '', $str); + return true; +}); + +function show($label, $value, $m, &$warnings) { + echo $label, ": ", var_export($value, true), " rc=", $m->getResultCode(), " ", json_encode($warnings), "\n"; + $warnings = array(); +} + +show('set string', $m->set('s', 'value'), $m, $warnings); +show('set array', $m->set('a', array(1)), $m, $warnings); +show('set bool', $m->set('b', true), $m, $warnings); +show('set long', $m->set('l', 5), $m, $warnings); +show('get long', $m->get('l'), $m, $warnings); +show('increment', $m->increment('l'), $m, $warnings); + +phpser_raw_set('unsigned', MEMC_TEST_IS_PHPSER, phpser_serialize(array('forged'))); +phpser_raw_set('signed_short', MEMC_TEST_IS_PHPSER, phpser_forge_signed(array('forged'), 'too-short')); +phpser_raw_set('ser', MEMC_TEST_IS_SERIALIZED, phpser_gadget_serialized()); +phpser_raw_set('str', MEMC_TEST_IS_STRING, 'forged'); +PhpserGadget::$events = array(); + +show('get unsigned', $m->get('unsigned'), $m, $warnings); +show('get signed with short key', $m->get('signed_short'), $m, $warnings); +show('get serialized', $m->get('ser'), $m, $warnings); +show('get string', $m->get('str'), $m, $warnings); + +restore_error_handler(); +echo "gadget events: ", json_encode(PhpserGadget::$events), "\n"; +var_dump(Memcached::RES_PAYLOAD_FAILURE); +?> +--EXPECT-- +set string: false rc=-1001 ["phpser.key must be at least 32 bytes; keyed value rejected"] +set array: false rc=-1001 ["phpser.key must be at least 32 bytes; keyed value rejected"] +set bool: false rc=-1001 ["phpser.key must be at least 32 bytes; keyed value rejected"] +set long: true rc=0 [] +get long: 5 rc=0 [] +increment: 6 rc=0 [] +get unsigned: false rc=19 ["phpser.key must be at least 32 bytes; keyed value rejected"] +get signed with short key: false rc=19 ["phpser.key must be at least 32 bytes; keyed value rejected"] +get serialized: false rc=19 ["could not unserialize value: phpser.key is set and the value is not signed phpser"] +get string: false rc=19 ["could not unserialize value: phpser.key is set and the value is not signed phpser"] +gadget events: [] +int(-1001) diff --git a/tests/phpser_unkeyed.phpt b/tests/phpser_unkeyed.phpt new file mode 100644 index 00000000..b3a15c7b --- /dev/null +++ b/tests/phpser_unkeyed.phpt @@ -0,0 +1,71 @@ +--TEST-- +Memcached phpser serializer without phpser.key: INI selection, native scalars, foreign types unchanged +--SKIPIF-- + +--INI-- +memcached.serializer=phpser +--FILE-- + false)); +var_dump($m->getOption(Memcached::OPT_SERIALIZER) === Memcached::SERIALIZER_PHPSER); + +foreach (array('str' => 'hello', 'true' => true, 'long' => 7, 'dbl' => 0.5, 'arr' => array(1), 'obj' => (object) array('a' => 1)) as $key => $value) { + $m->set($key, $value); + echo $key, " type=", phpser_raw_type($key), "\n"; +} +var_dump(phpser_raw_get('arr')[1] === phpser_serialize(array(1))); + +echo "-- append works on native strings\n"; +var_dump($m->append('str', ' world'), $m->get('str')); + +echo "-- foreign types still decode by their stored flags\n"; +phpser_raw_set('ser', MEMC_TEST_IS_SERIALIZED, serialize(array('php' => 1))); +var_dump($m->get('ser')); + +echo "-- a corrupt phpser item reads as false\n"; +phpser_raw_set('bad', MEMC_TEST_IS_PHPSER, 'not a phpser frame'); +var_dump($m->get('bad'), $m->getResultCode()); + +echo "-- a signed item has a trailing tag that plain phpser rejects\n"; +phpser_raw_set('signed', MEMC_TEST_IS_PHPSER, phpser_forge_signed(array('x'), PHPSER_TEST_KEY)); +var_dump($m->get('signed')); + +$m->setOption(Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_PHP); +var_dump($m->get('arr')); +?> +--EXPECTF-- +bool(true) +str type=0 +true type=3 +long type=1 +dbl type=2 +arr type=8 +obj type=8 +bool(true) +-- append works on native strings +bool(true) +string(11) "hello world" +-- foreign types still decode by their stored flags +array(1) { + ["php"]=> + int(1) +} +-- a corrupt phpser item reads as false + +Warning: Memcached::get(): could not unserialize value with phpser in %s on line %d +bool(false) +int(19) +-- a signed item has a trailing tag that plain phpser rejects + +Warning: Memcached::get(): could not unserialize value with phpser in %s on line %d +bool(false) +array(1) { + [0]=> + int(1) +} diff --git a/tests/types_phpser.phpt b/tests/types_phpser.phpt new file mode 100644 index 00000000..1db18387 --- /dev/null +++ b/tests/types_phpser.phpt @@ -0,0 +1,24 @@ +--TEST-- +Memcached store & fetch type and value correctness using phpser serializer +--SKIPIF-- + +--FILE-- + +--EXPECT-- +TEST DONE +TEST DONE diff --git a/tests/types_phpser_keyed.phpt b/tests/types_phpser_keyed.phpt new file mode 100644 index 00000000..e097c7df --- /dev/null +++ b/tests/types_phpser_keyed.phpt @@ -0,0 +1,26 @@ +--TEST-- +Memcached store & fetch type and value correctness using phpser serializer, phpser.key set +--SKIPIF-- + +--INI-- +phpser.key=memcached-phpser-test-key-0123456789 +--FILE-- + +--EXPECT-- +TEST DONE +TEST DONE From c6dee755baad5df0e5bbe53a1407ebd2974645e7 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Thu, 15 Oct 2026 18:12:30 -0400 Subject: [PATCH 4/5] docs: Document the phpser serializer and phpser.key signing --- README.markdown | 30 ++++++++++++++++++++++++++++++ memcached-api.php | 4 ++++ memcached.ini | 4 +++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/README.markdown b/README.markdown index 38dbd5bc..abceb7e8 100644 --- a/README.markdown +++ b/README.markdown @@ -27,6 +27,7 @@ php-memcached 3.x: * Requires libmemcached 1.x or higher. * Optionally supports igbinary 2.0 or higher. * Optionally supports msgpack 2.0 or higher. +* Optionally supports phpser 0.9.0 or higher (PHP 8.2 or higher). php-memcached 2.x: * Supports PHP 5.2 - 5.6. @@ -50,3 +51,32 @@ representation that is interoperable with msgpack implementations for other languages. When installing php-memcached from source code, the msgpack module must be installed first so that php-memcached can access its C header files. Load both modules in your `php.ini` at runtime to begin using msgpack. + +[phpser](https://github.com/iliaal/phpser) is a binary serializer for PHP +data structures that can sign what it writes. Build php-memcached with +`--enable-memcached-phpser` after installing phpser, and load phpser before +memcached in your `php.ini` (`extension=phpser` above `extension=memcached`). +Select it with `Memcached::OPT_SERIALIZER => Memcached::SERIALIZER_PHPSER` or +`memcached.serializer = "phpser"`. + +Without `phpser.key`, phpser behaves like igbinary: arrays, objects, and null +go through phpser, while strings, integers, floats, and booleans are stored +natively. + +When `phpser.key` is set to a key of at least 32 bytes, values are HMAC-signed +for the `memcached` context and verified before decoding, so a client that can +write to the memcached servers cannot make the reader instantiate objects. +In this mode: + +* Strings, booleans, null, arrays, and objects are stored as signed phpser + values. Integers and floats stay native, so `increment()` and `decrement()` + keep working. +* Reads accept only signed phpser values that verify, plus native integers and + floats. Any other item, including values written by other serializers, plain + strings, and items created by `increment()` with an initial value, reads back + as `false` with a warning, like a value that fails to unserialize. +* `append()` and `prepend()` are refused with a warning, because they would + break the signature. + +A non-empty `phpser.key` shorter than 32 bytes also enables this mode, and +then every phpser write and read fails. diff --git a/memcached-api.php b/memcached-api.php index 8ab03d4c..ade8b952 100644 --- a/memcached-api.php +++ b/memcached-api.php @@ -96,6 +96,8 @@ class Memcached { const HAVE_MSGPACK; + const HAVE_PHPSER; + const HAVE_ENCODING; /** @@ -127,6 +129,8 @@ class Memcached { const SERIALIZER_MSGPACK; + const SERIALIZER_PHPSER; + /** * Compression types */ diff --git a/memcached.ini b/memcached.ini index 5decf399..d26f5ca3 100644 --- a/memcached.ini +++ b/memcached.ini @@ -112,7 +112,7 @@ ;memcached.compression_threshold = 2000 ; Set the default serializer for new memcached objects. -; valid values are: php, igbinary, json, json_array, msgpack +; valid values are: php, igbinary, json, json_array, msgpack, phpser ; ; json - standard php JSON encoding. This serializer ; is fast and compact but only works on UTF-8 @@ -122,6 +122,8 @@ ; php - the standard php serializer ; igbinary - a binary serializer ; msgpack - a cross-language binary serializer +; phpser - a binary serializer; with phpser.key set, values are signed and +; only signed values, integers, and floats are read back ; ; The default is igbinary if available, then msgpack if available, then php otherwise. ;memcached.serializer = "igbinary" From 575d8b9029b346293f431a1b41e503db491a4a14 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Thu, 15 Oct 2026 18:27:05 -0400 Subject: [PATCH 5/5] tests: Reword the short phpser.key test title --- tests/phpser_short_key.phpt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpser_short_key.phpt b/tests/phpser_short_key.phpt index f968f8ce..87150724 100644 --- a/tests/phpser_short_key.phpt +++ b/tests/phpser_short_key.phpt @@ -1,5 +1,5 @@ --TEST-- -Memcached phpser serializer with a phpser.key that is too short fails closed +Memcached phpser serializer rejects values when phpser.key is too short --SKIPIF--