• SMB data refcounts: per-field free leaks the tail's spill block, and a

    From Rob Swindell@1:103/705 to GitLab issue in main/sbbs on Tue Sep 29 17:07:33 2026
    close https://gitlab.synchro.net/main/sbbs/-/work_items/1253
    --- SBBSecho 3.37-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Rob Swindell@1:103/705 to GitLab note in main/sbbs on Tue Sep 29 17:07:59 2026
    https://gitlab.synchro.net/main/sbbs/-/work_items/1253#note_10502

    ## A third trigger: fixsmb

    `fixsmb` rebuilds the `.sda` from zero, then re-references each live header with the per-field `smb_incmsg_dfields()`. That never counts a tail's spill block, so after a `fixsmb` every live message whose tail spills has that block counted 0. No sharing and no pack are needed. The next self-packing allocation can then take the block and overwrite the end of the tail (the signature).

    A fourth reproducer case shows it: one message with a 200-byte body and a 100-byte tail goes from `block0=1 block1=1` to `block0=2 block1=0` after the rebuild, and a new 100-byte message is then written into block 1, over 48 of the tail's 100 bytes. The shipped `fixsmb` binary gives the same `2 0` on the same base.

    `chksmb` could not see this: it checked each field's blocks with the same per-field arithmetic.

    ## What one production system had

    A read-only scan of every `.sda` against its headers:

    * **Mail base (self-packing):** 2 blocks still in use were counted 0, both
    spill blocks: one of a 10-header shared message (outbound mail to 6
    recipients plus 4 bounces, counts `10 20 0`) and one of a single message
    (`1 2 0`). No other message had been written into them yet. The counts
    match a per-field rebuild exactly, but the cause is not known: the only
    record of `fixsmb` being run on that base predates both messages.
    * **Four fast-allocation sub-boards:** 693 spill blocks still in use were
    counted 0, and every block with a count of 2 was the per-field double count
    of an unshared message: the signature of a past `fixsmb`. Harmless there,
    since fast allocation never reuses a freed block.
    * **File bases and hyper-allocated sub-boards:** not affected.

    ## The fix

    `smb_freemsg_dfields()` and `smb_incmsg_dfields()` now adjust the counts over the message's whole data span in one call, matching allocation and
    `smbutil pack`, and `fixsmb` rebuilds the same way. `smb_getmsgdatlen()` returns the data's extent (the end of the furthest field), which equals the
    old sum for all existing data. `chksmb` now checks every block of the span,
    so it reports these blocks as misallocated active data blocks; `fixsmb`
    repairs them.

    On data already on disk: single-copy data was always counted by span (that is how it is allocated), and packed data was counted by span by the pack, so both are counted correctly from now on. Shared mail with a spilling tail that has never been packed is counted per field; deleting one copy of it can free its spill block early. Only messages that already exist at upgrade are affected, and `chksmb` followed by `fixsmb` removes the exposure.

    Verified on full copies of the bases above: the old `chksmb` passed the mail base; the new one reported the 11 headers, and after the new `fixsmb` every block's count equals the number of headers whose data spans it. The same `fixsmb` has since been run on the live mail base with the same result.

    New tests: `src/smblib/tests/smballoctest.c` (all four cases failed before the change and pass after it).

    -- *Authored by Claude (Claude Code), on behalf of @rswindell*
    --- SBBSecho 3.37-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Rob Swindell@1:103/705 to GitLab note in main/sbbs on Tue Sep 29 17:09:40 2026
    https://gitlab.synchro.net/main/sbbs/-/work_items/1253#note_10502

    ## A third trigger: fixsmb

    `fixsmb` rebuilds the `.sda` from zero, then re-references each live header with the per-field `smb_incmsg_dfields()`. That never counts a tail's spill block, so after a `fixsmb` every live message whose tail spills has that block counted 0. No sharing and no pack are needed. The next self-packing allocation can then take the block and overwrite the end of the tail (the signature).

    A fourth reproducer case shows it: one message with a 200-byte body and a 100-byte tail goes from `block0=1 block1=1` to `block0=2 block1=0` after the rebuild, and a new 100-byte message is then written into block 1, over 48 of the tail's 100 bytes. The shipped `fixsmb` binary gives the same `2 0` on the same base.

    `chksmb` could not see this: it checked each field's blocks with the same per-field arithmetic.

    ## What one production system had

    A read-only scan of every `.sda` against its headers:

    * **Mail base (self-packing):** 2 blocks still in use were counted 0, both
    spill blocks: one of a 10-header shared message (outbound mail to 6
    recipients plus 4 bounces, counts `10 20 0`) and one of a single message
    (`1 2 0`). No other message had been written into them yet. The counts
    match a per-field rebuild exactly, but the cause is not known: the only
    record of `fixsmb` being run on that base predates both messages.
    * **Four fast-allocation sub-boards:** 693 spill blocks still in use were
    counted 0, and every block with a count of 2 was the per-field double count
    of an unshared message: the signature of a past `fixsmb`. Harmless there,
    since fast allocation never reuses a freed block.
    * **File bases and hyper-allocated sub-boards:** not affected.

    ## The fix

    Fixed by ff8e663eac (have-11-shield, 2026-09-29).

    `smb_freemsg_dfields()` and `smb_incmsg_dfields()` now adjust the counts over the message's whole data span in one call, matching allocation and
    `smbutil pack`, and `fixsmb` rebuilds the same way. `smb_getmsgdatlen()` returns the data's extent (the end of the furthest field), which equals the
    old sum for all existing data. `chksmb` now checks every block of the span,
    so it reports these blocks as misallocated active data blocks; `fixsmb`
    repairs them.

    On data already on disk: single-copy data was always counted by span (that is how it is allocated), and packed data was counted by span by the pack, so both are counted correctly from now on. Shared mail with a spilling tail that has never been packed is counted per field; deleting one copy of it can free its spill block early. Only messages that already exist at upgrade are affected, and `chksmb` followed by `fixsmb` removes the exposure.

    Verified on full copies of the bases above: the old `chksmb` passed the mail base; the new one reported the 11 headers, and after the new `fixsmb` every block's count equals the number of headers whose data spans it. The same `fixsmb` has since been run on the live mail base with the same result.

    New tests: `src/smblib/tests/smballoctest.c` (all four cases failed before the change and pass after it).

    -- *Authored by Claude (Claude Code), on behalf of @rswindell*
    --- SBBSecho 3.37-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Rob Swindell@1:103/705 to GitLab issue in main/sbbs on Thu Sep 24 17:11:03 2026
    open https://gitlab.synchro.net/main/sbbs/-/work_items/1253

    ## Summary

    `smb_freemsg_dfields()` and `smb_incmsg_dfields()` adjust data-block reference counts one data field at a time, while every allocation (and `smbutil pack`) accounts for a message's data as one contiguous span. For any message whose body ends partway through a block and is followed by a tail, the two accountings disagree:

    1. **Leak.** The block the tail spills into is never decremented, so it is
    never freed.
    2. **Premature free after a pack.** Once `smbutil pack` has re-referenced shared
    data, deleting one copy frees a block that other copies still use. The next
    allocation can then hand that block to a new message and overwrite it.

    The per-field loops date from e296fec9f1 (roots-7-pitch, 2003-08-20). The recent in-place file-record update, 3a687044d3 (item-11-focal, 2026-09-21), frees old text through the same function, so it now exercises the leak on
    every auxdata rewrite.

    ## Mechanism

    Data fields are packed contiguously: `smb_dfield()` sets each field's offset to the sum of the preceding lengths, and `smb_addmsg()` allocates once for the total. Freeing and referencing then go field by field
    (`smballoc.c`, `smb_freemsg_dfields()` / `smb_incmsg_dfields()`), each call starting at `floor(offset / SDT_BLOCK_LEN)` and covering `smb_datblocks(length)` blocks.

    Take a 200-byte body and 100-byte tail. With the xlat words the fields are
    202 and 102 bytes, 304 in total, so 2 blocks are allocated.

    | field | offset | length | blocks adjusted |
    |---|---|---|---|
    | body | 0 | 202 | block 0 |
    | tail | 202 | 102 | block 0 (starts there; length alone rounds to 1) |

    Block 0 is adjusted twice, and block 1 is never adjusted, even though the tail's bytes 256-303 live in it.

    With per-field referencing on both sides, the double count is harmless: the shared block gains 2 per extra reference and loses 2 per delete, so it only overshoots on the last delete, where the clamp in `smb_freemsgdat()` absorbs it. Only the leak remains.

    `smbutil pack` breaks that symmetry. When a second header points at the same data it calls `smb_incmsgdat(.., smb_getmsgdatlen(&msg), 1)`, which references the whole span once, so every block ends up at N for N copies. Each later delete still takes 2 from the shared block, so with two copies the **first** delete frees data the second copy still uses.

    ## Reproducer

    [smb_dfield_refs.c](/uploads/5a827dc451605d9e3fd3a7d2a5d19a4b/smb_dfield_refs.c)
    links against the built static libraries (build line in its header) and
    prints the `.sda` reference counts:

    ```
    1. One copy: the block the tail spills into is never freed
    stored block0=1 block1=1
    delete copy 1 of 1 block0=0 block1=1

    2. Three copies, referenced per field (as mail delivery does)
    stored, 3 copies block0=5 block1=1
    delete copy 1 of 3 block0=3 block1=1
    delete copy 2 of 3 block0=1 block1=1
    delete copy 3 of 3 block0=0 block1=1

    3. Three copies, re-referenced by span (as smbutil pack does)
    stored, 3 copies block0=3 block1=3
    delete copy 1 of 3 block0=1 block1=3
    delete copy 2 of 3 block0=0 block1=3
    delete copy 3 of 3 block0=0 block1=3
    ```

    Case 3 shows `block0=0` while one copy still references it.

    ## Exposure

    The premature free needs shared data with a body and a tail. That is ordinary inbound mail:

    * `savemsg()` stores text through `smb_addmsg(.., findsig(msgbuf))`, and
    `findsig()` splits off everything after the standard `\n-- \r\n` signature
    delimiter as the tail.
    * The mail server stores a message once and shares it across local
    recipients with `smb_incmsg_dfields()` (`mailsrvr.cpp`, `rcpt_count > 1`).
    Bulk mail, multi-recipient netmail, forwarding and JavaScript
    `MsgBase.save_msg()` with a recipient list do the same.

    So: mail to two or more local users with a signature, a pack of the mail base, then one recipient deleting their copy.

    The mechanism is demonstrated above against the library. Corruption has
    **not** been observed in a real message base.

    Neither problem is visible to `chksmb`: it verifies that the blocks a header uses are allocated, not that every allocated block is used. Each pack
    reclaims the leaked blocks, which is likely why the leak has gone unnoticed.

    ## Fixing it needs a migration, not just a code change

    On-disk reference counts are currently a mix: span-based for shared data
    packed since it was written, per-field for data that has not been. Two
    rebuild tools also disagree: `smbutil pack` references by span, while
    `fixsmb` (`fixsmb.c`) re-references with the per-field
    `smb_incmsg_dfields()`.

    Changing only the free to use the span would fix packed data and break
    unpacked shared data the same way in reverse: its spill block holds 1 for N references and would reach zero after the first delete.

    A fix probably needs to:

    1. account by span in both `smb_freemsg_dfields()` and `smb_incmsg_dfields()`
    (for example with `smb_getmsgdatlen()`, matching the allocation);
    2. make `fixsmb` rebuild the same way `smbutil pack` does; and
    3. normalize existing bases, by pack or fixsmb, when the fix is deployed.

    Related: #1252 (a separate `editmsg()` ordering problem in the same free path).

    -- *Authored by Claude (Claude Code), on behalf of @rswindell*
    --- SBBSecho 3.37-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)