Exploring inner workings of a random free android VPN
Introduction
Nothing special here. Got some free time to play around and decided to explore a free VPN from android play store in hopes to find something interesting, solve some riddle and just have fun. For this experiment I chose “BD NET VPN”. It is freely avaible for any android phone from Google play store: BD NET VPN. There is nothing special in it. My interest grabbed that it was loading it encrypted configuration stored in github and I have experience finding interesting apps that use this method (not this time though).
Action
After some playing with the app I found that it was loading it’s configuration from this github file. It is a file that looks like
bZgJB3mu...........z9Xww7QQ==
Currently it is a 650+ KB file which is base64 encoded binary data.
Initially I wanted to just attach to the process and read the output after program deciphers it itself got tricked into wasting some time figuring out what I was doing incorrectly because the app just didn’t want to work. The app itself is distributed as xapk which is just a another zip on top of original apk file with additional extra files. In this version it contained several apk in one xapk.
I spent some time trying different ways to merge all those files into one single apk so that I could preprocess it with frida. The issue was that doing so with a rooted phone and automatic frida attachment didn’t work due the app being aware of that is running on a rooted phone and just crash. So after serveral different approaches not leading me to intended outcome I decided not to use frida for now and instead try other ways and come back it if nothing else works.
The problem with patching apk was that app checks for it’s hash file and if it is not matching hardcoded hash it just does not work. By the time I figured that it comparing hash and the location of that hash I already figured out everything I wanted and didn’t need frida anymore.
Decryption of internal strings
After decompiling of the apk I started looking into finding some familiar strings that I cool start my exploration from. Regular file browsing and then grepping didn’t give any valuable output. Even after searching for ‘github’ I couldn’t find anything. It turned out that they encrypted all the strings used with the app and all places where there supposed to be string it had some large “long signed” numbers.
Eventually I found byte code responsibe for string decryption and after some researching I found that they used paranoid library for string encryption. So instead of regular
someView.setText("Does it work?");
they used something like:
someView.setText(Deobfuscator.getString(0));
which in turn compiled into something like:
someView.setText(tp.a(-32738271919007L));
This littered decopiled code with a lot of “signed long” numbers. To convert this back to normal text we need to used deobfuscator. I took their code and slightly modified it for my own purposes. Here is how looks:
public class Deobfuscator {
public static final int MAX_CHUNK_LENGTH = 0x1fff;
private static final String[] chunks;
static {
String [] r0 = new String[5];
chunks = r0;
r0[0] = array_block0;
r0[1] = array_block1;
r0[2] = array_block2;
r0[3] = array_block3;
r0[4] = array_block4;
}
public static long seed(final long x) {
final long z = (x ^ (x >>> 33)) * 0x62a9d9ed799705f5L;
return ((z ^ (z >>> 28)) * 0xcb24d0a5c88c35b3L) >>> 32;
}
public static long next(final long state) {
short s0 = (short) (state & 0xffff);
short s1 = (short) ((state >>> 16) & 0xffff);
short next = s0;
next += s1;
next = rotl(next, 9);
next += s0;
s1 ^= s0;
s0 = rotl(s0, 13);
s0 ^= s1;
s0 ^= (s1 << 5);
s1 = rotl(s1, 10);
long result = next;
result <<= 16;
result |= s1;
result <<= 16;
result |= s0;
return result;
}
private static short rotl(final short x, final int k) {
return (short) ((x << k) | (x >>> (32 - k)));
}
public static String getString(final long id, final String[] chunks) {
long state = seed(id & 0xffffffffL);
state = next(state);
final long low = (state >>> 32) & 0xffff;
state = next(state);
final long high = (state >>> 16) & 0xffff0000;
final int index = (int) ((id >>> 32) ^ low ^ high);
state = getCharAt(index, chunks, state);
final int length = (int) ((state >>> 32) & 0xffffL);
final char[] chars = new char[length];
for (int i = 0; i < length; ++i) {
state = getCharAt(index + i + 1, chunks, state);
chars[i] = (char) ((state >>> 32) & 0xffffL);
}
return new String(chars);
}
private static long getCharAt(final int charIndex, final String[] chunks, final long state) {
final long nextState = next(state);
final String chunk = chunks[charIndex / MAX_CHUNK_LENGTH];
return nextState ^ ((long) chunk.charAt(charIndex % MAX_CHUNK_LENGTH) << 32);
}
public static void main(String[] args) {
System.out.print(Deobfuscator.getString(Long.parseLong(args[0]), chunks));
}
}
**array_block0, array_block1, array_block2, array_block3, array_block4 ** are just blocks of utf8 string text that is used for the algorithm during decoding phase. I fished it out from apk file. In total they are 8191 + 8191 + 8191 +8191 + 2472 = 35236 bytes of data. I didn’t include it in this post to not litter it with unneeded data but if you need you can ask me and I will send them via email.
To use this code you just run
java Deobfuscator.java -138372992564127
It will print back deobfusctaed string. Then I did a little more code magick and deobfuscated all integers back to their string representations.
Decoding encrypted payloads
After getting real strings it got easier to find places where interesting parts of code execution is happening. With this we find that github file object is directly embedded into the apk and is being simply loaded on startup and updates.
So the app loads this encoded file from github and proceeds to decoding. After it sae version number, caches the file and shows a notification with a message from that decoded file (from ‘changelogs’ field). So what is the decoding method?
To be honest I got a little lazy and didn’t spend much time trying to figure out what is begin used here. Maybe it is a custom algorithm or more probable it is again some helper library.
Here is a version of the code that does decryption of the downloaded file:
class Decryption {
public static int a(int i, int i2, int i3, int i4, int i5, int[] iArr) {
return ((i ^ i2) + (iArr[(i4 & 3) ^ i5] ^ i3)) ^ (((i3 >>> 5) ^ (i2 << 2)) + ((i2 >>> 3) ^ (i3 << 4)));
}
public static byte[] f(int[] iArr, boolean z) {
int length = iArr.length << 2;
if (z) {
int i = iArr[iArr.length - 1];
int i2 = length - 4;
if (i < i2 - 3 || i > i2) {
return null;
}
length = i;
}
byte[] bArr = new byte[length];
for (int i3 = 0; i3 < length; i3++) {
bArr[i3] = (byte) (iArr[i3 >>> 2] >>> ((i3 & 3) << 3));
}
return bArr;
}
public static int[] g(byte[] bArr, boolean z) {
int[] iArr;
int length = (bArr.length & 3) == 0 ? bArr.length >>> 2 : (bArr.length >>> 2) + 1;
if (z) {
iArr = new int[length + 1];
iArr[length] = bArr.length;
} else {
iArr = new int[length];
}
int length2 = bArr.length;
for (int i = 0; i < length2; i++) {
int i2 = i >>> 2;
iArr[i2] = iArr[i2] | ((bArr[i] & 255) << ((i & 3) << 3));
}
return iArr;
}
public static final byte[] b(byte[] bArr, byte[] bArr2) {
byte[] bArr3 = bArr2;
if (bArr.length == 0) {
return bArr;
}
int[] g = g(bArr, false);
int i = 16;
if (bArr3.length != 16) {
byte[] bArr4 = new byte[16];
if (bArr3.length < 16) {
i = bArr3.length;
}
System.arraycopy(bArr3, 0, bArr4, 0, i);
bArr3 = bArr4;
}
int[] g2 = g(bArr3, false);
int length = g.length - 1;
if (length >= 1) {
int i2 = g[0];
for (int i3 = ((52 / (length + 1)) + 6) * (-1638454863); i3 != 0; i3 -= -1638454863) {
int i4 = (i3 >>> 2) & 3;
int i5 = i2;
int i6 = length;
while (i6 > 0) {
int i7 = i6 - 1;
i5 = g[i6] - a(i3, i5, g[i7], i6, i4, g2);
g[i6] = i5;
i6 = i7;
}
i2 = g[0] - a(i3, i5, g[length], i6, i4, g2);
g[0] = i2;
}
}
return f(g, false);
}
static String readEntireFile(String path) throws IOException {
Path fileName = Path.of(path);
String str = Files.readString(fileName);
return str;
}
public static void main(String[] args) {
String filename = "./content.data";
if(args.length == 0) {
System.err.println("You didn't provide file as argument. Using default: " + filename);
}
else if(args.length == 1) {
filename = args[0];
}
else {
System.err.println("Usage: java Main.java <filename>");
System.exit(13);
}
try {
String str = readEntireFile(filename);
if(str.charAt(str.length() - 1) == 10) {
str = str.substring(0, str.length() - 1);
}
byte[] bytes = Base64.getDecoder().decode(str);
byte[] resultBytes = b(bytes, "...|...".getBytes("UTF-8"));
System.out.println(new String(resultBytes));
} catch(Exception e) {
System.out.println(e.toString());
}
}
}
You can run it with this line:
java Decryption.java <filename>
This will print back decoded contents which in this case is just a regular json string. It looks something like this:
{
"Payloads": [
{
"port": "80",
"host": "www.yellowpepper.com",
"friendly_name": "🇹🇹 Trinidad and Tobago bmobile 02",
"sausage": "",
"payload": "GET / HTTP/1.1[crlf]Host: [cf][crlf]User-Agent: [ua][crlf]Upgrade: websocket[crlf]Connection: Upgrade[crlf][crlf]",
"query": "normal",
"category": "ocsws",
"message": "",
"method": "http",
"sslws": "false",
"useDnsForward": "true",
"isHostSni": "false",
"isAutoSelect": "true",
"dothost": ""
}
...
...
],
"Free_servers": [
{
"server_name": "🇬🇧 UDP UK (London)",
"server_host": "udp-uk1.maxcdnify.org;udp-uk2.maxcdnify.org;udp-uk3.maxcdnify.org;udp-uk4.maxcdnify.org",
"server_port": "",
"server_udp_port": "",
"ssl_port": "",
"category": "ocsudp",
"squid_proxy": "udp-uk1.maxcdnify.org;udp-uk2.maxcdnify.org;udp-uk3.maxcdnify.org;udp-uk4.maxcdnify.org",
"squid_port": "",
"dns_proxy": "",
"custom_cert": "",
"username": "",
"password": ""
},
...
...
...
],
"Premium_servers": [],
"Vip_servers": [],
"Private_servers": [],
"Version": "6.2:62.0",
"Changelogs": "🌎 Network Updates\n\n- If not connected, please try other network options from this app\n- اگر متصل نیستید، لطفاً سایر گزینه های شبکه را از این برنامه امتحان کنید\n- Baglanmadyk bolsaňyz, bu programmadan beýleki ulgam opsiýalaryny synap görüň"
}
Full file is too big to put directly here so here is a link to a full contents if you want to have a look: decrypted_file.json
So here we see a version, changelog mesasge on update, payloads for domain fronting and free servers list. Premium, vip and private servers are missing and I couldn’t find a way to buy a pro version to load those. I guess it might be added in the future or there is some secret way to get those which I haven’t yet looked at.
It looks ok and don’t see anything malicious here (even though as we have seen before it is pretty easy to make it malicious in one update from the previous post).
How does this VPN work?
So from quick skimming through “code” and basic testing it looks like it is not a regular VPN used for security and in corporate environments but rather a tool to circumvent blocking of resources in places where they took blocking very seriously. The most important tool here is ofcourse “domain fronting” where they pretend to be some allowed website behind CDN and “tricking” CDN’s to connect to another site that will do routing.
So user from within the app can select a list of payloads which correspond to a specific country. I guess each country has different filters and they setup different payloads depending on the country. So if we look at the example json above the app will try to connect to the www.yellowpepper.com website which is behind cloudflare CDN. After connection it will send ‘payload’ of
GET / HTTP/1.1[crlf]Host: [cf][crlf]User-Agent: [ua][crlf]Upgrade: websocket[crlf]Connection: Upgrade[crlf][crlf]
but substitute params in square brackets with their corresponding values. crlf is just \r\n line terinator, ua - user agent and etc. The most interesting part is [cf]. It looks like it substitures is with one of server_host values from “Free_servers” list. So above line will become
GET / HTTP/1.1
Host: udp-uk1.maxcdnify.org
User-Agent: Mozilla/5.0 (Linux; Android 5.0; SAMSUNG SM-N900T Build/LRX21V) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/2.1 Chrome/34.0.1847.76 Mobile Safari/537.36
Upgrade: websocket
Connection: Upgrade
What type hidden connection between client and vpn server depends on selected type. I have not done deep inspection but it looks like they do openvpn (hidden inside regular http), Xray, psiphone and probably others. By looking at the example above and seeing it is trying to do websocket communication I can assumet it is Xray. So when cloudlare recieves this requrest it will read ‘Host’ field and redirect connection to a totally different host rather to the one that was used to initiate socket connection.
This example is pretty simple but if we look at othere there will be params like [split] which will try to send totally different host in the first packet to full DPI and send totally different host and request in the second packet on the same connection. This will probably fool a lot of blocking software which make decision based on the first packet.
Fun sidenote
While I was exploreing decompiled code I found some place that had only java bytecode. So I had to manually decompile one of the critical paths which looked a lot like an encryption mechanism. It looked a lot some some strange table lookup with strange shifts and code was very strange. From byte code it looked like some strange consecutive while loops with wrapped into one big while loop.
After some time it struck me that it was just optimzed version of base64 encoding. I replaced it with system’s impolemetaion and everything worked as expected. Glad that I had once implemented both versions of base64 my brain could pattern match my old memories.
Conclusion
I had fun playing around with this app. For my taste they did too much security by obscurity. They tried to stop execution by checking hash, encrypting all the string, checking if phone is root/emulator/x86 and few other techniques. Yet it still didn’t help hiding information.
Storing all the data in a wide open github repo is also very questionable solution. The reasoning could have been that blocking countries will not block github. Maybe it work but I would need to some people from those countries to verify that. I have not tested but it could be that ‘git pull’ and ‘object storage’ might be served from different places. Also there are different VPN’s on their Android Playstore page with different reposities on the same github account. All of them are decoded with same algorithm which is far from secure data storage.
Apart from that it is an interesting solution and deserves a bit more time of looking into what approaches they are using to make their app harder to explore and methods to circumvent blocking of resources. If you guys have interesting apps to look at you can send me a list. Not promising to look into it but will definetely add to my “apps to look into when I have free time” list.