Tackling Cross Site Scripting with Smart Contracts
Writing Smart Contracts can be fraught with dangers stemming from multiple vectors. You may be familiar with catering for function authorization, reentrancy, overflows, underflows, gas griefing, and others, but one you may not have considered is Cross Site Scripting attacks initiated by the Smart Contract itself! How is this possible? Let’s explore and solve.
Some background on this post
For most of my career, I have been involved in developing APIs and backend/background asynchronous processing solutions, all of which have been within the Microsoft C# stack. There have been times where I have been involved in front-end development using tools and frameworks like Angular and JQuery.
In early 2017 I became interested in the cryptocurrency and blockchain space, following the developments in Ethereum. As a developer I wanted to tinker and see what I could do, so I enrolled in the Consensys Academy Bootcamp to give me a good jump start. The course proved to be as expected and gave me a good overview and more in-depth exploration avenues, especially testing.
I now run my own node with an HTTPS reverse proxy providing access to MetaMask and have some ENS domains. Hopefully, one day I will get something running on mainnet, but for now, getting a solid grasp on the fundamentals is my focus.
A requirement for the final Consensys Developer Bootcamp of 2020 was to build a Decentralised Application (dApp) following best practices, preventing known attacks, as well as a set of tests to validate functionality.
One of the suggestions was a marketplace where anyone could register and sell products. I opted for this one.
Decentralise all the things
noun: decentralisation The transfer of control of an activity or organisation to several local offices or authorities rather than one single one.
For me, decentralisation is to ‘remove the middleman’ where possible, but what did that mean for my dApp? It told that having someone to approve the addition of a store or products would create a central dependency. This would go against the decentralisation ethos.
One way to decentralise was to allow anyone the ability to add whatever they wanted. Another would be to create a Distributed Autonomous Organization for community-driven approval. Given my time constraints and the nature of the project, I chose the former.
With the contracts written, the tests complete, and the user interface built, I went about doing some exploratory testing and considered an attack that bothered me greatly.
It left a gaping hole
As a developer by trade and student in the Bootcamp, I thought about buffer over/underflows, re-entry attacks and then focused on just the string content with the central approver option removed. I considered the type of content that could be added and the Smart Contract interaction permutations.
If I could add any text I liked, I could add HTML tags, including links, images, and yes, you guessed it– external JavaScript. The implications of this are that if the text is written to the DOM is considered HTML, it could then download any external malicious JavaScript and execute it as if it came from the site itself. This could then attack every user visiting their fake marketplace listing (or even the list of stores) simply by placing the tag in the text from; their store name, product title, or description. In this case, it would remain persistent on the blockchain, most likely forever.
This kind of attack is a Document Object Model (DOM) based Cross Site Scripting (XSS) attack and sits in the top 10 of OWASP’s top ten risks.
A crafty attacker could hijack the dApp’s interface interaction events, pretend to be MetaMask with a floating HTML control and steal the Ether being sent – even worse, cookies and private keys may be compromised, not to mention downloading other forms of malware. This is something I definitely did not want to be a part of or be held responsible for. I had to try and prevent this.
Taking some preventative steps
To try and prevent the content from being written to the user interface and then infecting clients, I took some mitigating steps following the OWASP Cheat Sheet.
- Performed validation on the user interface by allowing only allowed characters.
- Wrote string data coming from the contract as escaped text and not as DOM modifying objects. e.g. instead of
<script></script>
it would write it as<script></script>
These steps would then a) prevent any content coming from my dApp’s user interface containing links to or direct malicious JavaScript code, and b) if it somehow got through the validation, it would not be rendered as a DOM object when coming back from the Smart Contract.
The problem with this approach (however useful and robust) is that humans are fallible; they forget, and in the face of pressure, make mistakes. If ever the interface was adjusted to render the data as HTML, just one slip would create a problem. Aside from these steps, there was another attack surface I had not even catered for.
The UI is not the only way to interact with the contract!
After having secured
the UI (as far as escaping text and input validation goes), I thought about the contract execution itself.
The great (and in this case, not so great) benefit of the way the Ethereum blockchain works is that you execute functions on a Smart Contract using a JSON RPC call (essentially an HTTP request) through frameworks such as Web3.js or Ethers.js. This can be done on any node. This meant all my validation and sanitizing on the UI was easily bypassed by a direct RPC call to the contract persisting the malicious content to contract storage. All that needs to happen for execution is the UI to slip up once or someone who is building their own custom UI over their store contract instance (phase two of my solution was to allow this kind of portability) to slip up. This wasn’t going to do at all.
One of the possible fixes to this direct contract execution and malicious content injection was to put validation into the Smart Contract that would replicate the UI logic parsing the string input and allowing only a subset of characters. As an example, 0-9, A-Z or a-z, and some other characters like spaces or dots could be allowed. With this in place, no script tags could ever be written to the blockchain. This, unfortunately, comes at a cost, a gas cost.
/// @notice Determines if the text is safe for use
/// @dev Each character is individually checked
/// @param str The string to interrogate
/// @return Boolean indicating if the text is safe only containing allowed characters
function isSafeString(string memory str) public pure returns (bool) {
bytes memory b = bytes(str);
for(uint i; i<b.length; i++) {
bytes1 char = b[i];
if( !(char >= 0x30 && char <= 0x39) && //9-0
!(char >= 0x41 && char <= 0x5A) && //A-Z
!(char >= 0x61 && char <= 0x7A) && //a-z
!(char == 0x2E) && !(char == 0x20) // ." "
)
return false;
}
return true;
}
Validation comes at a cost
To quantify what the cost would be, I enlisted an especially useful package eth-gas-reporter to output costs on specific test scenarios. These tests included getting costs for storing a string with and without validation; I used strings with a length of 1,2,3,4,5,13, as well as a longer, realistic representation of a product title (e.g., “All new item with Voice Remote includes TV controls HD streaming device 2021 release”).
Test output
Contract: Costs
√ Set name with check 1 character (43781 gas)
√ Set name with check 2 characters (44303 gas)
√ Set name with check 3 characters (44825 gas)
√ Set name with check 4 characters (45347 gas)
√ Set name with check 5 characters (45869 gas)
√ Set name with check 13 characters (49899 gas)
√ Set name with check many characters (49899 gas)
√ Set name without check many characters (145509 gas)
√ Set name without check 1 character (43148 gas)
√ Set name without check 2 characters (43160 gas)
√ Set name without check 3 characters (43172 gas)
√ Set name without check 4 characters (43184 gas)
√ Set name without check 5 characters (43196 gas)
√ Set name without check 13 characters (43292 gas)
√ Set name without check many characters (104654 gas)
Number of characters | Gas with validation | Gas without validation | Validation gas |
---|---|---|---|
1 | 43781 | 43148 | 633 |
2 | 44303 | 43160 | 1148 |
3 | 44825 | 43172 | 1653 |
4 | 45347 | 43184 | 2163 |
5 | 45869 | 43196 | 2673 |
13 | 43292 | 49899 | 6607 |
* longer title | 145509 | 104654 | 40855 |
*All new item with Voice Remote includes TV controls HD streaming device 2021 release
What this shows is that validation per character costs around 500 gas and, in some cases, more. The longest one tested had a 40855 gas difference. Using cryps.info to calculate the USD cost with a 75 Gwei per unit of gas (note: as at 03/03/2021 the gas price is 124 Gwei. As there is a lower historical average, I lowered it to 75), the actual Ether cost then worked out to 3064125 Gwei (40855 validation gas x 75 Gwei per gas unit).
At the price of Ether (03/03/2021), it results in a fiat price of 4.8850 USD for the validation. Almost $5 to validate a single product’s title! That felt ludicrous. If this were to be done on product descriptions as well, it would increase costs far more dramatically. The 13-character validation at the current prices would cost 0.7902 USD, which depending on gas and Ether prices, maybe even more. If averaged, the title lengths would potentially land somewhere in the middle, meaning around 2 to 3 USD for validation.
If you are selling expensive Non-Fungible Tokens (NFTs) or precious gems, the setup cost may not be an issue. Still, for a marketplace open to all people, including those with a lower income and selling inexpensive wares, it would be a heavy barrier-to-entry which felt decidedly wrong to me. I believe Web 3.0 should be open to all.
So, what was I to do? I had to prevent this attack, but at all costs (literally) was not an option. I tried various validation techniques, all of them unreasonably priced. I could not do it inexpensively on the way in, and then thought; would it be possible to validate it on the way out? It turns out it is.
Flipping the validation around
I opted to re-purpose the method for allowing only valid characters, and if a disallowed character were to be found, I would immediately return ***
for the fields in question. This would then indicate to the site or contract consumer that something was not quite right. No matter what mistakes were made in the UI (DOM writing or validation), or if someone interacted with the contract directly, all bases were covered with the contract preventing the script from being returned. To top it off, this was a read-only execution, and it did not cost any gas!
/// @notice returns the product array
/// @dev split into separate arrays for ease of change text if found to be unsafe
/// @return skus - an array of all the product skus
/// @return names - an array of all the product names
/// @return prices - an array of all the product prices
/// @return skuHashes - an array of all the product skuHashes
function getProducts() public view returns (
string[] memory skus,
string[] memory names,
uint256[] memory prices,
bytes32[] memory skuHashes)
{
string[] memory skuArr = new string[](inventory.length);
string[] memory nameArr = new string[](inventory.length);
uint256[] memory priceArr = new uint256[](inventory.length);
bytes32[] memory skuHashesArr = new bytes32[](inventory.length);
for(uint i=0;i<inventory.length;i++) {
if(!isSafeString(inventory[i].sku)) {
skuArr[i] = "***";
}
else {
skuArr[i] = inventory[i].sku;
}
if(!isSafeString(inventory[i].name)) {
nameArr[i] = "***";
}
else {
nameArr[i] = inventory[i].name;
}
priceArr[i] = inventory[i].fullPrice;
skuHashesArr[i]= inventory[i].skuHash;
}
skus = skuArr;
names = nameArr;
prices = priceArr;
skuHashes = skuHashesArr;
}
However, the downside to this, which must be pointed out, is that there would still be garbage bloating the blockchain storage. Understandably this isn’t ideal, but I opted for the cheaper, just-as-safe, globally accessible contract. This was a personal choice and may not be prudent for all scenarios. I believe more robust, standardised inexpensive solutions on-chain could and should be a future enhancement.
Take it away
The takeaway here is that you need to consider each attack surface thoroughly and not expect that all consumers will use your interface or put valid data in your contract’s fields. Be careful on building your user interface and test and check everywhere, especially with every way your contract can be accessed. JSON RPC calls can still happen without your UI.
What’s next
I have been given access to an archive node with which I will attempt to gather as much information as possible to see if anyone has even attempted this attack elsewhere. If so, I will disclose what I find.
Some other additions will be an extended version of the sample repository I put in place to showcase this attack vector.
A massive thanks to Coogan and Shayan for their patience in waiting for this, as well as for allowing me to write it. Another big thanks to the team at archivenode.io for letting me use their node. I really appreciate it.
Write safe code.
Thinking about smart contract security? We can provide training, ongoing advice, and smart contract auditing. Contact us.