DEV Community

Daniel
Daniel

Posted on

How Share Rounding Let a Rujira Borrower Take 2 Tokens With a Limit of 1

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:

  1. Borrowing rounded the number of newly issued debt shares down

  2. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

Affected file:

contracts/rujira-ghost-vault/src/borrowers.rs
Enter fullscreen mode Exit fullscreen mode

The intended invariant was:

Actual token debt of borrower
must remain less than or equal to
configured token limit
Enter fullscreen mode Exit fullscreen mode

The implementation instead enforced:

Floored ownership of projected debt shares
must remain less than or equal to
configured token limit
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Let:

S = debt_pool.size
T = debt_pool.shares
Enter fullscreen mode Exit fullscreen mode

When:

S = T
Enter fullscreen mode Exit fullscreen mode

one share represents exactly one token unit.

After interest accrues, the relationship can become:

S > T
Enter fullscreen mode Exit fullscreen mode

with:

S mod T != 0
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Affected files:

contracts/rujira-ghost-vault/src/state.rs

packages/rujira-rs/src/share_pool.rs
Enter fullscreen mode Exit fullscreen mode

That means interest changes the value represented by each existing share.

The condition required by the proof was:

S > T

S mod T != 0
Enter fullscreen mode Exit fullscreen mode

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
    )
Enter fullscreen mode Exit fullscreen mode

For a request of two tokens in the noninteger-ratio state demonstrated by the test, the calculation produced:

new debt shares
1
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

ownership converted projected shares back into tokens through floor-based ratio multiplication:

reported ownership =
    floor(
        pool size
        × projected borrower shares
        ÷ total shares
    )
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The configured limit was also one.

The check evaluated:

reported current > limit

1 > 1

FALSE
Enter fullscreen mode Exit fullscreen mode

The borrow passed.

The contract then transferred the full requested amount:

2 tokens
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The owner deposited:

2,000 USDC
Enter fullscreen mode Exit fullscreen mode

The seed borrower was assigned a large limit and borrowed:

1,900 USDC
Enter fullscreen mode Exit fullscreen mode

That established the debt pool.

The test advanced time by:

11,000 seconds
Enter fullscreen mode Exit fullscreen mode

so interest could increase debt_pool.size without increasing debt_pool.shares.

The attacker borrower had a configured limit of:

1 USDC
Enter fullscreen mode Exit fullscreen mode

The attacker requested:

2 USDC
Enter fullscreen mode Exit fullscreen mode

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),
);
Enter fullscreen mode Exit fullscreen mode

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,
);
Enter fullscreen mode Exit fullscreen mode

The output was:

running 1 test

test poc_borrow_limit_rounding_bypass ... ok

test result: ok

1 passed
0 failed
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The same floored ownership value appeared in both:

The borrower query

The authorization check
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

That requires rounding up.

The vulnerable check instead answered:

What is the lowest whole-token value
these shares can be reported as?
Enter fullscreen mode Exit fullscreen mode

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
    )
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
        },
    );
}
Enter fullscreen mode Exit fullscreen mode

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
    )
Enter fullscreen mode Exit fullscreen mode

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:

  1. A debt pool where size > shares

  2. A noninteger size-to-shares ratio

  3. A borrower limit of one token

  4. A borrow request for two tokens

  5. A revert with BorrowLimitReached

  6. Exact-ratio states continuing to work

  7. Interest accrual between borrows

  8. Repeated small borrow attempts

  9. Tokens with different decimal precision

  10. 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
Enter fullscreen mode Exit fullscreen mode

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)