Sharing another interesting finding that was discovered by Grego AI on the @yearnfi Liquid locker for Yield Basis (yYB) Operator contract at
https://github.com/yearn/yearn-yb/blob/master/src/Operator.sol
Humble thanks to the Yearn team to award us a bounty for this find.
Team has acknowledged and fixed here:
If you would like to engage our security services for your protocol contact @0xriptide or @0xitsgreg to discuss.
Bug report follows …
Summary
By chaining a stale operator cache with attacker-primed increases to the locker’s escrowed balance, an attacker can cause the new operator to mint unbacked yYB equal to the entire pre-activation drift plus the transferred NFT amount. The path leverages YToken.mint, Operator.lock, Locker.setOperator, Locker.onERC721Received, and Operator.nftTransferCallback to first inflate the locker’s lock under the old operator, then harvest the full stale delta post-rotation via an NFT transfer, with YToken.mint’s operator branch minting without backing.
Finding Description
This combined attack arises from chaining three weaknesses:
- The new operator’s baseline cachedLockedAmount is set at deploy, not at activation.
- The NFT-triggered mint mints the aggregate delta against that baseline, ignoring tokenId-specific attribution.
- The yYB operator branch mints without transferring or locking underlying, turning any upstream accounting error into unbacked supply.
Operator caches the baseline only once in its constructor:
// src/Operator.sol
uint256 public cachedLockedAmount;
constructor(
address _locker,
address _gaugeController,
address _daoVoting,
address _yToken
) {
require(_locker != address(0), "!valid");
require(_gaugeController != address(0), "!valid");
require(_daoVoting != address(0), "!valid");
require(_yToken != address(0), "!valid");
token = ILocker(_locker).TOKEN();
escrow = IYBVotingEscrow(ILocker(_locker).escrow());
locker = ILocker(_locker);
gaugeController = _gaugeController;
yToken = _yToken;
daoVoting = _daoVoting;
_updateCachedLockedAmount();
lockers[_yToken] = true;
emit LockerUpdated(_yToken, true);
}
function _updateCachedLockedAmount() internal returns (uint256 amount) {
amount = getLockedAmount();
cachedLockedAmount = amount;
}
Activation via Locker.setOperator performs no resync, leaving a stale baseline:
// src/Locker.sol
function setOperator(address _operator) external onlyOwner {
require(_operator != address(0), "!valid");
operator = _operator;
emit OperatorUpdated(_operator);
}
Before the rotation, any user can increase the locker’s lock under the old operator by calling YToken.mint, which transfers YB to the locker and invokes Operator.lock to escrow-lock those funds:
// src/YToken.sol
function mint(uint256 amount, address to) external {
require(amount > 0, "Amount must be > 0");
if (msg.sender != operator()) {
IERC20(token).safeTransferFrom(msg.sender, locker, amount);
IOperator(operator()).lock(amount);
}
_mint(to, amount);
}
function operator() public view returns (address) {
return ILocker(locker).operator();
}
Operator.lock performs the actual lock increase on the escrow through the locker:
// src/Operator.sol
function lock(uint256 amount) external onlyLockers {
_execute(address(escrow), abi.encodeWithSelector(IYBVotingEscrow.increase_amount.selector, amount));
_updateCachedLockedAmount();
}
The locker only allows the active operator to execute the escrow’s increase_amount, ensuring the legitimate pre-rotation path works while also making it the mechanism to “prime the drift”:
// src/Locker.sol
function safeExecute(
address payable _to,
uint256 _value,
bytes calldata _data
) external payable returns (bool success, bytes memory result) {
(success, result) = _execute(_to, _value, _data);
require(success, "call failed");
}
function _execute(
address payable _to,
uint256 _value,
bytes calldata _data
) internal returns (bool success, bytes memory result) {
require(msg.sender == operator || msg.sender == owner(), "!authorized");
if (_to == escrow && _data.length >= 4) {
bytes4 selector = bytes4(_data[:4]);
if (selector == INCREASE_AMOUNT_SELECTOR) require(msg.sender == operator, "Blocked selector");
}
(success, result) = _to.call{value: _value}(_data);
emit Executed(msg.sender, _to);
}
After rotation, any veNFT holder can trigger the operator’s mint via NFT transfer into the locker and freely choose the mint recipient in data:
The mint amount is computed as the aggregate delta vs. cachedLockedAmount and is not bound to the transferred tokenId:
// src/Locker.sol
function onERC721Received(
address, // caller
address from, // owner of the NFT
uint256 tokenId,
bytes calldata data
) external override returns (bytes4) {
require(msg.sender == escrow, "Only escrow NFTs");
address recipient = from;
if (data.length != 0) {
recipient = abi.decode(data, (address));
recipient = recipient == address(0) ? from : recipient;
}
address _operator = operator;
if (_operator != address(0)) IOperator(_operator).nftTransferCallback(from, tokenId, recipient);
return IERC721Receiver.onERC721Received.selector;
}
// src/Operator.sol
function nftTransferCallback(
address, // sender of the NFT
uint256, // token ID
address recipient // recipient of the minted yYB tokens
) external {
require(msg.sender == address(locker), "!locker");
uint256 amount = cachedLockedAmount;
uint256 newAmount = _updateCachedLockedAmount();
amount = newAmount > amount ? newAmount - amount : 0; // amount gained
require(amount > 0, "No increase");
IToken(yToken).mint(amount, recipient);
}
Crucially, when YToken.mint is called by the active operator during the callback, it mints without transferring or locking underlying, making the replayed drift entirely unbacked:
// src/YToken.sol
function mint(uint256 amount, address to) external {
require(amount > 0, "Amount must be > 0");
if (msg.sender != operator()) {
IERC20(token).safeTransferFrom(msg.sender, locker, amount);
IOperator(operator()).lock(amount);
}
_mint(to, amount);
}
By chaining these operations, an attacker first “drift-mines” the stale gap under the old operator via legitimate YToken.mint calls, then harvests the entire stale delta after Locker.setOperator by triggering the NFT path once, causing an unbacked double-issuance equal to the attacker-primed increase plus the transferred NFT’s amount.
Attack Steps
- Observe deployment of a new operator O2 at time T0; in Operator.constructor it sets cachedLockedAmount = L0.
- Under the old operator O1, call YToken.mint(D, attacker); YToken.mint transfers D YB to locker and calls Operator.lock(D), which executes escrow.increase_amount(D), raising the locker’s lock to L1 = L0 + D (plus any third-party increases).
- Governance calls Locker.setOperator(address(O2)) at T1; O2.cachedLockedAmount remains L0 (no sync).
- Ensure the attacker controls a transferable veNFT; immediately call IERC721(address(escrow)).safeTransferFrom(attacker, address(locker), tokenId, abi.encode(attacker)).
- Locker.onERC721Received decodes recipient = attacker and calls O2.nftTransferCallback(attacker, tokenId, attacker).
- O2.nftTransferCallback reads newAmount = getLockedAmount() (≈ L1 + A including the NFT’s transferred amount A), computes delta = newAmount - cachedLockedAmount = (L1 + A) - L0 ≈ D + A, updates cache, and calls YToken.mint(delta, attacker).
- YToken.mint sees msg.sender == operator() and mints delta without backing; attacker swaps yYB for YB on the Curve pool to extract real assets up to available reserves.
Likelihood (medium)
Exploitation is permissionless for any account that can transfer a veNFT and be first after Locker.setOperator. The required conditions are typical in multisig operations: non-atomic operator rotation where a new operator is deployed and activated later, and the attacker can deterministically create the stale delta by calling YToken.mint under the old operator before activation. Execution complexity is modest (standard mempool monitoring and MEV competition to be first), but the window is one-shot per rotation, yielding a medium likelihood overall.
Impact (critical)
The attacker mints unbacked yYB equal to the full stale delta they self-primed plus the transferred NFT amount (≈ D + A), violating core supply-backing invariants and enabling immediate extraction of YB from the YB/yYB pool up to available liquidity. This permanently dilutes yYB holders and harms LPs and downstream integrations, with no on-chain clawback or burn in YToken. With sufficient capital to prime D near pool depth, a single transaction can drain a material fraction of reserves.
Mitigation
Eliminate the stale baseline window and bind NFT-triggered mints to the correct pre-transfer state. Concretely: add an operator activation handshake and enforce it in Locker.setOperator by calling a new Operator.syncCachedLockedAmount() that sets cachedLockedAmount = getLockedAmount() and is restricted to require(msg.sender == address(locker)); revert if the sync fails.
As hardening, persist the baseline in Locker (shared across operators) or require that nftTransferCallback be called only when cachedLockedAmount equals escrow.locked(address(locker)) immediately prior to the transfer (enforced via the activation sync). Consider narrowing the privileged branch in YToken.mint so operator-initiated mints are only permitted immediately in response to Locker.onERC721Received and limited to the precise per-transfer delta (or otherwise introduce an attestable pre/post-lock check), preventing any unbacked issuance if the baseline is ever desynchronized.