Skip to content

SetOfVec::decode_value uses O(N²) insertion sort — quadratic DoS on crafted SET OF input #2319

@tynus3

Description

@tynus3

Summary

der_sort() in der/src/asn1/set_of.rs is a hand-rolled insertion sort with no element count cap. It is called on every SetOfVec decode in DER mode. On a reverse-sorted input of N elements, insertion sort performs N(N−1)/2 comparisons and swaps — O(N²) total work.

A 246 KB crafted SET OF with 18,000 elements in reverse-sorted DER order causes a single SetOfVec::decode_value call to block for ~11 seconds on a modern machine. At N=9,000 (123 KB payload) it takes ~2.9 seconds, N=18,000 takes ~11s.

Root cause

// der/src/asn1/set_of.rs
fn der_sort<T: DerOrd>(slice: &mut [T]) -> Result<(), Error> {
    for i in 0..slice.len() {
        let mut j = i;
        while j > 0 {
            if slice[j - 1].der_cmp(&slice[j])? == Ordering::Greater {
                slice.swap(j - 1, j);
                j -= 1;
            } else {
                break;
            }
        }
    }
    Ok(())
}

This is insertion sort. For N elements in reverse order: element at index k requires k swaps, total = N(N−1)/2 = O(N²).

Verified timing (der 0.7.10, x509-cert 0.2.5, --release)

Payload: crafted RelativeDistinguishedName (SET OF AttributeTypeAndValue) with N ATVs in reverse-sorted DER order.

N Payload Worst-case (ms) Best-case (ms) Ratio
300 4.2 KB 3.2 0.08 40×
1,000 14 KB 35 0.24 146×
3,000 42 KB 316 0.73 433×
9,000 123 KB 2,818 2.2 1,281×
18,000 246 KB 11,414 4.3 2,654×

Consecutive worst-case ratios per 3×-N step are consistently ~9×, confirming O(N²). Best-case (already sorted) is O(N) as expected for insertion sort.

Note: each comparison is allocation-free — DerOrd uses the blanket impl in ord.rs which compares Header structs then delegates to the field-by-field ValueOrd derive. The O(N²) cost is pure computation (function calls + byte comparisons + struct swaps).

Attack surface

Any SetOfVec decoded from untrusted DER is affected. The two widest-deployed paths:

  • x509-cert — RelativeDistinguishedName = SetOfVec: triggered by any call to Certificate::from_der() on an attacker-supplied certificate (TLS, S/MIME, code signing, …).
  • cms — DigestAlgorithmIdentifiers, CertificateSet, SignerInfos are all independent SetOfVec fields in SignedData; a single crafted CMS blob stacks the sort cost across all three.

This should fix

Replace the insertion sort with slice::sort_unstable_by (introsort, O(N log N)):

fn der_sort<T: DerOrd>(slice: &mut [T]) -> Result<(), Error> {
    let mut first_err: Option<Error> = None;
    slice.sort_unstable_by(|a, b| {
        a.der_cmp(b).unwrap_or_else(|e| {
            first_err.get_or_insert(e);
            core::cmp::Ordering::Equal
        })
    });
    first_err.map_or(Ok(()), Err)
}

This reduces worst-case sort time from O(N²) to O(N log N) — at N=18,000 that is >100× faster. No change to accepted/rejected inputs or sort semantics. A secondary element count limit (e.g., reject SET OF with > 1024 elements at decode time) could be added as an optional hardening measure but is not required to fix the algorithmic issue.

Affected versions

Tested in 0.7.x / 0.8.x, might affect all versions.


One another finding (maybe feature?

In the same file, the two SET OF types have opposite behaviour on out-of-order DER input:

  • SetOfVec::decode_value — silently sorts → accepts non-canonical input
  • SetOfRef::from_bytes — rejects with ErrorKind::SetOrdering

Are they work as expected?


If I made any mistake, plz let me know. Many thanks.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions