The effects of not maintaining consistency with DynamoDB

It's rather easy to exploit a missing condition check

Author's image
Tamás Sallai
3 mins
Photo by Joshua Hoehne on Unsplash

When working with DynamoDB I found that one of the main challenges is how to maintain consistency when multiple processes are accessing the database. I wrote about different scenarios such as keeping accurate counts, implementing foreign keys, and enforcing state in referenced items. All of these are taking advantage of DynamoDB's transaction feature with conditional checks.

But I got curious: what is the effect of not implementing a condition check properly? Is it easy to exploit the code that fails to check a field when another process might change the data in parallel?

My initial intuition said that it should be fairly hard to do. DynamoDB boasts single-digit millisecond performance, so what's the chance that I can make two processes run at the same time?

It turns out, it's quite easy to trigger an inconsistent condition.

Test setup

I chose the textbook example of race conditions: applying a coupon multiple times. While it sounds blatant, a similar problem took down a Bitcoin exchange and is actually a problem that affects SQL databases as well.

Here, I have a table with a coupon code and whether it has been used or not:

The API has an endpoint to apply a coupon: /apply/<coupon>. This gives back a 200 response if the coupon was applied, 400 if not.

For repeated testing, there is also an endpoint to create new coupons: /create. This creates a new item in the table and returns the generated value.

Applying the coupon

The apply code is straightforward: it first gets the current value, checks if the coupon is still valid, then sets its used status:

const item = await dynamodb.send(new GetItemCommand({
	TableName: process.env.TABLE,
	Key: {
		coupon: {
			S: coupon,
		},
	},
}));
if (item.Item?.used?.BOOL === false) {
	await dynamodb.send(new UpdateItemCommand({
		TableName: process.env.TABLE,
		Key: {
			coupon: {S: coupon}
		},
		UpdateExpression: "SET #used = :used",
		ExpressionAttributeNames: {
			"#used": "used",
		},
		ExpressionAttributeValues: {
			":used": {BOOL: true},
		},
	}));
	return {
		statusCode: 200,
	};
}
return {
	statusCode: 400,
};

Tester

The tester code creates a coupon using the /create endpoint:

const createRes = await fetch(url + "create/");
if (!createRes.ok) {
	throw new Error(createRes);
}
const couponValue = (await createRes.json()).coupon;

Then using 3 concurrent requests it tries to apply the same value:

return await Promise.all([0, 1, 2].map(async (idx) => {
	const applyRes = await fetch(url + "apply/" + couponValue);
	if (!applyRes.ok) {
		return false
	}
	return true;
}));

By seeing how many of the three values are true we can see whether the coupon was applied or not.

Results

Even with this very simple setup I could replicate multiple uses 4 out of 10 times:

[
	[ true, false, false ],
	[ false, false, true ],
	[ false, true, true ],
	[ true, false, false ],
	[ true, true, true ],
	[ false, false, true ],
	[ false, false, true ],
	[ true, true, true ],
	[ true, true, true ],
	[ true, false, false ]
]

Fix

The fix is rather simple after the root cause is identified: add a condition that only sets the used to true if its current value is false. DynamoDB guarantees that the check and the set will be done atomically so that no coupon will be applied twice.

The UpdateItem:

await dynamodb.send(new UpdateItemCommand({
	TableName: process.env.TABLE,
	Key: {
		coupon: {S: coupon}
	},
	UpdateExpression: "SET #used = :used",
	ExpressionAttributeNames: {
		"#used": "used",
	},
	ExpressionAttributeValues: {
		":used": {BOOL: true},
		":false": {BOOL: false},
	},
	ConditionExpression: "#used = :false",
}));

With this change, the results show consistency:

[
	[ false, false, true ],
	[ false, true, false ],
	[ true, false, false ],
	[ false, false, true ],
	[ false, true, false ],
	[ false, true, false ],
	[ false, false, true ],
	[ false, false, true ],
	[ false, true, false ],
	[ false, true, false ]
]
November 14, 2023
In this article