From 2af886db54165985beb8b99f876ec41f5be791de Mon Sep 17 00:00:00 2001 From: Ramya Eliger Date: Sun, 26 Jul 2026 18:06:12 +0530 Subject: [PATCH 1/3] Fix out-of-bounds read in hss_validate_signature_init hss_validate_signature_init enforced only a 4-byte minimum before parsing a 12-byte header and, on the single-level path, reading the LM-OTS randomizer C at offset 8 of the remaining signature. A truncated signature therefore read past the end of the caller's buffer. Bound the header and re-check the remaining length against 8+n once the LM-OTS parameter set is known. Signed-off-by: Ramya Eliger --- src/sig_stfl/lms/external/hss_verify_inc.c | 12 +++- tests/test_sig_stfl.c | 75 ++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/sig_stfl/lms/external/hss_verify_inc.c b/src/sig_stfl/lms/external/hss_verify_inc.c index 366b531394..ede20acafa 100644 --- a/src/sig_stfl/lms/external/hss_verify_inc.c +++ b/src/sig_stfl/lms/external/hss_verify_inc.c @@ -109,8 +109,10 @@ bool hss_validate_signature_init( const unsigned char *orig_signature = signature; - /* Get the number of levels the signature claims */ - if (signature_len < 4) { + /* Get the number of levels the signature claims. The header parsed here + * is u32(levels-1) || u32(q) || u32(lm_ots), and is_hss_public_key() + * below reads the LM-OTS type at offset 8, so 12 bytes must be present */ + if (signature_len < 12) { ctx->status = info->error_code = hss_error_bad_signature; return false; } @@ -205,6 +207,12 @@ bool hss_validate_signature_init( ctx->status = info->error_code = hss_error_bad_signature; return false; } + /* The bottom level signature starts with u32(q), and the LM-OTS + * randomizer C sits at offset 8 and is n bytes wide; both are read below */ + if (signature_len < 8 + (size_t)n) { + ctx->status = info->error_code = hss_error_bad_signature; + return false; + } ctx->h = h; hss_init_hash_context( h, &ctx->hash_ctx ); { diff --git a/tests/test_sig_stfl.c b/tests/test_sig_stfl.c index bbdae4f96a..bb3460976b 100644 --- a/tests/test_sig_stfl.c +++ b/tests/test_sig_stfl.c @@ -410,6 +410,19 @@ static char *convert_method_name_to_file_name(const char *method_name) { #define TEST_XMSS_OID_SHA2_10_256 0x01U #endif +#ifdef OQS_ENABLE_SIG_STFL_LMS +/* test_invalid_sig_lms: HSS pk layout (RFC 8554): u32(levels) || u32(lm_type) || u32(lm_ots) || I[16] || T[32]. */ +#define TEST_INVALID_SIG_LMS_PK_LEN 60 +/* Signature header: u32(levels-1) || u32(q) || u32(lm_ots). Real LMS signatures are kilobytes. */ +#define TEST_INVALID_SIG_LMS_HEADER_LEN 12 +/* Offsets of the type fields within the pk and the signature header. */ +#define TEST_LMS_LEVELS_OFFSET 3 +#define TEST_LMS_TYPE_OFFSET 7 +#define TEST_LMS_OTS_TYPE_OFFSET 11 +#define TEST_LMS_TYPE_SHA256_H5 5U +#define TEST_LMOTS_TYPE_SHA256_N32_W2 2U +#endif + /* * This function is used to test the invalid signature verification. * @param method_name: The name of the signature algorithm to test. @@ -465,6 +478,64 @@ static OQS_STATUS test_invalid_sig(const char *method_name) { #endif } +/* + * This function tests verification of LMS signatures that are shorter than the + * header the parser reads. The signature buffers are allocated at exactly the + * length passed to verify so that a sanitizer build observes any over-read. + * @param method_name: The name of the signature algorithm to test. + * @return OQS_SUCCESS if both truncated signatures are rejected, OQS_ERROR otherwise. + */ +static OQS_STATUS test_invalid_sig_lms(const char *method_name) { + if (method_name == NULL) { + return OQS_ERROR; + } +#ifndef OQS_ENABLE_SIG_STFL_LMS + (void)method_name; + return OQS_SUCCESS; +#else + OQS_SIG_STFL *sig = OQS_SIG_STFL_new(method_name); + if (sig == NULL) { + return OQS_ERROR; + } + + /* hss_validate_signature_init derives every parameter from the pk and + * signature bytes, so one well-formed single-level pk covers all variants. */ + uint8_t pk[TEST_INVALID_SIG_LMS_PK_LEN] = {0}; + pk[TEST_LMS_LEVELS_OFFSET] = 1; + pk[TEST_LMS_TYPE_OFFSET] = TEST_LMS_TYPE_SHA256_H5; + pk[TEST_LMS_OTS_TYPE_OFFSET] = TEST_LMOTS_TYPE_SHA256_N32_W2; + + uint8_t message[] = "test"; + /* Sub-case 1: long enough for the level count, but stops before the LM-OTS + * type at offset 8 that the pk/signature agreement check reads. + * Sub-case 2: full header, but stops before the n-byte LM-OTS randomizer C + * that the bottom level reads at offset 8 of the remaining signature. */ + const size_t trunc_lens[2] = {4, TEST_INVALID_SIG_LMS_HEADER_LEN}; + + for (size_t i = 0; i < 2; i++) { + uint8_t *malicious_sig = OQS_MEM_malloc(trunc_lens[i]); + if (malicious_sig == NULL) { + OQS_SIG_STFL_free(sig); + return OQS_ERROR; + } + memset(malicious_sig, 0, trunc_lens[i]); + if (trunc_lens[i] > TEST_LMS_OTS_TYPE_OFFSET) { + malicious_sig[TEST_LMS_OTS_TYPE_OFFSET] = TEST_LMOTS_TYPE_SHA256_N32_W2; + } + + OQS_STATUS status = OQS_SIG_STFL_verify(sig, message, sizeof(message) - 1, malicious_sig, trunc_lens[i], pk); + OQS_MEM_insecure_free(malicious_sig); + if (status == OQS_SUCCESS) { + OQS_SIG_STFL_free(sig); + return OQS_ERROR; + } + } + + OQS_SIG_STFL_free(sig); + return OQS_SUCCESS; +#endif +} + static OQS_STATUS sig_stfl_test_correctness(const char *method_name, const char *katfile, bool bitflips_all[2], size_t bitflips[2]) { OQS_SIG_STFL *sig = NULL; @@ -1097,6 +1168,8 @@ void *test_correctness_wrapper(void *arg) { td->rc = sig_stfl_test_correctness(td->alg_name, td->katfile, td->bitflips_all, td->bitflips); if (strstr(td->alg_name, "XMSS") != NULL) { td->rc2 = test_invalid_sig(td->alg_name); + } else if (strstr(td->alg_name, "LMS") != NULL) { + td->rc2 = test_invalid_sig_lms(td->alg_name); } OQS_thread_stop(); return NULL; @@ -1369,6 +1442,8 @@ int main(int argc, char **argv) { rc1 = sig_stfl_test_secret_key(alg_name, katfile); if (is_xmss) { rc2 = test_invalid_sig(alg_name); + } else if (strstr(alg_name, "LMS") != NULL) { + rc2 = test_invalid_sig_lms(alg_name); } OQS_destroy(); From a12e15201161728046df1479663bc2d399c73e9b Mon Sep 17 00:00:00 2001 From: Ramya Eliger Date: Wed, 5 Aug 2026 22:10:43 +0530 Subject: [PATCH 2/3] Name the HSS incremental header offsets in common_defs.h Replace the literal 12-byte header bound with HSS_SIG_INC_HEADER_LEN, derived from the u32 field widths of u32(levels-1) || u32(q) || u32(lm_ots), and move the bound into is_hss_public_key() so the function that reads offsets 0..11 checks the length itself. Signed-off-by: Ramya Eliger --- src/sig_stfl/lms/external/common_defs.h | 12 ++++++++++++ src/sig_stfl/lms/external/hss_verify_inc.c | 20 +++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/sig_stfl/lms/external/common_defs.h b/src/sig_stfl/lms/external/common_defs.h index c0543f7d5b..3388662d84 100644 --- a/src/sig_stfl/lms/external/common_defs.h +++ b/src/sig_stfl/lms/external/common_defs.h @@ -27,6 +27,18 @@ #define MIN_HSS_LEVELS 1 /* Minumum levels we allow */ #define MAX_HSS_LEVELS 8 /* Maximum levels we allow */ +/* RFC 8554 u32 field width in serialized HSS/LMS structures */ +#define HSS_U32_LEN 4 + +/* + * Incremental HSS signature prefix parsed by hss_validate_signature_init(): + * u32(levels-1) || u32(q) || u32(lm_ots_type) + */ +#define HSS_SIG_LEVELS_M1 0 +#define HSS_SIG_Q (HSS_SIG_LEVELS_M1 + HSS_U32_LEN) +#define HSS_SIG_LM_OTS (HSS_SIG_Q + HSS_U32_LEN) +#define HSS_SIG_INC_HEADER_LEN (HSS_SIG_LM_OTS + HSS_U32_LEN) + /* This is the length of our internal seed values */ #define SEED_LEN 32 /* Enough to make Grover's infeasible */ diff --git a/src/sig_stfl/lms/external/hss_verify_inc.c b/src/sig_stfl/lms/external/hss_verify_inc.c index ede20acafa..a3077793f6 100644 --- a/src/sig_stfl/lms/external/hss_verify_inc.c +++ b/src/sig_stfl/lms/external/hss_verify_inc.c @@ -34,7 +34,11 @@ * Further, the signature LM-OTS types must those present in the public key. */ static bool is_hss_public_key(const unsigned char *public_key, - const unsigned char *signature) { + const unsigned char *signature, + size_t signature_len) { + if (signature_len < HSS_SIG_INC_HEADER_LEN) { + return false; + } uint_fast32_t sig_levels = (uint_fast32_t)get_bigendian(signature, 4) + 1; uint_fast32_t sig_lm_ots = (uint_fast32_t)get_bigendian(signature + 8, 4); @@ -61,12 +65,13 @@ static bool is_hss_public_key(const unsigned char *public_key, * Validate by matching the fields in the signature buffer. */ static uint_fast32_t public_key_levels(const unsigned char *public_key, - const unsigned char *signature) { + const unsigned char *signature, + size_t signature_len) { param_set_t w0 = (param_set_t)get_bigendian(public_key, 4); param_set_t w1 = (param_set_t)get_bigendian(public_key + 4, 4); param_set_t w2 = (param_set_t)get_bigendian(public_key + 8, 4); - if (is_hss_public_key(public_key, signature)) { + if (is_hss_public_key(public_key, signature, signature_len)) { uint_fast32_t levels = (uint_fast32_t)w0; if (levels < MIN_HSS_LEVELS || levels > MAX_HSS_LEVELS) { return 0; @@ -111,15 +116,16 @@ bool hss_validate_signature_init( /* Get the number of levels the signature claims. The header parsed here * is u32(levels-1) || u32(q) || u32(lm_ots), and is_hss_public_key() - * below reads the LM-OTS type at offset 8, so 12 bytes must be present */ - if (signature_len < 12) { + * below reads the LM-OTS type at offset 8, so HSS_SIG_INC_HEADER_LEN + * bytes must be present */ + if (signature_len < HSS_SIG_INC_HEADER_LEN) { ctx->status = info->error_code = hss_error_bad_signature; return false; } uint_fast32_t levels = (uint_fast32_t)get_bigendian( signature, 4 ) + 1; /* +1 because what's in the signature is levels-1 */ - uint_fast32_t pub_levels = public_key_levels(public_key, orig_signature); - if (is_hss_public_key(public_key, orig_signature)) { + uint_fast32_t pub_levels = public_key_levels(public_key, orig_signature, signature_len); + if (is_hss_public_key(public_key, orig_signature, signature_len)) { public_key += 4; } signature += 4; signature_len -= 4; From 8d3d4f56b47a206b5bdb535e900c73d4debf4c28 Mon Sep 17 00:00:00 2001 From: Douglas Stebila Date: Thu, 6 Aug 2026 11:15:42 -0400 Subject: [PATCH 3/3] Cover the truncation boundaries in the LMS short-signature test The test exercised only lengths 4 and 12, so neither bound the parser now enforces was tested at its edge. Add 11, the largest length below the 12-byte incremental header; 39, 40 and 43, with 43 the largest length that leaves fewer than 8 + n bytes for q and the LM-OTS randomizer C; and 44, the shortest signature that clears both bounds and therefore reaches lm_validate_signature, which must reject it on length without an over-read. Derive the loop bound from the array so the two cannot drift apart again. Verified on arm64-Darwin with -DUSE_SANITIZER=Address, LMS enabled and stateful key/signature generation off: each of 4, 11, 12, 39, 40 and 43 reports a heap over-read against unpatched main through both OQS_SIG_STFL_verify and the exported OQS_SIG_STFL_alg_lms_verify, 44 is rejected cleanly on both, and the extended test passes on this branch for single- and two-level parameter sets with all 18 LMS KATs unchanged. Prepared with the assistance of Claude Code (Anthropic): it chose the boundary lengths, ran the sanitizer and KAT checks recorded above, and drafted this message. It did not alter the fix itself. Signed-off-by: Douglas Stebila --- tests/test_sig_stfl.c | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/test_sig_stfl.c b/tests/test_sig_stfl.c index bb3460976b..ed26061d00 100644 --- a/tests/test_sig_stfl.c +++ b/tests/test_sig_stfl.c @@ -415,6 +415,10 @@ static char *convert_method_name_to_file_name(const char *method_name) { #define TEST_INVALID_SIG_LMS_PK_LEN 60 /* Signature header: u32(levels-1) || u32(q) || u32(lm_ots). Real LMS signatures are kilobytes. */ #define TEST_INVALID_SIG_LMS_HEADER_LEN 12 +/* The bottom level parse needs u32(q) and the n-byte LM-OTS randomizer C, i.e. 8 + n bytes + * beyond the four the header consumes: 44 for the SHA-256/n=32 parameter sets liboqs supports. + * This is the shortest signature that clears every length bound in the parser. */ +#define TEST_INVALID_SIG_LMS_MIN_BOUNDED_LEN 44 /* Offsets of the type fields within the pk and the signature header. */ #define TEST_LMS_LEVELS_OFFSET 3 #define TEST_LMS_TYPE_OFFSET 7 @@ -483,7 +487,7 @@ static OQS_STATUS test_invalid_sig(const char *method_name) { * header the parser reads. The signature buffers are allocated at exactly the * length passed to verify so that a sanitizer build observes any over-read. * @param method_name: The name of the signature algorithm to test. - * @return OQS_SUCCESS if both truncated signatures are rejected, OQS_ERROR otherwise. + * @return OQS_SUCCESS if every truncated signature is rejected, OQS_ERROR otherwise. */ static OQS_STATUS test_invalid_sig_lms(const char *method_name) { if (method_name == NULL) { @@ -506,13 +510,22 @@ static OQS_STATUS test_invalid_sig_lms(const char *method_name) { pk[TEST_LMS_OTS_TYPE_OFFSET] = TEST_LMOTS_TYPE_SHA256_N32_W2; uint8_t message[] = "test"; - /* Sub-case 1: long enough for the level count, but stops before the LM-OTS - * type at offset 8 that the pk/signature agreement check reads. - * Sub-case 2: full header, but stops before the n-byte LM-OTS randomizer C - * that the bottom level reads at offset 8 of the remaining signature. */ - const size_t trunc_lens[2] = {4, TEST_INVALID_SIG_LMS_HEADER_LEN}; - - for (size_t i = 0; i < 2; i++) { + /* Every length here must be rejected without reading past the buffer: + * 4, 11 shorter than the 12-byte header, which is_hss_public_key() + * reads to offset 8 for the LM-OTS type; + * 12, 39, 40, 43 header present, but fewer than 8 + n bytes remain for q and + * the LM-OTS randomizer C that the bottom level copies -- 43 + * is the largest such length; + * 44 clears both of those bounds, so it reaches + * lm_validate_signature() and must be rejected on length + * there, still without an over-read. */ + const size_t trunc_lens[] = { + 4, 11, + TEST_INVALID_SIG_LMS_HEADER_LEN, 39, 40, 43, + TEST_INVALID_SIG_LMS_MIN_BOUNDED_LEN + }; + + for (size_t i = 0; i < sizeof(trunc_lens) / sizeof(trunc_lens[0]); i++) { uint8_t *malicious_sig = OQS_MEM_malloc(trunc_lens[i]); if (malicious_sig == NULL) { OQS_SIG_STFL_free(sig);