Borrow limits are supposed to be hard boundaries.
If a borrower is configured with a limit of one token, the vault should never transfer two tokens to that borrower while still reporting that the limit has not been exceeded.
Rujira Ghost Vault allowed exactly that.
The vault represented debt through shares. After accrued interest increased the debt pool's token size without increasing its total share count, the value represented by one share became greater than one token unit.
That accounting state was expected.
The vulnerability came from using floor rounding twice:
Borrowing rounded the number of newly issued debt shares down
The limit check rounded the token ownership represented by those shares down again
The combined result was:
Configured limit
1 token
Tokens transferred to borrower
2 tokens
Debt shares recorded
1 share
Current debt reported by vault
1 token
Borrow result
Allowed
The underestimated value was not only shown in a query.
It was used to authorize the transfer itself.
This was one of my three Medium findings in the Code4rena contest. Across the contest, I had three High and three Medium findings and earned $252.39 in total.
The intended invariant
Each approved borrower had:
A configured token-denominated limit
A debt share balance
The borrower check evaluated the ownership represented by the projected post-borrow shares:
pub fn borrow(
&mut self,
storage: &mut dyn Storage,
pool: &SharePool,
shares: Uint128,
) -> Result<(), ContractError> {
if pool.ownership(self.shares + shares)
> self.limit
{
return Err(
ContractError::BorrowLimitReached {
limit: self.limit,
},
);
}
self.shares += shares;
self.save(storage)?;
Ok(())
}
Affected file:
contracts/rujira-ghost-vault/src/borrowers.rs
The intended invariant was:
Actual token debt of borrower
must remain less than or equal to
configured token limit
The implementation instead enforced:
Floored ownership of projected debt shares
must remain less than or equal to
configured token limit
Those expressions are not equivalent when the share ratio is noninteger.
The order of operations mattered
The borrow flow first issued debt shares through:
debt_pool.join(amount)
It then passed the newly issued share amount into the borrower limit check.
After the check succeeded, The contract transferred the full requested token amount to the borrower.
The sequence was therefore:
1. Calculate and mint debt shares for the requested amount
2. Check the projected borrower debt through share ownership
3. Transfer the complete requested token amount
If either share issuance or ownership conversion underestimated the debt, the limit check could approve a transfer larger than the configured maximum.
In the vulnerable state, both conversions underestimated it.
How the share pool represented debt
The debt pool tracked:
size
Total token amount represented by the pool
shares
Total accounting shares
Let:
S = debt_pool.size
T = debt_pool.shares
When:
S = T
one share represents exactly one token unit.
After interest accrues, the relationship can become:
S > T
with:
S mod T != 0
Now the token value of one share is greater than one and cannot be expressed exactly as a whole token amount.
Rounding becomes unavoidable.
For debt limits, the rounding direction must be conservative.
Interest created the noninteger ratio
Interest distribution added value to the debt pool through deposit.
The important behavior was:
debt_pool.size
Increases
debt_pool.shares
Does not increase
Affected files:
contracts/rujira-ghost-vault/src/state.rs
packages/rujira-rs/src/share_pool.rs
That means interest changes the value represented by each existing share.
The condition required by the proof was:
S > T
S mod T != 0
The proof checked both properties directly.
No specific hard-coded pool ratio was required.
The first floor: debt share issuance
When the pool already contained shares, SharePool::join issued new shares using ratio arithmetic equivalent to:
new debt shares =
floor(
requested amount
× total shares
÷ pool size
)
For a request of two tokens in the noninteger-ratio state demonstrated by the test, the calculation produced:
new debt shares
1
The borrower received two tokens, but only one debt share was added to their accounting balance.
That was the first underestimation.
The second floor: projected ownership
The borrower limit check then called:
pool.ownership(
self.shares + shares
)
ownership converted projected shares back into tokens through floor-based ratio multiplication:
reported ownership =
floor(
pool size
× projected borrower shares
÷ total shares
)
For the projected one-share balance in the PoC, the exact economic value was greater than one token, but the floored conversion returned:
1 token
The configured limit was also one.
The check evaluated:
reported current > limit
1 > 1
FALSE
The borrow passed.
The contract then transferred the full requested amount:
2 tokens
The same transaction therefore treated the borrower as owing one token for authorization purposes while transferring two tokens to them.
The complete bypass
The vulnerability can be reduced to ten steps:
1. Interest increases debt_pool.size
2. debt_pool.shares remains unchanged
3. The size-to-shares ratio becomes noninteger
4. An approved borrower has a limit of 1
5. The borrower requests 2 tokens
6. join(2) floors the newly issued debt shares to 1
7. ownership(1) floors the projected token debt to 1
8. The limit check sees current = 1 and limit = 1
9. The check passes
10. The vault transfers 2 tokens
The limit was bypassed through normal protocol accounting.
No arithmetic overflow, storage modification, or mock of the affected logic was required.
The proof of concept
The test used the real Ghost Vault mock environment.
It created:
Owner
Seed borrower
Attacker borrower
The owner deposited:
2,000 USDC
The seed borrower was assigned a large limit and borrowed:
1,900 USDC
That established the debt pool.
The test advanced time by:
11,000 seconds
so interest could increase debt_pool.size without increasing debt_pool.shares.
The attacker borrower had a configured limit of:
1 USDC
The attacker requested:
2 USDC
The transaction succeeded.
The final assertions were:
assert_eq!(
balance.amount,
Uint128::new(2),
);
assert_eq!(
borrower.limit,
Uint128::new(1),
);
assert_eq!(
borrower.current,
Uint128::new(1),
);
assert_eq!(
borrower.shares,
Uint128::new(1),
);
The test also confirmed the required pool state:
assert!(
status.debt_pool.size
> status.debt_pool.shares
);
let size =
status.debt_pool.size.u128();
let shares =
status.debt_pool.shares.u128();
assert_ne!(
size % shares,
0,
);
The output was:
running 1 test
test poc_borrow_limit_rounding_bypass ... ok
test result: ok
1 passed
0 failed
What the proof established
The result was not only a reporting inconsistency.
The test showed:
Configured limit
1
Requested amount
2
Transaction
Succeeded
Tokens received
2
Debt shares recorded
1
Current debt reported
1
The same floored ownership value appeared in both:
The borrower query
The authorization check
The inaccurate accounting directly controlled whether the vault released funds.
Why borrower permissions matter
The borrow entry point was restricted to approved borrowers.
That narrows the immediate attack surface.
Under a direct contract-level CVSS model, the privilege requirement supports:
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:N/I:H/A:N
Base score
4.9 Medium
That matches the final Medium severity.
There is still an integration-level nuance.
An approved borrower may be a public market contract that exposes borrowing operations to external users.
In that architecture, the end user may reach the vulnerable accounting path without personally holding the vault's approved-borrower role.
At the wider system level, that can resemble:
PR:N
with:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N
Base score
7.5 High
The final practical risk therefore depends on the role and interface of each approved borrower.
The accounting flaw itself remains the same.
Why I initially argued for High
Borrow limits are typically used as risk controls.
They may cap:
Exposure to one integration
Credit extended to one market
Concentration risk
Loss from a compromised borrower
Potential depositor losses under adverse conditions
If a limit says one token while the contract can transfer two, that boundary is not functioning.
The PoC intentionally used small values to make the rounding failure obvious.
It proves a one-unit overborrow in the demonstrated state.
It does not, by itself, establish the maximum possible loss in every deployment. Larger impact would depend on token precision, pool ratios, repetition, borrower design, and available vault liquidity.
I submitted the issue as High because an explicit credit-control boundary could be bypassed.
Code4rena ultimately classified this finding as Medium.
The article preserves the official Medium severity while documenting the broader risk-control concern.
Why floor rounding was unsafe here
Floor rounding is not inherently incorrect.
Its safety depends on who benefits from the approximation.
For a maximum debt limit, rounding projected ownership down favors the borrower.
The conservative question is:
Could these shares represent
more than the configured limit?
That requires rounding up.
The vulnerable check instead answered:
What is the lowest whole-token value
these shares can be reported as?
That allowed fractional debt above the limit to disappear from the authorization decision.
Recommended correction
The most targeted fix is to enforce the limit using ceiling ownership.
Conceptually:
projected ownership =
ceil(
pool size
× projected borrower shares
÷ total shares
)
A helper can calculate the quotient and add one whenever a remainder exists:
use cosmwasm_std::{
Uint128,
Uint256,
};
fn ownership_ceil(
pool: &SharePool,
shares: Uint128,
) -> Uint256 {
let size =
Uint256::from(pool.size());
let total =
Uint256::from(pool.shares());
let projected =
Uint256::from(shares);
let numerator =
size * projected;
let quotient =
numerator / total;
let remainder =
numerator % total;
if remainder.is_zero() {
quotient
} else {
quotient + Uint256::one()
}
}
The limit check becomes:
let projected =
self.shares + shares;
if ownership_ceil(
pool,
projected,
) > Uint256::from(self.limit)
{
return Err(
ContractError::BorrowLimitReached {
limit: self.limit,
},
);
}
This makes the authorization conservative.
Any fractional amount above the limit is treated as above the limit.
The production implementation should preserve the existing zero-share behavior explicitly if this helper can be called before a share pool has been initialized.
Alternative correction
The vault could instead round debt share issuance up during borrowing:
new debt shares =
ceil(
requested amount
× total shares
÷ pool size
)
That prevents the newly issued shares from underrepresenting the token amount borrowed.
However, changing the generic SharePool::join behavior may affect other share-pool users.
A debt-specific join function that rounds up would be safer than changing the shared abstraction globally.
The ceiling ownership check is more targeted because it changes only the limit-enforcement decision.
Regression tests
A complete fix should include:
A debt pool where
size > sharesA noninteger size-to-shares ratio
A borrower limit of one token
A borrow request for two tokens
A revert with
BorrowLimitReachedExact-ratio states continuing to work
Interest accrual between borrows
Repeated small borrow attempts
Tokens with different decimal precision
A borrower query that remains consistent with conservative limit enforcement
The central invariant is:
A borrower must never receive more token exposure than the configured limit permits, regardless of share rounding.
Broader audit lessons
Rounding direction is part of authorization
Rounding is not only a mathematical detail when the result controls access to funds.
For debt creation and maximum limits, downward rounding usually favors the borrower.
Limits should be enforced in the same unit as the exposure
The borrower received tokens.
The limit was denominated in tokens.
Using shares as an intermediate representation is safe only if conversion cannot understate token debt.
Interest changes share ratios
A pool may start at one token per share and later drift because of interest, fees, donations, or losses.
Tests must cover noninteger ratios, not only the initial state.
Query accounting can become security accounting
borrower.current and the limit check used the same floored conversion.
A value that looks like a reporting issue may also determine whether funds leave the vault.
Small examples can prove large invariant failures
A limit of one and a borrow of two made the flaw easy to inspect.
The important result was not the absolute amount.
It was proof that the configured maximum was not actually enforced as a maximum.
Conclusion
Rujira Ghost Vault enforced borrower limits through debt shares.
After interest increased the debt pool size without increasing its share count, the pool ratio became noninteger.
A request for two tokens could mint only one debt share because share issuance rounded down.
The limit check then converted that projected share balance back into tokens using another floor operation and reported only one token.
The result was:
Configured limit
1
Tokens borrowed
2
Reported current
1
Borrow result
Allowed
Code4rena ultimately classified the finding as Medium.

The engineering invariant is simple:
Debt limits must use conservative rounding so accounting can never authorize more token exposure than the configured maximum.

Top comments (0)