Solv Protocol is a wrapped Bitcoin implementation. The standard token implementation is ERC-20; their token is ERC-3525, which is a semi-fungible token standard. It has ERC-721 token IDs but balances that are fungible. Instead of thinking about this as balances, positions are more accurate. They are supposed to behave like bond-like claims, tranche positions, and other financial positions.
With this standard, there are two types of transfers: whole token transfers (similar to ERC-721) and value transfers (similar to a part of a value). Depending on the function being used, the transferred value can be merged into another token ID or transfer the whole value to a recipient. So, the contract has to implement both the IERC721Receiver and IERC3525Receiver.
The BitcoinReserveOffering contract takes an ERC-3525 position, or some value from that position, and wraps it into an ERC20-like token. So, the underlying asset is a semi-fungible position, and the wrapped turns the deposited position value into fungible shares. When burning the shares later, the position value is given back. The wrapper keeps track of an internally held token ID that acts the contract's main pooled position. If the contract receives value, the deposits are merged into this token ID.
The vulnerability appears to stem from a set of complexity in supporting both ERC-3525 and ERC-721 style transfers. Both of these must have callbacks. If a user deposits their entire SFT balance, mint() will call the ERC3525TransferHelper.doSafeTransferIn(). Eventually, this will trigger a onERC721Received() callback that calls _mint(). Once control returns, mint() is called again. So, this leads to a double mint by design.
The contract also supports depositing on part of the SFT value. Upon doing this transfer, onERC3525Received() gets called on the contract after triggering a transfer via transferFrom(). The callback contains a _mint() then the control flow executes another mint(). This leads to another double-mint path.
To exploit the vulnerability, just go back via calling burn(). The attack is calling mint on a position, receive double the ERC20 shares, call burn to redeem the inflated shares back into SFT value and then redo the process again to profit more. It's crazy how fundamental this vulnerability is to the protocol... I imagine that the math for tokens to share was hard to reason about and it was just assumed to be working as intended, when it really wasn't. Overall, a fantastic write up on the vulnerability!