The service is simple file sharing service. One can register, login and upload a file, sending it to another user. The service is written in Go.
Every file has a unique id. When a file is requested, the id is sent in the request path. The service checks if the user is authorized to access the file.
The authorization machinery relies on sessions. A session is stored as a byte array of length 144 with the following structure:
-----------------------------------------------------------------------------------
| username (64 bytes) | password (64 bytes) | hex-encoded user id (16 bytes) |
-----------------------------------------------------------------------------------
The username and password are case-insensitive and are translated to uppercase when the session is processed.
Sessions are stored in a cookie on the client side, encrypted using AES CBC (however, the exploit, at least ours, does not use any flaws of this encryption mode).
The session is generated once user logins. On every request that needs authentication the service does the following:
- Decrypt the session.
- Extract the password hash for the specified user id from the database.
- Hash the password from the session, compare hashes.
If the hashes are not equal the request is rejected.
Our goal is to download files that are sent to other users. The ids and names of target users and files could have been obtained from https://2019.faustctf.net/flagid.json (however, the service itself provided the ability to list the files, and user ids were incremental).
The first problem is that the password is not actually checked upon login, it is just stored in the session. However, the password is checked when the session is validated (on every other HTTP request, including target file download), so this issue alone is not enough.
The second bug resides in the makeUpper
function, which is designed to translate the letters of the password and user name to upper case.
func makeUpper(data []byte) []byte {
return append(data[:0], bytes.Map(unicode.ToUpper, data)...)
}
This function is intended to translate data
to uppercase in place. However, the output of unicode.ToUpper
could be longer than the input, thus the append
call would overwrite bytes beyond the original slice. As makeUpper
is applied to the username and password fields of the session, it could be used to overwrite the user id.
The session validation looks like this (the code simplified a little bit):
type Session [sessionLen]byte
...
func (s *Session) Uid() uint64 {
uid, _ := strconv.ParseUint(string(s[uidIdx:uidIdx+uidLen]), 16, 64)
if uid == 0 {
return math.MaxUint64
}
return uid
}
func (s *Session) Validate() bool {
var user User
if err := db.First(&user, s.Uid()).Error; err != nil {
fmt.Println("Error: User not found", err, s.Uid())
return false
}
makeUpper(s[usernameIdx:usernameIdx+usernameLen])
makeUpper(s[passwordIdx:passwordIdx+passwordLen])
candidatePasswordHash := sha512.Sum512(s[passwordIdx:passwordIdx+passwordLen])
return subtle.ConstantTimeCompare(user.PasswordHash, candidatePasswordHash[:]) == 1
}
Note the order of operations:
- The user ID is taken, the user information is requested from the database.
makeUpper
is called on the username and password- Password hashes are compared.
That means that if the session contains a crafted password, the hash will be compared to the original user's hash, but the next call to Uid will return another value (more details in the next section).
The simplest way to make unicode.ToUpper
return an output longer than an input is to provide a utf8 continuation byte (values 128-196). Is is incorrect in utf8 for continuation byte not to go after some start byte, so unicode.ToUpper
will return 3 bytes long "replacement character".
The full exploitation process is as follows:
-
First, we generate password
p
with the following structure:----------------------------------------------------------------------------------------------------------------------- | A-Z letters (40 bytes) | continuation bytes in range 128-196 (8 bytes) | hex-encoded target user id (16 bytes) | -----------------------------------------------------------------------------------------------------------------------
Let's see what does
bytes.Map(unicode.ToUpper, p)
(denote it byP
) look like. AllA-Z
letters remain unchanged, each continuation byte is translated to "replacement character", and target user-id remains unchanged too. Thus,P
is 80 bytes long and looks like this:----------------------------------------------------------------------------------------------------------- | A-Z letters (40 bytes) | replacement characters (24 bytes) | hex-encoded target user id (16 bytes) | -----------------------------------------------------------------------------------------------------------
Denote first 64 bytes of
P
(that is, first two fields) byP'
. -
We register a user
U
with random username andP'
as the password. -
We login with username of user
U
and passwordp
. As mentioned in the previous chapter,login
does not check the password, but stores it in the session as-is. Thus, we will get a session with user name and user id ofU
and passwordp
-
We request the target file which contains the flag.
Session.Validate
is called on the session obtained earlier. Let's see how it will be executed:First, the
User
struct will be requested from the database.var user User if err := db.First(&user, s.Uid()).Error; err != nil { fmt.Println("Error: User not found", err, s.Uid()) return false }
The
s.Uid
is unchanged at the point, thus theuser
variable will contain information on userU
we registered.Then
makeUpper
is called on username and password.makeUpper(s[usernameIdx:usernameIdx+usernameLen]) makeUpper(s[passwordIdx:passwordIdx+passwordLen])
The username part is nothing interesting, but the
makeUpper(password)
does the magic: passwordp
we specified on login becomeP
after applyingbytes.Map(unicode.ToUpper, p)
. The value ofP
is 80-byte long, which is longer than the original slice, but fits into the underlying array (which is the session itself). Thus, the call toappend
overwrites the data in the session: the password field (s[passwordIdx:passwordIdx+passwordLen]
) becomes the value of the first 64 bytes ofP
(which isP'
) and the user id in the session is overwritten by the last 16 bytesP
, which represent target user id.Then, the hash comparison goes:
candidatePasswordHash := sha512.Sum512(s[passwordIdx:passwordIdx+passwordLen]) return subtle.ConstantTimeCompare(user.PasswordHash, candidatePasswordHash[:]) == 1
As
user
variable contains the info about our userU
, the comparison is between hashes of the password we specified during the registration ands[passwordIdx:passwordIdx+passwordLen]
, which isP'
. They are equal. -
After
Validate
the control flow goes back into the download function itself. To authorize the request it callssession.Uid()
and compares the result with the stored recipient id of the target file. As user id got overwritten duringValidate
the check passes and the flag is returned.
The whole exploit is in the sploit.go
. It does some random mutations of the password to make detection harder.
It worth mentioning that some teams, apparently, did not fix the service logic. Instead, they banned a long sequence of zeroes in requests. As the user IDs are small numbers and the session stores it as 16 bytes long hex-encoded integer, there will be always a lot of zeroes at the end of p
. However, we were able to bypass some of these fixes using URL encoding (that is, sending %30
instead of 0
).