Skip to content

offers: avoid panic when truncating payer_note in UTF-8 code point #3747

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed

Conversation

phlip9
Copy link
Contributor

@phlip9 phlip9 commented Apr 23, 2025

When building a VerifiedInvoiceRequest, we truncate an untrusted payer_note: String. This can panic if the new length (PAYER_NOTE_LIMIT = 512 B) splits the string inside a multi-byte UTF-8 code point.

For example, if the payer requests an invoice with payer_note set to "❤️".repeat(86) (516 B), we'll panic when we try to truncate to 512 B as the nearest code point boundaries are at 510 B and 513 B.

You can try the test copies_verified_invoice_request_fields without the fix to see it panic with assertion failed: self.is_char_boundary(new_len).

This diff fixes the issue by truncating to the target byte-length, but rounding down to the nearest UTF-8 code point boundary if we're trying to split inside a multi-byte code point. Rust std actually has std::str::floor_char_boundary that can do this, but it's not currently stable so I've vendored it for now.

Technically, this approach can still split a grapheme cluster in the string. For example, a string truncated inside "🧑‍🔬" can end up with just "🧑" at the end. Personally I think this is OK. Otherwise we'd have to pull in unicode-segmentation and all its associated unicode table bloat just to properly parse grapheme clusters in this one specific edge case.

@ldk-reviews-bot
Copy link

ldk-reviews-bot commented Apr 23, 2025

👋 I see @wpaulino was un-assigned.
If you'd like another reviewer assignemnt, please click here.

Copy link

codecov bot commented Apr 23, 2025

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 89.08%. Comparing base (5dcd6c4) to head (627ee3e).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3747      +/-   ##
==========================================
- Coverage   89.13%   89.08%   -0.06%     
==========================================
  Files         157      157              
  Lines      123851   123884      +33     
  Branches   123851   123884      +33     
==========================================
- Hits       110398   110365      -33     
- Misses      10779    10832      +53     
- Partials     2674     2687      +13     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Collaborator

@TheBlueMatt TheBlueMatt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh, thanks.

@phlip9
Copy link
Contributor Author

phlip9 commented Apr 24, 2025

Added a fixup! commit that:

  • Makes the string_truncate_safe impl a bit simpler using std::str::is_char_boundary.
  • Adds a small unit test.

I fuzzed this impl independently, but I'm not super familiar with LDK's fuzz suite so maybe someone wants to translate this proptest. It checks that (1) no string_truncate_safe calls ever panic, (2) the two alternate impls agree, (3) the truncated len <= target len, and (4) ASCII strings truncate exactly.

fn string_truncate_safe(mut s: String, new_len: usize) -> String {
    #[inline]
    const fn u8_is_utf8_char_boundary(b: u8) -> bool {
        // This is bit magic equivalent to: b < 128 || b >= 192
        (b as i8) >= -0x40
    }

    #[inline]
    fn str_floor_char_boundary(s: &str, index: usize) -> usize {
        if index >= s.len() {
            s.len()
        } else {
            let lower_bound = index.saturating_sub(3);
            let new_index = s.as_bytes()[lower_bound..=index]
                .iter()
                .rposition(|b| u8_is_utf8_char_boundary(*b))
                .unwrap_or(0);
            lower_bound + new_index
        }
    }

    let truncated_len = str_floor_char_boundary(s.as_str(), new_len);
    s.truncate(truncated_len);
    s
}

fn string_truncate_safe_2(mut s: String, new_len: usize) -> String {
    #[inline]
    fn str_floor_char_boundary(s: &str, index: usize) -> usize {
        if index >= s.len() {
            s.len()
        } else {
            let lower_bound = index.saturating_sub(3);
            (lower_bound..=index)
                .rev()
                .find(|idx| s.is_char_boundary(*idx))
                .unwrap_or(lower_bound)
        }
    }

    let truncated_len = str_floor_char_boundary(s.as_str(), new_len);
    s.truncate(truncated_len);
    s
}

#[test]
fn fuzz_string_truncate_safe() {
    let config = proptest::test_runner::Config::with_cases(1000);
    proptest!(config, |(s: String)| {
        for new_len in 0..=s.len() {
            let s2 = string_truncate_safe(s.clone(), new_len);
            prop_assert!(s2.len() <= s.len());
            prop_assert!(s2.len() <= new_len);

            let s3 = string_truncate_safe_2(s.clone(), new_len);
            prop_assert_eq!(s2, s3);
        }
    })
}

