TetCTF comes back with their great crypto challenges. I played with @blackb6a and we ended up securing the ðŸĨ‰ third place. In this blog post, I will walk through adapt, a challenge which required us to make fake proofs in an IAVL tree implementation. There are only three solves during the contest period.

Challenge Summary

We are started with an empty immutable AVL (IAVL) tree every connection. We can send up to 2024 messages and it will store the key-value pair (message, "") to the tree. In this stage, we have to set the message to $m_0$ at least once. After that, we are asked to provide a proof that $m_0$ is not on the tree.

Solution

Part I: Background

cosmos/iavl@v0.19.2 is known to be vulnerable12 for the Binance Bridge hack in October 2022, which has been fixed in v0.19.3. However, there is a issue in â‰Īv0.19.7 that allow us to create fake absence proofs.

Part II: Attack overview

In short, the vulnerability appears because of the below reasons:

  1. similarity between serialization of parent nodes and leaf nodes, and
  2. missing checks to heights for parent nodes.

We then can create malicious leaf nodes that appeared to be parent nodes, and use that node to generate range proofs to show that an key is absent from the tree.

ðŸĪ– Where are the details? They are in the next part! I intend to skip the technical details here to let the readers to have a idea what’s going on.

We now present an example where we assume that there is a single node (namely ðŸšĐ) in the IAVL tree. The objective is to supply a proof that ðŸšĐ is absent.

digraph { graph [bgcolor="transparent"] node [color="#ffe4e1", fontcolor="#ffe4e1", fillcolor="#33333c", style="filled"] edge [color="#ffe4e1", fontcolor="#ffe4e1"] node[shape="box", style="rounded, filled", color="#ffe4e1", fontcolor="#ffe4e1", fillcolor="#33333c"] flag[label=<ðŸšĐ<BR /><font point-size="11">Height: 0<BR />Size: 1<BR />Version: 1</font>>] }

What we do is to insert a single malicious node to the left of the IAVL tree, here 😈 needs to lexicographically smaller than ðŸšĐ so that the malicious node is placed to the left of ðŸšĐ:

digraph { graph [bgcolor="transparent"] node [color="#ffe4e1", fontcolor="#ffe4e1", fillcolor="#33333c", style="filled"] edge [color="#ffe4e1", fontcolor="#ffe4e1"] node[shape="box", style="rounded, filled", color="#ffe4e1", fontcolor="#ffe4e1", fillcolor="#33333c"] root[label=<📂<BR /><font point-size="11">Height: 1<BR />Size: 2<BR />Version: 1</font>>] malicious[label=<😈<BR /><font point-size="11">Height: 0<BR />Size: 1<BR />Version: 1</font>>] flag[label=<ðŸšĐ<BR /><font point-size="11">Height: 0<BR />Size: 1<BR />Version: 1</font>>] root->malicious root->flag }

This malicious node is disguised as a parent node with two child nodes, ðŸ‘ŧ and 📁, when supplied as a proof. We require ðŸ‘ŧ to be lexicographically greater than ðŸšĐ. If we generate a range proof containing ðŸ‘ŧ as the only node, we can tell that ðŸ‘ŧ is the leftmost element in the IAVL tree. With that said, there are no keys in $(-\infty, ðŸ‘ŧ)$. Given that $ðŸšĐ \in (-\infty, ðŸ‘ŧ)$, the verifier is convinced that ðŸšĐ is absent.

digraph { graph [bgcolor="transparent"] node [color="#ffe4e1", fontcolor="#ffe4e1", fillcolor="#33333c", style="filled"] edge [color="#ffe4e1", fontcolor="#ffe4e1"] node[shape="box", style="rounded, filled", color="#ffe4e1", fontcolor="#ffe4e1", fillcolor="#33333c"] root[label=<📂<BR /><font point-size="11">Height: 1<BR />Size: 2<BR />Version: 1</font>>, fillcolor="#66666c"] malicious[label=<😈<BR /><font point-size="11">Height: 0<BR />Size: 1<BR />Version: 1</font>>, fillcolor="#66666c"] malicious_left_child[label=<ðŸ‘ŧ<BR /><font point-size="11">Height: 0<BR />Size: 1<BR />Version: 1</font>>, fillcolor="#66666c", style="dashed,rounded,filled"] malicious_right_subtree[label=<📁<BR /><font point-size="11">Height: ?<BR />Size: ?<BR />Version: ?</font>>, style="dashed,rounded,filled"] flag[label=<ðŸšĐ<BR /><font point-size="11">Height: 0<BR />Size: 1<BR />Version: 1</font>>] root->malicious root->flag malicious->malicious_left_child malicious->malicious_right_subtree }

Part III: Implementation details

Computing a node hash of IAVL trees

There will be a hash for each node in IAVL trees. The parent and leaf nodes use different material to compute the hashes as defined in writeHashBytes in node.go:

  • Parent node: SHA256(height || size || version || leftHash || rightHash)
  • Leaf node: SHA256(height || size || version || key || valueHash)

Here height, size and version are integers and the remaining are bytes. They are uniquely encoded so that no two parent nodes (resp. leaf nodes) could end up having the same node hash.

