Yeah it’s over for NextJS... 13 NEW vulnerabilities
BBetter Stack
Computing/SoftwareBusiness NewsInternet Technology
Transcript
00:00:00It happened again. This is like my third video on server component CVEs this year and I don't
00:00:05even think I covered all of them. This time it's 13 CVEs, yes 13 of them, across React
00:00:11and Next.js, 6 of which are high severity and include denial of service, middleware
00:00:15bypasses, cross-site scripting and more. Maybe server components were a mistake.
00:00:20So here's the Next.js security release, you know just fixing a few casual issues in here
00:00:28that they've had this month and down at the bottom obviously the resolution is to upgrade
00:00:32all of your Next.js versions and these are the impacted versions. It's worth noting
00:00:36TanStack is not impacted by this which I might be biased but that's another reason to use
00:00:41TanStack for me. Now I won't go through all of these as we'd probably be here for a while
00:00:44and I also haven't found working exploits for all of these but I do want to show you one
00:00:48from each category and we'll start out with a middleware and proxy bypass and the one I've
00:00:52managed to recreate is this pages router one. So we have a middleware proxy bypass in the
00:00:56pages router if you're using i18n and you can see this is a CVE with a 7.5 out of 10
00:01:02severity. This is an example of a vulnerable application so in the Next.js config I've turned
00:01:06on i18n and also set up two locales so English and French then I also have a middleware file
00:01:12which has actually been renamed to proxy in later versions of Next.js to try and avoid
00:01:16the confusion that I'm about to show you but essentially what middleware should allow us
00:01:19to do is modify an incoming request whether that's by redirecting it, rewriting it or adding
00:01:24in some headers or anything like that. In my case I'm using it so we try and visit the
00:01:28/secret page it checks if they have a session cookie so they're logged in and if they're
00:01:32not it should redirect them to the login page so hopefully only all users should be seeing
00:01:37my secret page. At the bottom we also have a matcher so that any middleware that we're
00:01:41applying to that secret page is also matching the locale variants of them because technically
00:01:45now since we have two locales we have three versions of this URL. In the secret page itself
00:01:50I then also have some server-side props so these should be fetched off the server at render time
00:01:54and again since we set up that middleware theoretically only a logged in user should be able to see
00:01:58what these values are which I then later use on the page itself so an email, a flag and
00:02:03also a headline. Again only an authored user should be able to view these. Let's put that
00:02:07to the test then and the first thing I'll try and do is access that secret page and you can
00:02:11see I'm redirected to the login since I'm not logged in which means our middleware is working
00:02:16but what have we turned into master hackers? Well we could do that by first inspecting element
00:02:20absolutely crazy hacker stuff and then in the next data script down here we need to look
00:02:24for our build ID so in my case that's this one here and we can go ahead and copy that
00:02:28then we need to type in a URL which is underscore next slash data slash the build ID that we
00:02:32just copied and then the page we're trying to access dot JSON. Once you've done that you
00:02:37can see we get back those props that should have been behind that middleware and protected
00:02:40which in my case was a flag email headline also something telling you to subscribe for
00:02:44more developer news tutorials tips and tricks. So go ahead and do that I hope I've impressed
00:02:48you with my crazy hacker skills there but why does this actually happen? Well this one is
00:02:52as easy to explain as it was to do since we had our secret page and some server side props
00:02:56in next year server side props are served from a URL that looks like this but our middleware
00:03:00should have been protecting this route. The trouble is since we were using i18n we also
00:03:05had two other URLs which was the English and French variant you can see that server side
00:03:09props also get the English and French variants as well and next year's just had some faulty
00:03:13code which meant if we did have i18n enabled it wasn't protecting the base case so this
00:03:18wasn't included in the matcher but the other two were so English and French versions were
00:03:22protected but not the base case of slash secret. We can see that really quickly if I just change
00:03:26this URL to the English version I do get redirected to the login page. An incredibly simple vulnerability
00:03:31then but I'll be honest with you these middleware bypasses often sound a lot worse than they
00:03:35are they're not great but you shouldn't really be protecting much with just middleware anyways
00:03:40and next.js don't even recommend that you do this. If you did have some sensitive data
00:03:44in those server side props and you had no sort of server auth logic well I feel like part
00:03:48of the issue is on you as well so let's move on to a more damaging one which is denial of
00:03:53service there were three of these but there was only one I could reliably recreate and
00:03:56that was this one here which is denial of service with server components and this impacts next.js
00:04:01as well as anything using the react server DOM package which is pretty much just next.js
00:04:05and the other frameworks that have copied it like vinxt and a few of the other forks. Tanstack
00:04:10start doesn't use this so it's not vulnerable to it. You can see this also has a severity
00:04:14of 7.5 out of 10. With this one all you need is a very simple next.js application and in
00:04:18it you need to be using a server action but again it can be a very simple one. This is
00:04:22that site up and running and you can see when I refresh the page there is pretty much no
00:04:25loading it is nearly instant and to put some actual numbers behind that if I send this request
00:04:29you can see it resolves in 0.02 seconds but if I now go ahead and run my exploit and then
00:04:34send that request again this time it took six seconds and that was from me running the exploit
00:04:39once so imagine what would happen if I chained it. Now to understand this exploit we need
00:04:42to know a little bit about the react flight protocol which is the format that react uses
00:04:46to serialize component trees and data between the server and the client. You've probably
00:04:50actually seen this before so on this page we had a form that had a server action. If I go
00:04:54over to the network tab here and click send you can see that the payload actually gets
00:04:58sent as data that looks like this which kind of looks like gibberish and the same for the
00:05:02response here. If we go ahead and copy this payload I can explain what happens when it's
00:05:05sent to the server. The first step is deserialization and it will start on chunk 0 where we have
00:05:10this $k1. This $k1 is actually just a pointer saying there's going to be some form data here
00:05:16that starts with one underscore so it will take all of the other keys that we sent over
00:05:20as part of that payload it will pass over all of them and look for a string that starts with
00:05:24one underscore and it will know this will be the key and that this is the value. So once
00:05:28it's done that it can say we have name, email, message and it will simply turn this data into
00:05:32this object that we have down here. Nice and simple. The trouble with this approach though
00:05:36is what happens when we scale it. Say I add in another pointer this time looking for $k2
00:05:41this is going to look for all of the keys that start with two underscore. The trouble
00:05:44is now when we're on $k1 it's going to pass over all six of these looking for the ones
00:05:48that start with one underscore and when it goes to $k2 it's going to do the exact same
00:05:52thing but looking for the ones that start with two underscore. So we're now passing over 12
00:05:56keys in total. Now that's not too bad but let's take this to the extreme. If we add in 199,999
00:06:03random keys to the payload that we're sending over and then we change our array on zero here
00:06:07to go $k1 $k2 all the way up to $k1000 that means it's going to have to look for one underscore
00:06:12two underscore three underscore all the way up to a thousand underscore all over our 200,000
00:06:17keys that we have in our payload and that means in total it's going to do 200 million string
00:06:21comparisons. As you can imagine that is going to block the thread for a fair few seconds.
00:06:25This is the commit that I believe fixed the problem that we were having. You can see there
00:06:28is a lot going on in this commit and to be honest it's a little bit complex but try and
00:06:33explain this the best I can. Essentially now they use a cursor based system for the keys
00:06:36so they load in all of these 200,000 keys that we sent in our payload into a list and then
00:06:41we start on zero here where it's looking for the $k1 reference and it starts going down
00:06:45that list with a cursor that cannot go backwards. So it goes down here to $j1 it sees that doesn't
00:06:50match the one underscore that we need so it goes to $j2 that doesn't match one underscore
00:06:54either so it continues all the way down this key list down to $j199,999. Once it's reached
00:07:01here it realizes there is no match for $k1 so it moves on to $k2. Now $k2 starts looking
00:07:06for two underscore but the trouble is since this is a cursor based system and this cursor
00:07:09cannot go backwards it immediately runs out at the end of the list so that is also going
00:07:14to be undefined and that continues all the way up to $k1000. So now this time we've only
00:07:18gone over 200,000 keys. Essentially this fix has taken the number of operations down from
00:07:23$k*n where $k is the number of $k references we have and $n is the number of keys all the
00:07:27way down to $n+k. So in our case we went from 200,000,000 operations down to 201,000 since
00:07:33it does still need to go over all of the keys and also those $k references. I think this
00:07:37tweet from Prime really sums up the situation we're in. Making your own protocol with serialization
00:07:41is incredibly hard so it's not surprising that we're seeing this many issues. In my opinion
00:07:46they need to get Claude Mythos to do a once over on the React and Next.js codebase. Next
00:07:50up we have the highest severity CV of the lot which is server-side request forgery in
00:07:54Next.js applications. You can see this one is ranked 8.6 out of 10 but it is also worth
00:07:59noting that this one didn't impact for cell-hosted deployment so only self-hosted or other providers.
00:08:04This exploit is also super simple to take advantage of. First we need to launch our Next.js server
00:08:09and again this can be a stock Next.js application. You don't have to make any modifications.
00:08:14Next we're also going to want an internal server. So say this server could only be accessed by
00:08:18the Next.js server and not by the outside world. Say this was on our cloud deployment.
00:08:23Then what we're going to do is simply send a very simple curl request where we send a
00:08:26curl request to our Next.js application. So that was on port 3002 and we say we want our
00:08:31request target to be the server that we want access to on that localhost URL. If I now hit
00:08:36enter on this you can see what is returned. It's actually the HTML page for a Python server
00:08:40where it just has that basic directory listing and if I go back to the Python server itself
00:08:45you can see that it had an incoming request that actually came from the Next.js application.
00:08:49To better picture what we just did say in our dotted line here is our deployment of Next.js
00:08:53so we have our Next.js server and then also some internal service whether that's Redis
00:08:57a database or any other service and this one here you don't want to have access to the public
00:09:02so no one can send a curl request to this it will simply fail it is firewalled and locked
00:09:06off and can only be accessed by the Next.js server. What we've done is we've simply sent
00:09:10a curl request to the Next.js server and we've said hey can you send a request on our behalf
00:09:15to the internal service and we've got that information back so we've bypassed that firewall
00:09:19by going through Next.js which does have access to the internal service. The root cause of
00:09:23this one is also pretty simple basically in our curl request we send an upgrade websocket
00:09:28header and when we send these headers what happens in Next.js is we get to this piece
00:09:32of code and this does resolve routes on our URL but the past URL that we get out here is
00:09:37actually the request target that we send in our curl request so this is going to be the
00:09:40target that we're trying to reach on that internal server and not actually the Next.js application.
00:09:45What happens with this past URL is it goes through one check down here to say does the
00:09:49past URL have a protocol but in this case it's yes since we're using HTTP and that is a protocol
00:09:55so it goes ahead and proxies that request for us. The fix for this simply adds in two new
00:09:58guards on our resolve routes function that we have before we're now getting a finished
00:10:02boolean as well as a status code. What this resolve routes function actually does is it
00:10:06takes our URL and processes whether it's a legitimate proxy request based on our Next.js
00:10:11rewrites middleware and things like that if it's not it means finished is going to be set
00:10:15to false so down here we have a check if finished is true we can move on to the next step if
00:10:20it's not it's set to false so this won't run which means we won't run our proxy request
00:10:24and in the case of our curl request that we had previously that is exactly what's going
00:10:27to happen finished will be set to false. Now if somehow finished was set to true our next
00:10:32check is the status code when resolve routes run we actually get a status code back if the
00:10:36request is a HTTP one so it's going to be 200 404 anything like that so if there is a status
00:10:41code that means it's not a valid web socket proxy request so it's simply going to ignore
00:10:45this line and not run it. I've really tried to dive deep into these issues in this video
00:10:49so let me know if you're still watching by commenting something random I don't know like
00:10:53bar or something like that and also subscribe if you appreciate the content. We've got two
00:10:58more categories to get through but these ones should be a bit quicker and I'll start out
00:11:01with cache poisoning where I managed to recreate this moderate issue here and you see this one
00:11:04is cache poisoning in react server component and it's ranked 5.4 out of 10. To recreate
00:11:09this one I have a Next.js application but then I also have a fake CDN to act as if this was
00:11:14in a real deployed environment. This means if I visit my site for the first time on its
00:11:18CDN URL and then click browse products and go back and click it again the first time it
00:11:23should be a cache miss and then next time a cache hit. We can see that happen in the logs
00:11:27here we first had a miss slash products with a query string and then next times it was a
00:11:31hit. What I've done next though is I've cleared the cache to simulate maybe the cache expired
00:11:35or something else and now I can send this curl request. With that back on my application
00:11:39if I click browse products now you can see we get a load of gibberish back so we've successfully
00:11:44done a cache poisoning attack. What I believe is happening here is when we send our curl
00:11:47request with a header of react server components being one this URL is going to return its server
00:11:52component data instead of HTML. Then when this needs to be cached next is going to go through
00:11:58a function which checks if it's server component data or not. If it is server component data
00:12:02it will store it in the cache as so and if it's not it will store it as HTML. This means
00:12:07theoretically when the user tries to go and get the HTML version of the page so by just
00:12:11clicking the button it should never return server component data. The trouble is in our
00:12:16case since we had this query string at the end of our server component curl request it
00:12:20didn't actually meet the requirements for checking whether it's a server component or not since
00:12:24that simply checks whether it ends in .rse but our version ends in the query string so
00:12:29now it thinks it's HTML it stores it in the cache as HTML so the next time a user clicks
00:12:33on the button they actually got the server component data back because it believed that
00:12:37was HTML. The fix for this one was incredibly simple when they're doing the check for whether
00:12:41it ends in .rse they simply now just ignore the query strings. Moving on now to our final
00:12:46category of CVEs we have some cross site scripting ones this is the one I managed to get a recreation
00:12:50of you can see it's a 6.1 out of 10 and it has cross site scripting in before interactive
00:12:55scripts with untrusted input. Basically all that means is in Next.js if I have a script
00:12:59tag that has the strategy of before interactive if I then have another attribute on this that
00:13:03needs some form of untrusted content for example this one is coming from the search params
00:13:08I can do some cross site scripting I can do that by making the user click on a link like
00:13:12this for example where I've embedded a load of content into that search param if someone
00:13:16was to click on that link this is what they would see so they might think they have to
00:13:19log in to the site again and when they click sign in you can see here that was an entirely
00:13:22fake login form that was injected via that search param it essentially allows the attacker
00:13:26to do some JavaScript execution on the victim's machine so I think a more realistic example
00:13:31would be stealing the session cookies of Chrome to log into everything that you had access
00:13:34to this one is a pretty simple example of incorrect escaping and we can see that a bit easier with
00:13:39this more simplified version but all I'm doing in that search param is I'm first closing a
00:13:43script tag then opening up a new script tag with whatever I want to run in it when we hit
00:13:47enter on that you can see I get that alert that says pwned as I showed you with my app
00:13:51the way this one works is we need a next.js script tag that has a strategy of before interactive
00:13:55and you also want some attribute on that script tag that takes in its data from some untrusted
00:13:59source in my case that is coming from the query parameters what this does in next.js is this
00:14:04script tag gets turned into something like this where we have a dangerously set in a HTML
00:14:08I mean it's in the name that it's going to be dangerous and we also have a JSON dot stringify
00:14:12the important thing to know about JSON stringify is that it doesn't escape HTML characters
00:14:17like closing brackets so what's actually happening here is we're taking all of the script tags
00:14:21that we set up in next.js it's looking for the source which is going to be set here and
00:14:24then the rest of the props is going to contain data tracking ID as well as the value that
00:14:29we set in our query parameter so that gets put into this JSON stringify well this actually
00:14:33gets rendered as on the page is something like this so we have our data tracking ID which
00:14:37was the rest of the props but then we also have the string which we inserted via the query
00:14:41params so if we expanded what this actually looks like on the page it looks something like
00:14:45this we have our script tag we have a data tracking ID but then after this we actually
00:14:49have a closing script tag so it ends the script tag that next.js was trying to do and after
00:14:53this we can run whatever script we want on that page then we also have this extra bit
00:14:58at the end here because if we didn't have this analytics would actually be rendered on the
00:15:01page as text as the HTML would see this as text so this essentially just swallows that
00:15:05up so there's no clear signs that something bad is happening on that page and there we
00:15:09go that is 13 CVs for next.js and react this week and how a few of them worked and honestly
00:15:14I don't know what to think about this I hate this and that's coming from a place where two
00:15:18or so years ago every project I did was in next.js and I thought it was the best in the
00:15:22future but it's just seemed to be hurdle after hurdle it feels like they've rushed into a
00:15:26few things and then had to fix them later personally now I am just fully tan stack peeled and also
00:15:31astro when I need a content based site they just seem much simpler to me and to be honest
00:15:35I've also been really liking what cloudflare has been doing lately so I've been slowly migrating
00:15:39my projects over there but I do still have about 20 of them on vacel and I need to go
00:15:43and update what do you think will server components ever be useful or have we tried them and failed
00:15:48let me know in the comments down below why they're subscribe and as always see you in
00:15:51the next one.