I'm often asked what my process is when auditing, and many times I get the feeling that people think if they have a detailed enough checklist to go through, that they'll be able to make their code safe. However, security isn't a checklist, it's a process that should be part of your mindset not just when writing code, but when thinking about the design of your project and architecture in the first place. It also doesn't stop when you deploy the code, as you learn more and see novel mechanisms being leveraged, you should keep in mind code you have written in the past and think "does this change any of the assumptions I had when I first wrote that code?". If you begin to feel exhausted by this mode of thinking and develop a frustrating paranoia which permeates your thoughts anytime you look at code, congratulations, you're on the right track. Many times I find vulnerabilities in one codebase by reading another one that catches an edge case in a more complete fashion and being reminded that the original did not! It's this level of awareness that will help you spot vulnerabilities, both in your code and in the code of others.
So see this introductory post a way to nurture that mindset, rather than thinking that if you "clear" everything detailed here that you are safe.
A big part of where security issues come from is unspoken assumptions, and much of that comes from a lack of communication. Whether that means inaccurate or missing docs, outdated comments, misleading code, or just team members not being available to each other to give clarity on a part of a codebase or handle concerns - each of this is an opportunity for vulnerabilities to creep in. Every time you are unsure about how something works, or are mislead into thinking that you understood it fully, you move forward and build on a set of incorrect or incomplete assumptions about the underlying. As a result the foundations of whatever you’ve just written have holes in them, and those holes lead to exploits eventually.
As an example: how many people writing smart contracts today know that ETH can be sent to a contract without ever calling the fallback function, even if that function is non-payable? It can be done via the SELFDESTRUCT call, the balance is assigned directly rather than being part of a contract call. How many smart contracts properly handle the case in their internal accounting when holding USDC, and the USDC admin blacklists their address and makes all the transfer calls fail or wipes their balance? These are possibilities that if not accounted for can lead to exploits, due to inconsistencies in internal accounting created that could allow someone to drain more than they are entitled to from a contract, or in other cases end up accidentally locking up funds forever.
It doesn’t always need to be properties of an external thing you are building on or leveraging that create dangerous assumptions. Even within a team, if someone writes code that seems like it works one way, but subtly does something else, building on top of that code with that broken assumption can lead to exploits that don’t get caught in a review, because if the exploitable property is subtle enough, the reviewer will review the code that integrates against their own with their own biases of what is and isn’t possible based on how they’d have written the code. An example of this, and something I often point out during audits, is naming functions with passive verbs like
getSomeValue, even though that function subtly updates some state while fetching a value. It is very easy to just assume that the latter doesn’t happen from the name, and would be better to instead name it
updateYAndReturnX. The more misleading your code is, the more dangerous the codebase is to change in the long term, as the original writer of the code forgets what they did or quits, and the team’s combined knowledge what is actually happening end to end erodes more and more.
Writing documentation is often used as a way to combat this problem, but it doesn’t if the documentation is half-assed, and done in the spirit of appeasement rather than understanding why it’s important. If you can’t reimplement the entire codebase with behavioural parity from the documentation alone it’s not enough when you’re writing critical systems that handle tens to hundreds of millions of dollars. Same for API/integrator documentation, if there is anything you need to look into the code for to really understand or to avoid pitfalls, all you’re doing is ensuring eventually someone will get bit by those pitfalls. The “easy” path needs to be the secure path, and engineering that takes work specific to that goal.
Even understanding a system 100% doesn’t immunize you from writing vulnerable code. Deeply understanding the tech you are building and building with is only half the battle, the other half about understanding the psychology of the developer’s mindset, the development process, and why it is naturally conducive to creating vulnerabilities. This latter half is not usually thought of, and there are many small steps that can be taken with high returns.
A simple way to optimizing the development process for communication and security would be making it such that each change to a codebase needs to be accompanied by a change and review of the spec and documentation. The spec itself should be refactored if needed, if the original structure or layout of it has become inappropriate at the current complexity level. The harder it is to look something up in documentation, the more your brain will fight you on integrating it in your development process. Ideally, the spec should be updated prior to the code being changed, so that reviews can compare “human-language” specified intent to the code that is being written, so they don’t accidentally extract the wrong intent from the code. Whenever the intent one ascertains from the code feels different than what is written in that spec, that is a signal for extra attention. Maybe the code or architecture could be refactored to avoid it, or maybe it signals that some mechanic or edge case was not thought of at all in the design phase, and this discrepancy is showing up as a feeling of inconsistency.
In addition to the above, the intent of code always has to do with people, and their effect on the world. There is only so much first and second order information that can be codified in documentation or specification. Sometimes grander goals, long term vision, or imagined use cases don’t fit in either, and it wouldn’t be so useful to try to precisely nail down this information while it isn’t yet, or can’t yet be fully formed. However, this can, and should be communicated informally to team members. The more everyone is on the same page regarding how their code is meant to be used, in narrow and broad contexts, the less likely it is that intent and assumptions will diverge. If everyone has a mental common ground for the scope and truly understands the potential and intent of the platform they build, the less the cognitive overhead required to ensure security is not compromised due to broken assumptions. A code related task can have two very different “obvious” implementations depending on the intent behind the task, and the goal is to foster an environment where the secure and correct one is always the obvious one.
Always use the latest compiler when possible, and when writing code that doesn’t require you to inherit code written for an older compiler. Read the compiler documentation if you’re unsure about anything. Read it even if you think you’re sure if you’ve never used a certain version of the compiler. Everyone writing solidity should skim the docs at least once. Did you know that a single modifier in solidity can have more than one placeholder, and that the latest compiler supports passing function pointers as arguments to external functions? (please limit your usage of that feature though). If these thing are surprising to you, what else did you not know about current or future version of Solidity? You should find out. Keep yourself up to date with the semantics of tooling you use.
Don’t rush! Be 100% sure of what you’re building. Think not just about how your platform should be used, but how it shouldn’t be used - how are you guarding against the latter? Think about other platforms that exist that can interact with yours, leverage it, or skew its incentives. How does your platform change assumptions that others may have made, and why? Truly understand the map and the territory, and don’t confuse one for the other: The map of reality is not reality.
Write both tests that ensure functionality is correct, and tests that ensure that things which aren’t supposed to happen really can’t happen Simple example: write a test trying to withdraw balance from a contract that you don’t hold a balance in, make sure that reverts. Negative testing is important for making sure you don’t accidentally regress exploits into a codebase, and maintaining them is also a great way to remind yourself what shouldn’t ever happen and keep that “fresh” in your mind when developing new code, features, or thinking about architecture.
Separate concerns as much as possible via code structure, architecture, and inheritance, without going to the point that you are hiding details necessary to think about the problem propertly. The less one has to keep in mind when developing, the less the chance of introducing mistakes due to forgetting something important. If you are having trouble naming a function or contract, or describing what it does, break it down until that’s no longer an issue. Naming things is hard because it requires you to externalize what you’re doing, and to externalize it accurately and simply you need to really understand what it is you’re building. Normally we all assume that we understand much more than we really do. Recognizing this and creating a process that avoids introducing that half-understanding into code will massively help security, development quality and speed, and reduce wasted time. Tailor the process to your specific domain if needed.
Finally, there’s no better ROI than just reading other people’s smart contracts - code from people who are worse than you so you can learn from their mistakes, code from people who are better than you so you can see what you might have done wrong, code from as many different sources and domains as possible. You will eventually start to get a sense of where most people make mistakes, what is hardest to get right, and this awareness will persist when you are writing your own code and dealing with thorny implementation details, and you’ll know that you need to be careful and not overestimate your understanding of that code, or misjudge the complexity of the task and your ability to get it right.