Post

Ez ⛳ v3

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

https://caddy.chal-kalmarc.tf

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.

alt text

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

alt text

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

alt text

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 prints HELLO WORLD.
  • /fetch/* => Might be useful, extracts the first part of the path and uses it in the httpInclude 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.

alt text

and the following logs.

alt text

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.

alt text

We got the flag.

1
kalmar{4n0th3r_K4lmarCTF_An0Th3R_C4ddy_Ch4ll}
This post is licensed under CC BY 4.0 by the author.