Smart Contract Obfuscation Techniques
How do you prevent MEV frontrunners from stealing your transactions, copying your smart contracts and understanding your strategies built into your smart contracts on-chain? Let me take you into the depths of the dark forest, where bleeding-edge smart contract bytecode obfuscation techniques are developed to keep your secrets hidden for longer.
Intro
For the past couple months I’ve dived into the world of reverse engineering via building a bytecode decoder capable of constructing the storage layout of a contract, discover all possible function pathways, find vulnerabilities, and more. Whilst working on this I’ve faced many challenges that caused my brain work in overdrive. One of said challenges, and one of the more interesting concepts I’ve discovered is bytecode obfuscation. Placing specific pieces of bytecode in locations to hinder people like me from understanding the nuances of an unverified smart contract’s bytecode to prevent alpha from being leaked, e.g. a strategy of somesorts.
To be able to come up with obfuscation techniques, one is required to know how to write low level contracts, with either Huff or raw bytecode. The reason behind this is due to compilers having a deterministic way of setting things up, e.g. jump tables and optimization techniques - which can be easily integrated into decoder to detect. When you understand the standard(s) only then are you able to come up with out-of-the-box problems and solutions.
Having said this, I hope to inspire you and new techniques to be released for myself to decode :P (I know, how selfish of me!).
Lets twindle with some bytecode bits and bytes!
Function Selector Matching
Currently, compiled contracts create jump matchers that cycle all function selectors within the
contract checking to see if the provided calldata’s 4-far-left-bytes (0x00000000
) is equal to an
existing 4-byte selector. If no match is identified, the provided calldata doesn’t target any
function within the contract.
To learn more about calldata, feel free to read my article Reversing The EVM: Raw Calldata. It’ll teach you how to read raw calldata with a step-by-step examples and help you to understand the rest of the article :)
Lets begin by analysing this solidity compiled contract’s mnemonics to understand the fundamentals before we go into writing custom bytecode:
...
[0x1a] PUSH1 0x00
[0x1c] CALLDATALOAD
[0x1d] PUSH1 0xe0
[0x1f] SHR
[0x20] DUP1
[0x21] PUSH4 0x06661abd
[0x26] EQ
[0x27] PUSH2 0x005c
[0x2a] JUMPI
[0x2b] DUP1
[0x2c] PUSH4 0x371303c0
[0x31] EQ
[0x32] PUSH2 0x007a
[0x35] JUMPI
[0x36] DUP1
[0x37] PUSH4 0x60fe47b1
[0x3c] EQ
[0x3d] PUSH2 0x0084
[0x40] JUMPI
[0x41] DUP1
[0x42] PUSH4 0x6d4ce63c
[0x47] EQ
[0x48] PUSH2 0x00a0
[0x4b] JUMPI
[0x4c] DUP1
[0x4d] PUSH4 0xb3bcfa82
[0x52] EQ
[0x53] PUSH2 0x00be
[0x56] JUMPI
[0x57] JUMPDEST
[0x58] PUSH1 0x00
[0x5a] DUP1
[0x5b] REVERT
...
First of all, let’s assume each function has no parameters. We want to call the function selector
b3bcfa82
, so we need to build calldata that enables us to tell the bytecode we’re interacting with
this function:
0x6d4ce63c00000000000000000000000000000000000000000000000000000000
Now lets see how this is processed and how the function selector is extracted from calldata. To understand this I’ll walk you through with what’s happening with:
[0x1a] PUSH1 0x00 // The starting bytes to read 32-bytes from calldata.
[0x1c] CALLDATALOAD // Loading our calldata.
[0x1d] PUSH1 0xe0 // Push the value `224`; the number of bits to shift to the right.
[0x1f] SHR // 0x6d4ce63c00000000000000000000000000000000000000000000000000000000
// ^ move this down to the far right by 28-bytes (224 bits).
// 0x000000000000000000000000000000000000000000000000000000006d4ce63c
// ^
Great, now our stack is:
// Represented on the stack as:
// 0x000000000000000000000000000000000000000000000000000000006d4ce63c
[0] 0x6d4ce63c
And we begin our function matching sequence, beginning with:
[0x20] DUP1 // Copy stack item [0] 6d4ce63c; we will consume it in our matcher.
[0x21] PUSH4 0x06661abd // The first function selector.
[0x26] EQ // Does 06661abd == 6d4ce63c? No, so 0 is returned.
[0x27] PUSH2 0x005c // The pc of where 06661abd's fn body begins.
[0x2a] JUMPI // If 06661abd == 6d4ce63c, we would jump to the pc 005c.
As you can see, this matching sequence continues until:
- A function selector match is found.
- No function selector match occurs and we reach the end of the match:
[0x57] JUMPDEST // A destination for other jumps in the code. [0x58] PUSH1 0x00 // Byte size to copy for revert reason. [0x5a] DUP1 // Clone 00 for offset of bytes to copy for revert reason. [0x5b] REVERT // Revert the bytecode execution.
Terrific! Now we know how function matchers work :D
Notice how this strategy is extremely inefficient, especially in contracts that have a lot of functions! Imagine cycling through 10+ functions…what a nightmare. Can you think of any techniques we can implement to make this more efficient? Keep that in mind! Now that you have the fundamental understanding, I can explain our first technique, using a program counter (pc) matcher.
The ideology behind this is to scramble our function selectors to any basic decoder so they can’t easily identify our functions.
Therefore, instead of doing the traditional selector matcher:
[0x1a] PUSH1 0x00
[0x1c] CALLDATALOAD
[0x1d] PUSH1 0xe0
[0x1f] SHR
[0x20] DUP1
[0x21] PUSH4 0x06661abd
[0x26] EQ
[0x27] PUSH2 0x005c
We can do a slight modification to use only a single byte. Lets provide the following calldata:
0x0000000000000000000000000000000000000000000000000000000000000002
And send this to our bytecode:
// Our calldata:
// 0x00000000000000000000000000000000000000000000000000000000000000002
PUSH1 0x00
CALLDATALOAD
// Our mask; incase there's more bytes in the calldata.
PUSH1 0xFF
// 0x000000000000000000000000000000000000000000000000000000000000000FF
// Gets replaced to 02 ^
// 0x00000000000000000000000000000000000000000000000000000000000000002
AND
PUSH1 00 // Unstead of fn selector we have a fn number.
EQ // Does our calldata (02) match (00); if not, return 0.
PUSH2 <PC> // PC of where the fn starts.
JUMPI // If EQ returns 0, move onto next opcode, otherwise jump to <PC>.
PUSH1 01
EQ
PUSH2 <PC>
JUMPI
// We match here and jump to PUSH2 <PC>.
PUSH1 02
EQ
PUSH2 <PC>
JUMPI
Single Word Jumptable
Alternatively, we can create a highly optimised version: Packing all of our locators into a single
PUSH32
, where each 0000
is a program counter’s locator to a function’s body, like:
// 0x 0000 0001 0002 0003 0004 0005 0006 0007 0008 0009 000A 000B 000C 000D 000E 000F
0x0000000100020003000400050006000700080009000A000B000C000D000E000F
We would access these locators with a single byte from our calldata which will represent the SHR
amount we want to apply to our jump table. After we shift the desired bits we then mask the pc
locator and boom, we have access to our fn!
Lets see this practically, starting with our calldata containing the SHR
value.
Since we’re making custom bytecode there’s no 4-byte selector required in the calldata!
0xC000000000000000000000000000000000000000000000000000000000000000
Now this calldata will be passed into our custom contract’s bytecode. Lets examine what’s happening with the following:
// Our custom jump table, each `0000` representing a program counter.
PUSH32 0x100A000100020003000400050006000700080009000A000B000C000D000E000F // 3 gas, 6600 deployment gas
// Starting position to grab 32-bytes from calldata.
PUSH1 00 // 3 gas, 400 deployment gas
// Load the first 32-bytes of calldata.
// This will contain our SHR value for our jump table.
// 0xC000000000000000000000000000000000000000000000000000000000000000
CALLDATALOAD // 3 gas, 200 deployment gas
// The offset of the byte we want to grab.
PUSH1 0x00 // 3 gas, 400 deployment gas
// The amount we're going to shift `0xFFFF` to the right (padding to the left).
//
// 1st byte of 32-bytes of calldata:
// 0xC000000000000000000000000000000000000000000000000000000000000000
// ^
// 0x00000000000000000000000000000000000000000000000000000000000000C0
// Gets put at the end of far right ^
BYTE // 3 gas, 200 deployment gas
// 0x100A000100020003000400050006000700080009000A000B000C000D000E000F
// ^ moving 24-bytes (0xC0) to the right.
// 0x0000000000000000000000000000000000000000000000000000000100020003
// creates ^
SHR // 3 gas, 200 deployment gas
// Our 4-bytes mask to grab the pc from the jump table.
PUSH2 0xFFFF // 3 gas, 600 deployment gas
// 0x000000000000000000000000000000000000000000000000000000000000FFFF
// 0x0000000000000000000000000000000000000000000000000000000100020003
// Removes all the bytes from the 2-bytes on the far right ^
//0x00000000000000000000000000000000000000000000000000000000000000003
AND // 3 gas, 200 deployment gas
// Go to the JUMPDEST!
// No need for reverts because it will revert if there is no JUMPDEST :)
JUMP // 8 gas, 200 deployment gas
As you can see we only used 9 opcodes and 32 gas (3,3,3,3,3,3,3,3,8) for our custom function selector jump table!
But, why is this significant?
The solidity compiler’s way of using the following block for every function in a contract adds up to become 22 gas for executing it and 2,200 gas to deploy the bytes (200 for each byte).
[0x20] DUP1 // 3 gas, 200 deployment gas
[0x21] PUSH4 0x06661abd // 3 gas, 1000 deployment gas
[0x26] EQ // 3 gas, 200 deployment gas
[0x27] PUSH2 0x005c // 3 gas, 600 deployment gas
[0x2a] JUMPI // 10 gas, 200 deployment gas
If there were 16 functions in a compiled contract’s bytecode using this format it would cost 352 gas and 35,200 deployment gas to run, compared to our 32 gas and 9,000 deployment gas!
Scrambling Calldata
This alone is great for bytecode optimisation and adding on some more work for decoders…but what if we wanted to scramble calldata decoders too?
It’s common knowledge (if you’re into this stuff) that function selectors are always represented at the start of calldata, e.g.
0x12345678000000000000000000000000000000000000000000000000000000000
We changed the selector to a single byte:
0xC0000000000000000000000000000000000000000000000000000000000000000
We can further confuse reverse engineers by putting the new “selector” on the other end:
0x00000000000000000000000000000000000000000000000000000000000000C0
Why is this significant?
If you’ve read my article on calldata titled Reversing The EVM: Raw Calldata, you are aware that the word is read as a variable similar to uint. Once converted to uint, it represents the value 192. This is a significant obfuscation technique that deviates from the standard and is rarely used (I haven’t come across any contract that uses it).
CFG Spammer
Reverse engineers typically attempt to identify every possible flow path that a contract can produce
through the use of the JUMPI
and JUMP
opcodes. These opcodes allow the program to jump to a
destination indicated by the JUMPDEST
opcode.
There are important differences between the JUMPI
and JUMP
opcodes in Ethereum.
JUMPI
is a conditional jump opcode, which means that it checks a prior condition, and if that
condition is true, it jumps to the JUMPDEST
opcode. If the condition is false, it continues with
the current flow and ignores the JUMPI
.
JUMP
, on the other hand, always jumps to the provided JUMPDEST
opcode. If the input to JUMP
is dynamic, the destination can be anything. If the input is hardcoded, the destination is obvious
and doesn’t create multiple potential flows.
You’re probably thinking “this is so trivial…I can gather all the existing JUMPDEST
opcodes to
discover all the potential flows”.
Not so fast normie reverse engineer, that’s not the chadest of solutions. If someone purposely is creating arbitrary paths to bamboozle your shitty decoder you bet they would of thought of this.
Enter plotting JUMPDEST
s in specific locations.
These obfuscators will be thinking of putting JUMPDEST
s, JUMPI
s and JUMP
s in places that go
back and forth to to normal flows as well as scattering JUMPDEST
s everywhere to make the jumps
boom your CFG generator, maybe through in a couple of infinite loops that jump to other infinite
loops just to brighten your day a bit :)
All in all, these are rare cases. Only exploiters and bot operators will be using these techniques in order for you not to yoink their strategies. So if you find someone using these techniques you’ve most likely have struck gold, ser.
Function Body Logic
The most difficult thing to determine without a sophisticated system is dynamic inputs. For example, using a 1-byte MOD operation to get another 1-byte value that serves as the SHR value to calculate the PC value in the jump table we created earlier.
This is more a thinking exercise to get you in the mindset of obfuscating further from the techniques discussed here. Try to create ways that generate potential pathways that is hard to traverse backwards from without a reference - hint: usually something to do with math and bitwise operations ;)
Address Scattering
To deter reverse engineers from analyzing your bytecode and to prevent frontrunners from cloning your contract and replacing your addresses with their own, you can use an address obfuscation technique.
I thought of this while writing the article
There are 2 ways developers tend to implement an address into their contract(s):
- Importing via calldata.
0x123456780000000000000000082828f6aFf831e0D8b366D7b33caf12B39232772
// ^ fn selector ^ address
This approach can be picked up from a frontrunner with basic heuristics to clone the tx, taking the opportunity for themselves…and reading it is quite simple.
- Hardcoding in the bytecode.
PUSH20 0x82828f6aFf831e0D8b366D7b33caf12B39232772
This approach however is slightly more advanced. An engineer can read it quite easily, however a frontrunner needs to disassemble and replace the address with one of their own. For a more deep dive on this, read Memware: Generalised Frontrunners.
Now we know the most common strategies, we are able to formalise an new off-meta technique, providing a portion of an address via calldata and hardcoding another portion.
Let me explain…
We want to have the following address be the destination to send our funds to without being easily replaced by frontrunners via calldata or bytecode.
// The address we want to send funds to.
PUSH20 0x82828f6aFf831e0D8b366D7b33caf12B39232772
We break this up into 4-bytes and 16-bytes respectively.
82828f6a
is passed in via calldata.Ff831e0D8b366D7b33caf12B39232772
is hardcoded in.
But why are we grabbing 4-bytes from the address?
Most decoders will scan for function selectors at the start, therefore we can create the illusion that we’re calling this “function selector”. Using our custom jump table and calldata ordering technique we can shift everything around to completely ruin a reverse engineers day :D
// Originally would be.
0x12345678000000000000000082828f6aFf831e0D8b366D7b33caf12B39232772
// ^ fn selector ^ address
// Our new obfuscated calldata.
0x82828f6a000000000000000000000000000000000000000000000000000000C0
// ^ fake fn selector (start of addr) jumptable shr value ^
Then at the bytecode level we would do something like this:
// Add the last 16-bytes of the address we want to use.
// 0x00000000000000000000000000000000Ff831e0D8b366D7b33caf12B39232772
PUSH16 0xFf831e0D8b366D7b33caf12B39232772
// Grab the first word of the calldata, containing the remaining 4-bytes of the address.
// 0x82828f6a000000000000000000000000000000000000000000000000000000C0
PUSH1 0x00
CALLDATALOAD
// Create our mask with the 4-bytes.
//
// Before:
// 0x82828f6a000000000000000000000000000000000000000000000000000000C0
// ^ is shifted right 12-bytes
//
// After:
// 0x00000000000000000000000082828f6a00000000000000000000000000000000
PUSH1 0x60
SHR
// Since the address has empty bits, we use OR to use the 4-byte bits to replace it.
//
// Before:
// 0x00000000000000000000000082828f6a00000000000000000000000000000000
// 0x00000000000000000000000000000000Ff831e0D8b366D7b33caf12B39232772
//
// After:
// 0x00000000000000000000000082828f6aFf831e0D8b366D7b33caf12B39232772
OR
Now we have an effective strategy to prevent frontrunners from both replacing our calldata with their own address and cloning our contract and replacing the address to steal our tx.
Unless they have an extremely sophisticated system, I’m guessing they wont have the heuristics to deal with this, yet - especially since it’s such a niche technique.
Anti Frontrunner Replication
A simple tactic to prevent frontrunners from taking your profits is to make sure that the address the funds are being sent to is a contract by checking if the destination address doesn’t have 0 code. If it has 0 code then we revert the transaction. This forces the frontrunner to be sophisticated enough to create a contract to send the funds to that has a withdraw or self destruct function!
laughs at loser frontrunner
Final
All-in-all obfuscation techniques isn’t a silver bullet that stops reverse engineers from figuring out the control flow, however it will more than likely hinder their progress (depending on how advanced they are) by requiring them to add more sophistication to their programs and forcing them to sacrifice more time to read and understand what’s happening.
Whether you use the techniques or not, I hope you learned something new and have gained inspiration to create your own techniques and/or create systems that are capable of reverse engineering those that implement bleeding-edge obfuscation techniques!
I never imagined I would be talking about smart contract obfuscation techniques. I thought these were only used in malware to avoid detection. What a crazy world crypto is.
If you enjoy my content, please share with your frens and free to support me
0x82828f6aFf831e0D8b366D7b33caf12B39232772
:)
Share this Article