Repository: teknogeek/ssrf-sheriff Branch: master Commit: 02cd31d60970 Files: 14 Total size: 17.6 KB Directory structure: gitextract_dh8uha71/ ├── Dockerfile ├── LICENSE.txt ├── README.md ├── config/ │ └── base.example.yaml ├── docker-compose.yml ├── generators/ │ ├── images.go │ └── init.go ├── handler/ │ └── handler.go ├── httpserver/ │ ├── handle.go │ ├── tcp_listener.go │ └── wait.go ├── main.go └── templates/ ├── csv.csv └── html.html ================================================ FILE CONTENTS ================================================ ================================================ FILE: Dockerfile ================================================ FROM golang:1.21-alpine AS build-env WORKDIR /build RUN go mod init ssrf-sheriff COPY . . RUN go get -d -v ./... RUN go build -o ssrf-sheriff . FROM alpine:3.19 WORKDIR /app COPY --from=build-env /build/ssrf-sheriff /usr/local/bin/ssrf-sheriff COPY config/base.example.yaml config/base.yaml ENTRYPOINT ["ssrf-sheriff"] ================================================ FILE: LICENSE.txt ================================================ MIT License Copyright (c) 2019 Joel Margolis Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # SSRF Sheriff This is an SSRF testing sheriff written in Go. It was originally created for the [Uber H1-4420 2019 London Live Hacking Event](https://www.hackerone.com/blog/london-called-hackers-answered-recapping-h1-4420), but it is now being open-sourced for other organizations to implement and contribute back to. ## Features - Respond to any HTTP method (`GET`, `POST`, `PUT`, `DELETE`, etc.) - Configurable secret token (see [base.example.yaml](config/base.example.yaml)) - Content-specific responses - With secret token in response body - JSON - XML - HTML - CSV - TXT - PNG - JPEG - Without token in response body - GIF - MP3 - MP4 ## Usage ```bash go get github.com/teknogeek/ssrf-sheriff cd $GOPATH/src/github.com/teknogeek/ssrf-sheriff cp config/base.example.yaml config/base.yaml # ... configure ... go run main.go ``` ### Example Requests: **Plaintext** ``` $ curl -sSD- http://127.0.0.1:8000/foobar HTTP/1.1 200 OK Content-Type: text/plain X-Secret-Token: SUP3R_S3cret_1337_K3y Date: Mon, 14 Oct 2019 16:37:36 GMT Content-Length: 21 SUP3R_S3cret_1337_K3y ``` **XML** ``` $ curl -sSD- http://127.0.0.1:8000/foobar.xml HTTP/1.1 200 OK Content-Type: application/xml X-Secret-Token: SUP3R_S3cret_1337_K3y Date: Mon, 14 Oct 2019 16:37:41 GMT Content-Length: 81 SUP3R_S3cret_1337_K3y ``` ## TODO - Dynamically generate valid responses with the secret token visible for - GIF - MP3 - MP4 - Secrets in HTTP response generated/created/signed per-request, instead of returning a single secret for all requests - TLS support ## Credit Inspired (and requested) by [Frans Rosén](https://twitter.com/fransrosen) during his [talk at BountyCon '19 Singapore](https://speakerdeck.com/fransrosen/live-hacking-like-a-mvh-a-walkthrough-on-methodology-and-strategies-to-win-big?slide=49) ----- Released under the [MIT License](LICENSE.txt). ================================================ FILE: config/base.example.yaml ================================================ http: address: ":8000" ssrf_token: "REPLACE_THIS_WITH_YOUR_SECRET_VALUE" ================================================ FILE: docker-compose.yml ================================================ version: '3' services: ssrf_sheriff: build: . ports: - "8000:8000" ================================================ FILE: generators/images.go ================================================ package generators import ( "github.com/fogleman/gg" "github.com/golang/freetype/truetype" "golang.org/x/image/font/gofont/goregular" ) // function that generates JPG and PNG images with the provided text // and save them into "/templates" directory func GenerateJPGAndPNG(ssrfToken string) { const W = 1024 const H = 768 dc := gg.NewContext(W, H) dc.SetRGB(0, 0, 0) dc.Clear() dc.SetRGB(1, 1, 1) font, err := truetype.Parse(goregular.TTF) if err != nil { panic("") } face := truetype.NewFace(font, &truetype.Options{ Size: 14, }) dc.SetFontFace(face) dc.DrawStringAnchored(ssrfToken, W/2, H/2, 0.5, 0.5) dc.SaveJPG("./templates/jpeg.jpg", 80) dc.SavePNG("./templates/png.png") } ================================================ FILE: generators/init.go ================================================ package generators // function that run all media files generators with the provided text func InitMediaGenerators(ssrfToken string) { GenerateJPGAndPNG(ssrfToken) } ================================================ FILE: handler/handler.go ================================================ package handler import ( "encoding/json" "encoding/xml" "fmt" "io/ioutil" "mime" "net/http" "path" "path/filepath" "github.com/gorilla/mux" "github.com/teknogeek/ssrf-sheriff/generators" "github.com/teknogeek/ssrf-sheriff/httpserver" "go.uber.org/config" "go.uber.org/fx" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) // SerializableResponse is a generic type which both can be safely serialized to both XML and JSON type SerializableResponse struct { SecretToken string `json:"token" xml:"token"` } // SSRFSheriffRouter is a wrapper around mux.Router to handle HTTP requests to the sheriff, with logging type SSRFSheriffRouter struct { logger *zap.Logger ssrfToken string } // NewHTTPServer provides a new HTTP server listener func NewHTTPServer( mux *mux.Router, cfg config.Provider, ) *http.Server { return &http.Server{ Addr: cfg.Get("http.address").String(), Handler: mux, } } // NewSSRFSheriffRouter returns a new SSRFSheriffRouter which is used to route and handle all HTTP requests func NewSSRFSheriffRouter( logger *zap.Logger, cfg config.Provider, ) *SSRFSheriffRouter { return &SSRFSheriffRouter{ logger: logger, ssrfToken: cfg.Get("ssrf_token").String(), } } // StartFilesGenerator starts the function which is dynamically generating JPG/PNG formats // with the secret token rendered in the media func StartFilesGenerator(cfg config.Provider) { generators.InitMediaGenerators(cfg.Get("ssrf_token").String()) } // StartServer starts the HTTP server func StartServer(server *http.Server, lc fx.Lifecycle) { h := httpserver.NewHandle(server) lc.Append(fx.Hook{ OnStart: h.Start, OnStop: h.Shutdown, }) } // PathHandler is the main handler for all inbound requests func (s *SSRFSheriffRouter) PathHandler(w http.ResponseWriter, r *http.Request) { fileExtension := filepath.Ext(r.URL.Path) contentType := mime.TypeByExtension(fileExtension) var response string switch fileExtension { case ".json": res, _ := json.Marshal(SerializableResponse{SecretToken: s.ssrfToken}) response = string(res) case ".xml": res, _ := xml.Marshal(SerializableResponse{SecretToken: s.ssrfToken}) response = string(res) case ".html": tmpl := readTemplateFile("html.html") response = fmt.Sprintf(tmpl, s.ssrfToken, s.ssrfToken) case ".csv": tmpl := readTemplateFile("csv.csv") response = fmt.Sprintf(tmpl, s.ssrfToken) case ".txt": response = fmt.Sprintf("token=%s", s.ssrfToken) case ".png": response = readTemplateFile("png.png") case ".jpg", ".jpeg": response = readTemplateFile("jpeg.jpg") // TODO: dynamically generate these formats with the secret token rendered in the media case ".gif": response = readTemplateFile("gif.gif") case ".mp3": response = readTemplateFile("mp3.mp3") case ".mp4": response = readTemplateFile("mp4.mp4") default: response = s.ssrfToken } if contentType == "" { contentType = "text/plain" } s.logger.Info("New inbound HTTP request", zap.String("IP", r.RemoteAddr), zap.String("Path", r.URL.Path), zap.String("Response Content-Type", contentType), zap.Any("Request Headers", r.Header), ) responseBytes := []byte(response) w.Header().Set("Content-Type", contentType) w.Header().Set("X-Secret-Token", s.ssrfToken) w.WriteHeader(http.StatusOK) w.Write(responseBytes) } func readTemplateFile(templateFileName string) string { data, err := ioutil.ReadFile(path.Join("templates", path.Clean(templateFileName))) if err != nil { return "" } return string(data) } // NewServerRouter returns a new mux.Router for handling any HTTP request to /.* func NewServerRouter(s *SSRFSheriffRouter) *mux.Router { router := mux.NewRouter() router.PathPrefix("/").HandlerFunc(s.PathHandler) return router } // NewConfigProvider returns a config.Provider for YAML configuration func NewConfigProvider() (config.Provider, error) { return config.NewYAMLProviderFromFiles("config/base.yaml") } // NewLogger returns a new *zap.Logger func NewLogger() (*zap.Logger, error) { zapConfig := zap.NewProductionConfig() zapConfig.Encoding = "console" zapConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder zapConfig.DisableStacktrace = true return zapConfig.Build() } ================================================ FILE: httpserver/handle.go ================================================ package httpserver import ( "context" "errors" "fmt" "net" "net/http" ) // HandleOption customizes the behavior of a Handle. type HandleOption interface { apply(*Handle) } type handleOptionFunc func(*Handle) func (f handleOptionFunc) apply(h *Handle) { f(h) } // ListenFunc is an option for Handle that allows changing how it listens for // incoming connections. func ListenFunc(f func(string, string) (net.Listener, error)) HandleOption { return handleOptionFunc(func(h *Handle) { h.listenFunc = f }) } // DefaultListenFunc builds a net.Listener with the given network and address. // This function is the default value for ListenFunc. func DefaultListenFunc(network, address string) (net.Listener, error) { ln, err := net.Listen(network, address) // keep-alive on all TCP connections. net/http's ListenAndServe and // ListenAndServeTLS do this by default but not Server.Serve(..). if tcpListener, ok := ln.(*net.TCPListener); ok { ln = tcpKeepAliveListener{tcpListener} } return ln, err } func newDialer() dialer { return new(net.Dialer) } // Changes how we build dialers. // // This is an unexported option used for testing only. func newDialerFunc(f func() dialer) HandleOption { return handleOptionFunc(func(h *Handle) { h.newDialerFunc = f }) } // Handle is a reference to an HTTP server. It provides clean startup and // shutdown for net/http HTTP servers. type Handle struct { // HTTP server provided by the user. srv *http.Server // Listener we're listening on (if any). This is nil if Start hasn't been // called yet. ln net.Listener // errCh will be filled with the error returned by http.Server.Serve. errCh chan error // Function used to create net.Listeners. Defaults to net.Listen. listenFunc func(string, string) (net.Listener, error) // Function used to build dialers. Defaults to newDialer. newDialerFunc func() dialer } // NewHandle builds a Handle to the given HTTP server. You can use the // returned Handle to start the server and access information about the // running server. // // Handle must be used for all server operations from this point onwards. // Starting or stopping the http.Server directly will lead to undefined // behavior. // // Note that Handle is not thread-safe. You must not call procedures on Handle // concurrently. func NewHandle(srv *http.Server, opts ...HandleOption) *Handle { h := &Handle{ srv: srv, listenFunc: DefaultListenFunc, newDialerFunc: newDialer, } for _, opt := range opts { opt.apply(h) } return h } // Addr returns the address on which the HTTP server is listening. This can be // used to determine the address of the server if it was started on an // OS-assigned port (":0"). // // Returns nil if the server hasn't been started yet. func (h *Handle) Addr() net.Addr { if h.ln == nil { return nil } return h.ln.Addr() } // Start starts the HTTP server for this Handle in a separate goroutine and // blocks until the server is ready to accept requests or the provided context // finishes. // // The server is started on the address defined on Server.Addr, defaulting to // an OS-assigned port (":0") if Server.Addr is empty. // // h := httpserver.NewHandle(&http.Server{Handler: myHandler}) // err := h.Start(ctx) // // Note that because the server is started in a separate goroutine, this // method is safe to use as-is inside Fx Lifecycle hooks. // // fx.Hook{ // OnStart: handle.Start, // OnStop: handle.Shutdown, // } func (h *Handle) Start(ctx context.Context) error { if h.ln != nil { return errors.New("server is already running") } // http.Server defaults to ":http" if Addr is empty. For our purposes, // ":0" is more desirable since we almost never listen on port 80. addr := h.srv.Addr if addr == "" { addr = ":0" } // Most errors that occur when starting an http.Server are actually Listen // errors. If we encounter one of those, we can abort immediately. ln, err := h.listenFunc("tcp", addr) if err != nil { return fmt.Errorf("error starting HTTP server on %q: %v", addr, err) } errCh := make(chan error, 1) go func() { // Serve blocks until it encounters an error or until the server shuts // down, so we need to call it in a separate goroutine. Errors here // (apart from http.ErrServerClosed) are rare. err := h.srv.Serve(ln) errCh <- err // Close the channel so that if shutdown is called on this Handle // again, it doesn't wait on the channel indefinitely. close(errCh) }() // We wait until the server is ready to process requests. // // We would normally be able to return after starting the listener but // that introduces a very annoying race condition: // // Consider, // // err := h.Start(..) // h.Shutdown(ctx) // // If srv.Shutdown gets invoked before the goroutine that is calling // srv.Serve has transitioned the server to the running state, // srv.Shutdown will return right away but srv.Serve will run forever. d := h.newDialerFunc() if err := waitUntilAvailable(ctx, d, ln.Addr().String()); err != nil { select { case err := <-errCh: // If the server failed to start up, errCh probably has a more // helpful error. return fmt.Errorf("error starting HTTP server: %v", err) default: // Kill the listener if we failed to start the server up. // // We don't need to do this for the errCh path because having a // value in errCh indicates that Serve finished running, and Serve // always closes the listener. ln.Close() return wrapNetErr(err, "error waiting for server to start up") } } h.errCh = errCh h.ln = ln return nil } // Shutdown initiates a graceful shutdown of the HTTP server. The provided // context controls how long we are willing to wait for the server to shut // down. Shutdown will block until the server has shut down completely or // until the context finishes. func (h *Handle) Shutdown(ctx context.Context) error { if err := h.srv.Shutdown(ctx); err != nil { return err } if err := <-h.errCh; err != http.ErrServerClosed { return err } h.ln = nil return nil } ================================================ FILE: httpserver/tcp_listener.go ================================================ package httpserver import ( "net" "time" ) // Copied from https://github.com/golang/go/blob/fcee1897767c0cfa6e13a843fe5ee5d1deb8081b/src/net/http/server.go#L3156-L3172 // tcpKeepAliveListener sets TCP keep-alive timeouts on accepted // connections. It's used by ListenAndServe and ListenAndServeTLS so // dead TCP connections (e.g. closing laptop mid-download) eventually // go away. type tcpKeepAliveListener struct { *net.TCPListener } func (ln tcpKeepAliveListener) Accept() (c net.Conn, err error) { tc, err := ln.AcceptTCP() if err != nil { return } tc.SetKeepAlive(true) tc.SetKeepAlivePeriod(3 * time.Minute) return tc, nil } ================================================ FILE: httpserver/wait.go ================================================ package httpserver import ( "context" "fmt" "net" ) var _invalidHTTPRequestLine = []byte("INVALID\n\n") // Subset of the net.Dialer API that we care about. type dialer interface { DialContext(context.Context, string, string) (net.Conn, error) } var _ dialer = (*net.Dialer)(nil) // waitUntilAvailable uses the given dialer to connect to the HTTP server at // the provided address and waits until the server is ready to accept requests // or the given context times out. // // This works by sending an invalid request line to the server and waiting for // a response. The request line is the "GET /index.html HTTP/1.1" part of an // HTTP request. Instead of sending a valid one which could end up calling the // user-provided request handler, we send one that will be rejected by the // HTTP server implementation without crashing. func waitUntilAvailable(ctx context.Context, d dialer, addr string) error { conn, err := d.DialContext(ctx, "tcp", addr) if err != nil { return wrapNetErr(err, "failed to dial to %q", addr) } defer conn.Close() if deadline, ok := ctx.Deadline(); ok { // DialContext applies the timeout only to establishing the // connection. Here we're applying the same deadline to the rest of // this TCP conversation. if err := conn.SetDeadline(deadline); err != nil { return fmt.Errorf("failed to set connection deadline to %v: %v", deadline, err) } } if _, err := conn.Write(_invalidHTTPRequestLine); err != nil { return wrapNetErr(err, "failed to write request to server") } // Once we receive a single byte from the server, we know that the server // is processing HTTP requests. var out [1]byte if _, err := conn.Read(out[:]); err != nil { return wrapNetErr(err, "failed to read response from server") } return nil } // Similar to fmt.Errorf except net.Error timeouts are translated to // context.DeadlineExceeded. func wrapNetErr(err error, msg string, args ...interface{}) error { if err == nil { return nil } if ne, ok := err.(net.Error); ok && ne.Timeout() { return context.DeadlineExceeded } if len(args) > 0 { msg = fmt.Sprintf(msg, args...) } return fmt.Errorf("%s: %v", msg, err) } ================================================ FILE: main.go ================================================ package main import ( "github.com/teknogeek/ssrf-sheriff/handler" "go.uber.org/fx" ) func main() { fx.New(opts()).Run() } func opts() fx.Option { return fx.Options( fx.Provide( handler.NewLogger, handler.NewConfigProvider, handler.NewSSRFSheriffRouter, handler.NewServerRouter, handler.NewHTTPServer, ), fx.Invoke(handler.StartFilesGenerator, handler.StartServer), ) } ================================================ FILE: templates/csv.csv ================================================ key,value token,%s ================================================ FILE: templates/html.html ================================================ token=%stoken=%s