For the below node with key being hello and value being the empty string, we have the digest being addee74d....

digraph { graph [bgcolor="transparent"] node [color="#ffe4e1", fontcolor="#ffe4e1", fillcolor="#33333c", style="filled"] edge [color="#ffe4e1", fontcolor="#ffe4e1"] node[shape="box", style="rounded, filled", color="#ffe4e1", fontcolor="#ffe4e1", fillcolor="#33333c"] dummy[label=<hello<BR /><font point-size="11">Height: 0<BR />Size: 1<BR />Version: 1</font>>] }
hashlib.sha256(bytes.fromhex(
    '00' + # height=0
    '02' + # size=1
    '02' + # version=1
    '05 68656c6c6f' + # key=hello
    '20 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' # SHA256(value)
)).hexdigest()
# addee74d6c7b968206e8072eb5147e48dc14d1ab78dceb3a352b1e6fa144fc4f

Now proceed to a tree. From above, we know that the digests of the left and right child nodes are respectively addee74d... and bdfd061f....

digraph { graph [bgcolor="transparent"] node [color="#ffe4e1", fontcolor="#ffe4e1", fillcolor="#33333c", style="filled"] edge [color="#ffe4e1", fontcolor="#ffe4e1"] node[shape="box", style="rounded, filled", color="#ffe4e1", fontcolor="#ffe4e1", fillcolor="#33333c"] root[label=<📂<BR /><font point-size="11">Height: 1<BR />Size: 2<BR />Version: 1</font>>] dummy1[label=<hello<BR /><font point-size="11">Height: 0<BR />Size: 1<BR />Version: 1</font>>] dummy2[label=<world<BR /><font point-size="11">Height: 0<BR />Size: 1<BR />Version: 1</font>>] root -> dummy1 root -> dummy2 }

With that, we can compute the digest of 📂:

hashlib.sha256(bytes.fromhex(
    '02' + # height=1
    '04' + # size=2
    '02' + # version=1
    '20 addee74d6c7b968206e8072eb5147e48dc14d1ab78dceb3a352b1e6fa144fc4f' +
    '20 bdfd061f8989347217253be5a7ef06117d2be62fc6dde0d9752a5debc902f011'
)).hexdigest()
# 166c299760ef7b1e27116893a2623e38a8b3c763b831568eac46138527553bc5

It would be theoretically infeasible to find two trees that yield the same node hash for the root node. Therefore we can use root node hashes to determine the integrity of IAVL trees.

😕 How about the add and remove operations? We are only interested in the final shapes of the IAVL trees, the update operations will be out of scope.

Range proofs

The leaf nodes of IAVL trees are sorted. If node A is lexicographically smaller than node B, we will see node A is on the left of node B. With this property, we are able to reveal a subtree of consecutive leaf nodes to show the absence of a given node.

We can imagine that we have a sorted array. If $a < x < b$, by showing a contiguous subarray [a, b] we can prove that x does not exist in the array. Now you have to convince the verifier that [a, b] is a subarray, and it is contiguous.

By having a IAVL tree, the verifier only have to maintain the digest of the root nodes to verify proofs. For more details, please read the documentation on proofs in cosmos/iavl.

Part IV: Shaping the attack

As mentioned before, the entire attack involves three leaf nodes: ðŸšĐ, 😈 and ðŸ‘ŧ. We assume that ðŸšĐ is already in the tree and will only insert 😈 to it. The values are always empty in the challenge and we can’t alter them.

We will first find the key of ðŸ‘ŧ. It has to be lexicographically greater than ðŸšĐ, and its node hash is lexicographically smaller than ðŸšĐ at the same time.

In our case, the keys for ðŸšĐ and ðŸ‘ŧ are Please give me flag and QQR respectively. The node hash for ðŸ‘ŧ is 22220311... hex-encoded.