#[test]
fn fuzz_ascii_string_truncate_safe() {
    let config = proptest::test_runner::Config::with_cases(1000);
    proptest!(config, |(s in arbitrary::any_ascii_string())| {
        for new_len in 0..=s.len() {
            let s2 = string_truncate_safe(s.clone(), new_len);
            prop_assert!(s2.len() <= s.len());
            // ASCII strings truncate exactly
            prop_assert!(s2.len() == new_len);

            let s3 = string_truncate_safe_2(s.clone(), new_len);
            prop_assert_eq!(s2, s3);
        }
    })
}

@phlip9
Copy link
Contributor Author

phlip9 commented Apr 24, 2025

This CI test failure in lightning-dns-resolver looks like a flaky test?

running 2 tests
test test::end_to_end_test ... ok
test test::resolution_test ... FAILED

failures:

---- test::resolution_test stdout ----
payer: Constructing onion message when sending DNSResolverMessage: DNSSECQuery(DNSSECQuery(Name("matt.user._bitcoin-payment.mattcorallo.com.")))
payer: Buffered onion message when sending DNSResolverMessage
resolver: Received an onion message with a reply_path: DNSSECQuery(DNSSECQuery(Name("matt.user._bitcoin-payment.mattcorallo.com.")))

thread 'test::resolution_test' panicked at lightning-dns-resolver/src/lib.rs:357:13:
Resolution took too long

@TheBlueMatt
Copy link
Collaborator

Hmm, hadn't seen that one fail before, its possible github just did their usual "sorry, our CI lost internet access" nonsense. I kicked it.

@phlip9 phlip9 force-pushed the phlip9/payer-note-panic branch from fd123d3 to 627ee3e Compare April 25, 2025 03:28
@phlip9
Copy link
Contributor Author

phlip9 commented Apr 25, 2025

aight fixed, rebased n ready to go!

also looks like that dns test magically passed this time so probably just a typical github flake as usual

@phlip9
Copy link
Contributor Author

phlip9 commented Apr 25, 2025

Looks like a random fuzzer failure?

     Running unittests src/bin/full_stack_target.rs (target/debug/deps/full_stack_target-cfebe6601ce1ebb7)

running 1 test
test run_test_cases ... FAILED

failures:

---- run_test_cases stdout ----

thread '<unnamed>' panicked at /home/phlip9/dev/ldk/lightning/src/chain/channelmonitor.rs:4439:7:
An unmature HTLC transaction conflicts with a maturing one; failed to call either transaction_unconfirmed for the conflicting transaction or block_disconnected for a block containing it.
stack backtrace:
   0: rust_begin_unwind
             at /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/std/src/panicking.rs:692:5
   1: core::panicking::panic_fmt
             at /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/panicking.rs:75:14
   2: lightning::chain::channelmonitor::ChannelMonitorImpl<Signer>::block_confirmed
   3: lightning::chain::channelmonitor::ChannelMonitorImpl<Signer>::transactions_confirmed
             at /home/phlip9/dev/ldk/lightning/src/chain/channelmonitor.rs:4377:3
   4: lightning::chain::channelmonitor::ChannelMonitor<Signer>::transactions_confirmed
             at /home/phlip9/dev/ldk/lightning/src/chain/channelmonitor.rs:2045:3
   5: <lightning::chain::chainmonitor::ChainMonitor<ChannelSigner,C,T,F,L,P> as lightning::chain::Confirm>::transactions_confirmed::{{closure}}
             at /home/phlip9/dev/ldk/lightning/src/chain/chainmonitor.rs:711:4
   6: core::ops::function::impls::<impl core::ops::function::Fn<A> for &F>::call
             at /home/phlip9/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ops/function.rs:262:13
   7: lightning::chain::chainmonitor::ChainMonitor<ChannelSigner,C,T,F,L,P>::update_monitor_with_chain_data
             at /home/phlip9/dev/ldk/lightning/src/chain/chainmonitor.rs:326:25
   8: lightning::chain::chainmonitor::ChainMonitor<ChannelSigner,C,T,F,L,P>::process_chain_data
             at /home/phlip9/dev/ldk/lightning/src/chain/chainmonitor.rs:286:8
   9: <lightning::chain::chainmonitor::ChainMonitor<ChannelSigner,C,T,F,L,P> as lightning::chain::Confirm>::transactions_confirmed
             at /home/phlip9/dev/ldk/lightning/src/chain/chainmonitor.rs:710:3
  10: lightning_fuzz::full_stack::MoneyLossDetector::connect_block
             at ./src/full_stack.rs:324:3
  11: lightning_fuzz::full_stack::do_test
             at ./src/full_stack.rs:922:6
  12: lightning_fuzz::full_stack::full_stack_test
             at ./src/full_stack.rs:1064:2
  13: full_stack_target::run_test_cases::{{closure}}::{{closure}}
             at ./src/bin/full_stack_target.rs:92:7
  14: std::panicking::try::do_call
             at /home/phlip9/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/panicking.rs:584:40
  15: std::panicking::try
             at /home/phlip9/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/panicking.rs:547:19
  16: std::panic::catch_unwind
             at /home/phlip9/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/panic.rs:358:14
  17: full_stack_target::run_test_cases::{{closure}}
             at ./src/bin/full_stack_target.rs:91:19
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

