Tags: web python crypto miscellaneous 

Rating: 4.5

# [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 probably
a client for the service. Maybe getting unauthorized access
somehow 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 by
one 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 probably
some hash value.

### getting API endpoints

The client app `NotesApp.apk` should contain API endpoint URLs
for 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 to
method `downloadDb` in class `MainActivity`:

```java
public 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/ -I
HTTP/2 200
x-served-by: index.py
date: Sun, 25 Jun 2017 12:12:41 GMT
expires: Sun, 25 Jun 2017 12:22:41 GMT
etag: "HRNRyw"
x-cloud-trace-context: b816262e0f5a9cffbfbc8296c9694ddc
content-type: text/html
server: Google Frontend
cache-control: public, max-age=600
content-length: 507
age: 3
```

Note the `x-served-by: index.py` header. This may mean that
the service engine is a Python script called `index.py`.
If there is a Python script, there may be `.pyc` compiled
Python 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.pyc
Resolving 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 OK
Length: 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 compiled
bytecode.

### decompiling Python bytecode

Decompiling is not difficult using e.g.
[pycdc](https://github.com/zrax/pycdc):

```
$ git clone https://github.com/zrax/pycdc
Cloning into 'pycdc'...
remote: Counting objects: 1185, done.
remote: Total 1185 (delta 0), reused 0 (delta 0), pack-reused 1185
Receiving 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 os
import re
import sys
from hasher import ZXHash
import webapp2
import logging
import secrets
from google.appengine.ext import ndb
hexre = 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 = db
note.put()
```

The `locked_id` should be the name of the user who
owns the sensitive db (it is `Corg1l0ver99<3!!` hex decoded).

In order to authenticate as `Corg1l0ver99<3!!`, the hash
value 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 calculate
the hash value from the username.

So the challenge is to get the hash value of
`Corg1l0ver99<3!!` somehow. Btw, if we try to register
as `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 and
can 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.py
Resolving 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 Found
2017-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.pyc
Resolving 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 OK
Length: 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 sys
from binascii import unhexlify, hexlify
from 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 collision
vulnerability in the padding (which was found by
[vakzz](https://wbowling.info/)):

```python
string = 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 unregistered
users) `436f7267316c3076657239393c33212100`
or `436f7267316c3076657239393c3321210000` (etc.), we shall
have 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 GET
request 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 Speed
100 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:40
Enter ".help" for usage hints.
sqlite> .tables
Diff FLAG Notes
DiffSet NoteSet android_metadata
sqlite> select * from flag;
ctf{with_crypt0_d0nt_ro11_with_it}
```

Original writeup (https://github.com/tothi/ctfs/tree/master/google-ctf-2017/misc/secretnotes).