digraph { graph [bgcolor="transparent"] node [color="#ffe4e1", fontcolor="#ffe4e1", fillcolor="#33333c", style="filled"] edge [color="#ffe4e1", fontcolor="#ffe4e1"] node[shape=&#34;box&#34;, style=&#34;rounded, filled&#34;, color=&#34;#ffe4e1&#34;, fontcolor=&#34;#ffe4e1&#34;, fillcolor=&#34;#33333c&#34;] malicious_left_child[label=&lt;ðŸ‘ŧ&lt;BR /&gt;&lt;font point-size=&#34;11&#34;&gt;Height: 0&lt;BR /&gt;Size: 1&lt;BR /&gt;Version: 1&lt;BR /&gt;Hash: 22220311...&lt;/font&gt;&gt;] flag[label=&lt;ðŸšĐ&lt;BR /&gt;&lt;font point-size=&#34;11&#34;&gt;Height: 0&lt;BR /&gt;Size: 1&lt;BR /&gt;Version: 1&lt;BR /&gt;Hash: a6fb1c60...&lt;/font&gt;&gt;] }

Now we can simply use the hex-encoded 22220311... as the key for 😈, and insert it to the tree. Now there are two nodes in our tree.

digraph { graph [bgcolor="transparent"] node [color="#ffe4e1", fontcolor="#ffe4e1", fillcolor="#33333c", style="filled"] edge [color="#ffe4e1", fontcolor="#ffe4e1"] node[shape=&#34;box&#34;, style=&#34;rounded, filled&#34;, color=&#34;#ffe4e1&#34;, fontcolor=&#34;#ffe4e1&#34;, fillcolor=&#34;#33333c&#34;] root[label=&lt;📂&lt;BR /&gt;&lt;font point-size=&#34;11&#34;&gt;Height: 1&lt;BR /&gt;Size: 2&lt;BR /&gt;Version: 1&lt;BR /&gt;Hash: 660f9501...&lt;/font&gt;&gt;] malicious[label=&lt;😈&lt;BR /&gt;&lt;font point-size=&#34;11&#34;&gt;Height: 0&lt;BR /&gt;Size: 1&lt;BR /&gt;Version: 1&lt;BR /&gt;Hash: 01f31a15...&lt;/font&gt;&gt;] flag[label=&lt;ðŸšĐ&lt;BR /&gt;&lt;font point-size=&#34;11&#34;&gt;Height: 0&lt;BR /&gt;Size: 1&lt;BR /&gt;Version: 1&lt;BR /&gt;Hash: a6fb1c60...&lt;/font&gt;&gt;] root-&gt;malicious root-&gt;flag }

In the proof, we can disguise 😈 as a parent node with ðŸ‘ŧ being its left child. We then reveal the below subtree to the verifier:

digraph { graph [bgcolor="transparent"] node [color="#ffe4e1", fontcolor="#ffe4e1", fillcolor="#33333c", style="filled"] edge [color="#ffe4e1", fontcolor="#ffe4e1"] node[shape=&#34;box&#34;, style=&#34;rounded, filled&#34;, color=&#34;#ffe4e1&#34;, fontcolor=&#34;#ffe4e1&#34;, fillcolor=&#34;#33333c&#34;] root[label=&lt;📂&lt;BR /&gt;&lt;font point-size=&#34;11&#34;&gt;Height: 1&lt;BR /&gt;Size: 2&lt;BR /&gt;Version: 1&lt;BR /&gt;Hash: 660f9501...&lt;/font&gt;&gt;, fillcolor=&#34;#66666c&#34;] malicious[label=&lt;😈&lt;BR /&gt;&lt;font point-size=&#34;11&#34;&gt;Height: 0&lt;BR /&gt;Size: 1&lt;BR /&gt;Version: 1&lt;BR /&gt;Hash: 01f31a15...&lt;/font&gt;&gt;, fillcolor=&#34;#66666c&#34;] malicious_left_child[label=&lt;ðŸ‘ŧ&lt;BR /&gt;&lt;font point-size=&#34;11&#34;&gt;Height: 0&lt;BR /&gt;Size: 1&lt;BR /&gt;Version: 1&lt;BR /&gt;Hash: 22220311...&lt;/font&gt;&gt;, fillcolor=&#34;#66666c&#34;, style=&#34;dashed,rounded,filled&#34;] malicious_right_subtree[label=&lt;&lt;font point-size=&#34;30&#34;&gt;📁&lt;/font&gt;&lt;BR /&gt;&lt;BR /&gt;&lt;font point-size=&#34;11&#34;&gt;Hash: e3b0c442...&lt;/font&gt;&gt;, style=plaintext, color=transparent] flag[label=&lt;&lt;font point-size=&#34;30&#34;&gt;📁&lt;/font&gt;&lt;BR /&gt;&lt;BR /&gt;&lt;font point-size=&#34;11&#34;&gt;Hash: a6fb1c60...&lt;/font&gt;&gt;, style=plaintext, color=transparent] root-&gt;malicious root-&gt;flag malicious-&gt;malicious_left_child malicious-&gt;malicious_right_subtree }

And finally the verifier is convinced that ðŸšĐ is not in the tree.

Below is the equivalent proof that I sent to the verifier, and eventually I got the flag – TetCTF{l34f_c0ns3cut1v3n3ss_n0t_3nf0rc3d}:

{
    "left_path": [
        {
            "height": 1,
            "size": 2,
            "version": 1,
            "right": "pvscYKxyFDUPxP0R6qEYaAMYdJC4G2VXKhgJAuAh4So="
        },
        {
            "height": 0,
            "size": 1,
            "version": 1,
            "right": "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
        }
    ],
    "inner_nodes": [],
    "leaves": [
        {
            "key": "515152",
            "value": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
            "version": 1
        }
    ]
}
ðŸĪ” What’s in the node e3b0c442...? We are forced to set this because the value of leaf nodes must be empty, thus we are unable to forge a subtree. We can assume that is a “corrupted” subtree that no one can ever expand.

  1. sanebow (2022): “BNB Hack: IAVL Spoofing Explained”
    https://sanebow.me/bnb-hack-iavl-explained ↩︎

  2. Immunefi (2023): “Hack Analysis: Binance Bridge, October 2022”
    https://medium.com/immunefi/hack-analysis-binance-bridge-october-2022-2876d39247c1 ↩︎