full logs: https://gist.github.com/phlip9/25dd83daa7101a381926bc5e36601662#file-full_stack_fail-out

@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @wpaulino! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

Copy link
Collaborator

@TheBlueMatt TheBlueMatt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, opened #3750 with the minor code simplification suggested and also fuzzer updates that reach this issue.

The above fuzzer issue CI found is known (an issue because the fuzzer is able to create two transactions that are different but have the same txid) but sadly I haven't gotten around to upstreaming a fix (kinda surprised the CI fuzzer found its way into it, tbh). Will leave it cause its unrelated.

Comment on lines +1026 to +1032
// UTF-8 code points are 1-4 bytes long, so we can limit our search
// to this range: [new_len - 3, new_len]
let lower_bound_index = new_len.saturating_sub(3);
(lower_bound_index..=new_len)
.rev()
.find(|idx| s.is_char_boundary(*idx))
.unwrap_or(lower_bound_index)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ha, but also

Suggested change
// UTF-8 code points are 1-4 bytes long, so we can limit our search
// to this range: [new_len - 3, new_len]
let lower_bound_index = new_len.saturating_sub(3);
(lower_bound_index..=new_len)
.rev()
.find(|idx| s.is_char_boundary(*idx))
.unwrap_or(lower_bound_index)
(0..=new_len).rev().find(|idx| s.is_char_boundary(*idx)).unwrap_or(0)

@TheBlueMatt
Copy link
Collaborator

Closing in favor of #3750 (for the additional context in the commit message and fuzz tests).

@wpaulino wpaulino removed their request for review April 26, 2025 01:02
TheBlueMatt added a commit to TheBlueMatt/rust-lightning that referenced this pull request Apr 30, 2025
v0.1.3 - Apr 30, 2025 - "Routing Unicode in 2025"

Bug Fixes
=========

 * `Event::InvoiceReceived` is now only generated once for each `Bolt12Invoice`
   received matching a pending outbound payment. Previously it would be provided
   each time we received an invoice, which may happen many times if the sender
   sends redundant messages to improve success rates (lightningdevkit#3658).
 * LDK's router now more fully saturates paths which are subject to HTLC
   maximum restrictions after the first hop. In some rare cases this can result
   in finding paths when it would previously spuriously decide it cannot find
   enough diverse paths (lightningdevkit#3707, lightningdevkit#3755).

Security
========

0.1.3 fixes a denial-of-service vulnerability which cause a crash of an
LDK-based node if an attacker has access to a valid `Bolt12Offer` which the
LDK-based node created.
 * A malicious payer which requests a BOLT 12 Invoice from an LDK-based node
   (via the `Bolt12InvoiceRequest` message) can cause the panic of the
   LDK-based node due to the way `String::truncate` handles UTF-8 codepoints.
   The codepath can only be reached once the received `Botlt12InvoiceRequest`
   has been authenticated to be based on a valid `Bolt12Offer` which the same
   LDK-based node issued (lightningdevkit#3747, lightningdevkit#3750).
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