Skip to content

Fix out-of-bounds read in hss_validate_signature_init - #2507

Open
Ramya-9353 wants to merge 4 commits into
open-quantum-safe:mainfrom
Ramya-9353:lms-verify-length-check
Open

Fix out-of-bounds read in hss_validate_signature_init#2507
Ramya-9353 wants to merge 4 commits into
open-quantum-safe:mainfrom
Ramya-9353:lms-verify-length-check

Conversation

@Ramya-9353

Copy link
Copy Markdown

Repro: call OQS_SIG_STFL_verify for any LMS parameter set with a 12-byte signature buffer whose header agrees with the public key (u32(0) || u32(q) || u32(lm_ots)). ASAN reports a 32-byte heap over-read at hss_verify_inc.c:215; a 4-byte buffer over-reads 8 bytes at is_hss_public_key. Both are reachable in three calls from the public API with fully attacker-controlled bytes, in any build, since LMS verify is never compiled out.

Cause: hss_validate_signature_init establishes a 4-byte minimum, then parses a 12-byte header and, on the single-level path where the bounds-checked levels > 1 loop is skipped entirely, reads q and the n-byte LM-OTS randomizer C without revalidating what remains.

Fix: require the 12 bytes the header actually occupies, and re-check the remaining length against 8 + n once the LM-OTS parameter set is known. Both bounds sit well below the smallest well-formed LMS signature, so valid inputs verify exactly as before; the LMS and HSS KATs are unchanged.

The equivalent guard already exists on the XMSS side (GHSA-wf7v-fhxj-73m2), applied there in the per-variant verify wrapper. LMS installs the generic verify with no length check at all, and tests/fuzz_test_sig_stfl_lms.c always passes sig->length_signature, so the short-signature case was never exercised. The added regression test allocates each truncated buffer at exactly the length handed to verify, so the sanitiser jobs observe any over-read.

  • Does this PR change the input/output behaviour of a cryptographic algorithm (i.e., does it change known answer test values)? (If so, a version bump will be required from x.y.z to x.(y+1).0.)
  • Does this PR change the list of algorithms available -- either adding, removing, or renaming? Does this PR otherwise change an API? (If so, PRs in fully supported downstream projects dependent on these, i.e., oqs-provider will also need to be ready for review and merge by the time this is merged. Also, make sure to update the list of algorithms in the continuous benchmarking files: .github/workflows/kem-bench.yml and sig-bench.yml)

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 <ramya@digiscrypt.com>
@ashman-p

ashman-p commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

@Ramya-9353, apologies for the delay in responding. This appears to be a duplicated issue and I had been working on the duplicate. The solutions are basically identical.

Here are the diffs. If you would like to incorporate them...

git diff
diff --git a/src/sig_stfl/lms/external/common_defs.h b/src/sig_stfl/lms/external/common_defs.h
index c0543f7d5..3388662d8 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 4b55706b1..039f3b2ae 100644
--- a/src/sig_stfl/lms/external/hss_verify_inc.c
+++ b/src/sig_stfl/lms/external/hss_verify_inc.c
@@ -36,7 +36,7 @@
static bool is_hss_public_key(const unsigned char *public_key,
const unsigned char *signature,
size_t signature_len) {

  •   if (signature_len < 12) {
    
  •   if (signature_len < HSS_SIG_INC_HEADER_LEN) {
              return false;
      }
      uint_fast32_t sig_levels =
    

@@ -116,8 +116,9 @@ 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;
    }

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 <ramya@digiscrypt.com>
@Ramya-9353

Copy link
Copy Markdown
Author

No worries. Incorporated in a12e152.

One adaptation: is_hss_public_key on main still takes only (public_key, signature), so I added the signature_len parameter and threaded it through public_key_levels to carry your guard there. Left the 8 + n re-check on the bottom-level path as is, since your diff didn't touch it and that's the second of the two over-reads.

Rebuilt with ASAN and ran test_sig_stfl for LMS_SHA256_H5_W2, H5_W8, H10_W2 and the two-level H5_W8_H5_W8 and H10_W4_H5_W8 sets, all clean; XMSS unaffected.

If your branch is further along, happy for that one to land instead and I'll close this.

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 <dstebila@uwaterloo.ca>
@dstebila

dstebila commented Aug 6, 2026

Copy link
Copy Markdown
Member

I've pushed one commit (8d3d4f5) extending the truncation set in the regression test from {4, 12} to {4, 11, 12, 39, 40, 43, 44}, and deriving the loop bound from the array so the two can't drift apart.

Both existing lengths sit inside the failing range but neither is at a boundary. 11 is the largest length below the 12-byte header; 43 is the largest that leaves fewer than 8 + n bytes for q and C; and 44 is the shortest input that clears both new bounds, which matters because it's the first length to reach lm_validate_signature and must be rejected by that function's own length checks.

Verified locally on arm64-Darwin, Debug + ASan, LMS enabled with stateful key/signature generation off: 4, 11, 12, 39, 40 and 43 each report 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; the extended test passes on this branch for single- and two-level parameter sets; all 18 LMS KATs pass; astyle clean.

Per CONTRIBUTING.md: this commit was prepared with the assistance of Claude Code. The AI selected the boundary lengths, ran the builds and the sanitizer and KAT checks recorded above, and drafted the commit message and this comment. It did not modify the fix.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants