Skip to content

MASTG-DEMO-0082: WebView WebStorage Cleanup

Download MASTG-DEMO-0082 APK Open MASTG-DEMO-0082 Folder Build MASTG-DEMO-0082 APK

Sample

The code snippet below shows a sample that uses a WebView to store sensitive data in the cache and then performs a cleanup with WebStorage API.

The sensitive data is stored using localStorage.setItem in the inline HTML loaded into the WebView. The values used are:

  • 'sensitive_token' with value 'SECRET_TOKEN_123456'.
  • 'driving_license_id' with value 'DL-987654321'.
  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
package org.owasp.mastestapp

import android.os.Bundle
import android.webkit.WebStorage
import android.webkit.WebView
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView

class MainActivityWebView : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            WebViewScreen()
        }
    }

    override fun onStop() {
        WebStorage.getInstance().deleteAllData()
        super.onStop()
    }
}

@Composable
@Preview
fun WebViewScreen() {
    val context = LocalContext.current
    val mastgTestWebViewClass = MastgTestWebView(context)
    var showWebView by remember { mutableStateOf(false) } // Controls whether the WebView is displayed
    var webViewKey by remember { mutableIntStateOf(0) }   // Unique key to force WebView recreation
    var webViewRef by remember { mutableStateOf<WebView?>(null) } // Holds a reference to the WebView

    // Use BackHandler to intercept the back press when the WebView is visible.
    if (showWebView) {
        BackHandler {
            // Check if the WebView can go back in history.
            if (webViewRef?.canGoBack() == true) {
                webViewRef?.goBack()
            } else {
                // If no back history exists, hide the WebView.
                showWebView = false
            }
        }
    }

    BaseScreen(
        onStartClick = {
            showWebView = true // Show the WebView
            webViewKey++       // Increment key to create/recreate WebView
        }
    ) {
        if (showWebView) {
            // Recompose AndroidView with a new key when showWebView is true
            key(webViewKey) {
                AndroidView(
                    factory = {
                        WebView(context).apply {
                            webViewRef = this // Store a reference to the WebView.
                            mastgTestWebViewClass.mastgTest(this)
                            setBackgroundColor(0)
                        }
                    },
                    modifier = Modifier
                        .fillMaxSize()
                        .clip(RoundedCornerShape(8.dp))
                )
            }
        } else {
            // Placeholder with text
            Text(
                modifier = Modifier.padding(16.dp),
                text = "Click \"Start\" to open the WebView.",
                color = Color.White,
                fontSize = 20.sp,
                fontFamily = FontFamily.SansSerif
            )
        }
    }
}
 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
package org.owasp.mastestapp

import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient

class MastgTestWebView(private val context: Context) {

    @SuppressLint("SetJavaScriptEnabled")
    fun mastgTest(webView: WebView) {
        webView.apply {
            // Enable JS and DOM storage so the page can use localStorage
            settings.apply {
                javaScriptEnabled = true
                domStorageEnabled = true
            }

            // Bridge object exposed to JS as "Android" so the page can request the app to close
            addJavascriptInterface(AndroidBridge(), "Android")

            // Inline HTML which writes sensitive data into localStorage, shows a countdown,
            // and calls Android.closeApp() when finished.
            val html = """
                <!doctype html>
                <html>
                <head>
                  <meta name="viewport" content="width=device-width,initial-scale=1.0" />
                  <style>
                    body{
                      background:#111;
                      color:#fff;
                      font-family:sans-serif;
                      margin:0;
                      padding:0;
                      text-align:center;
                    }
                    .msg{
                      font-size:20px;
                      margin:12px;
                      display:block;
                    }
                    .count{
                      font-size:30px;
                      margin-top:8px;
                      display:inline-block;
                    }
                  </style>                
                </head>
                <body>
                 <div class="msg">Stored a token and user's driving licence ID in Local Storage.</div>
                 <br />
                 <div class="msg">The app will now close in : <span id="count" class="count"></span></div>
                 <script>
                    // Store sensitive data in localStorage
                    try {
                      localStorage.setItem('sensitive_token', 'SECRET_TOKEN_123456');
                      localStorage.setItem('driving_license_id', 'DL-987654321');
                    } catch(e) { /* ignore */ }

                    var n = 10;
                    var el = document.getElementById('count');
                    if(el) el.innerText = n;
                    var t = setInterval(function(){
                      n--;
                      if(n<=0){
                        clearInterval(t);
                        if(el) el.innerText = 'Closing...';
                        try { Android.closeApp(); } catch(e) { /* ignore */ }
                      } else {
                        if(el) el.innerText = n;
                      }
                    }, 1000);
                  </script>
                </body>
                </html>
            """.trimIndent()

            // Load the HTML into the WebView. Use a base URL so localStorage origin is defined.
            loadDataWithBaseURL("https://mas.owasp.org/", html, "text/html", "utf-8", null)
        }
    }

    private inner class AndroidBridge {
        @Suppress("unused") // called from JS
        @JavascriptInterface
        fun closeApp() {
            val activity = context as? Activity ?: return
            activity.runOnUiThread {
                activity.finish()
            }
        }
    }
}
 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
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.MASTestApp"
        tools:targetApi="31">
        <activity
            android:name=".MainActivityWebView"
            android:exported="true"
            android:windowSoftInputMode="adjustResize"
            android:theme="@style/Theme.MASTestApp">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

Steps

  1. Install the app on a device ( Installing Apps)
  2. Make sure you have Frida for Android installed on your machine and the frida-server running on the device
  3. Run run.sh to spawn the app with Frida
  4. Click the Start button in the app
  5. Wait for the Frida script to capture the WebView cleanup calls
  6. Stop the script by quitting the Frida CLI
  7. Use Host-Device Data Transfer to search the contents of the /data/data/<app_package>/app_webview/ directory for the sensitive data used in the WebView.
 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
function enumerateDeleteAllDataMethod() {
  const res = Java.enumerateMethods('com.android.webview.chromium.*!deleteAllData');
  return res && res[0];
}

function enumerateSetDomStorageEnabledMethod() {
  const res = Java.enumerateMethods('com.android.webview.chromium.*!setDomStorageEnabled');
  return res && res[0];
}

function ensureClassLoaded(className) {
  try {
    Java.use(className);
  } catch (e) {
  }
}

function printBacktrace(maxLines = 8) {
  let Exception = Java.use("java.lang.Exception");
  let stackTrace = Exception.$new().getStackTrace().toString().split(",");

  console.log("\nBacktrace:");
  for (let i = 0; i < Math.min(maxLines, stackTrace.length); i++) {
      console.log(stackTrace[i]);
  }
  console.log("\n");
}

Java.perform(function () {
  console.log('Enumerating chromium.');

  if (enumerateDeleteAllDataMethod() === undefined) {
    console.log('Bring WebStorage to memory so we can hook its deleteAllData method.');
    Java.use('android.webkit.WebStorage').getInstance();
  }

  const deleteAllDataMethod = enumerateDeleteAllDataMethod();
  if (deleteAllDataMethod !== undefined) {
    Java.classFactory.loader = deleteAllDataMethod.loader;
    const WebStorageAdapter = Java.use(deleteAllDataMethod.classes[0].name);

    WebStorageAdapter.deleteAllData.implementation = function () {
      console.log('WebStorage.deleteAllData called.');
      printBacktrace();
      return this.deleteAllData();
    };

    console.log('WebStorage.deleteAllData hooked.');
  } else {
    console.log('WebStorage.deleteAllData not found.');
  }

  ensureClassLoaded('android.webkit.WebView');
  ensureClassLoaded('android.webkit.WebSettings');

  const domMethod = enumerateSetDomStorageEnabledMethod();
  if (domMethod !== undefined) {
    Java.classFactory.loader = domMethod.loader;
    const ContentSettingsAdapter = Java.use(domMethod.classes[0].name);

    if (ContentSettingsAdapter.setDomStorageEnabled.overloads.length === 0) {
      console.log('setDomStorageEnabled found but no overloads exposed.');
    } else {
      ContentSettingsAdapter.setDomStorageEnabled.overloads.forEach(function (ov) {
        ov.implementation = function () {
          const enabled = arguments.length > 0 ? arguments[0] : undefined;
          console.log('ContentSettingsAdapter.setDomStorageEnabled called, enabled is ' + enabled + '.');
          printBacktrace();
          return ov.apply(this, arguments);
        };
      });

      console.log('ContentSettingsAdapter.setDomStorageEnabled hooked.');
    }
  } else {
    console.log('ContentSettingsAdapter.setDomStorageEnabled not found.');
  }
});
1
2
3
#!/bin/bash

frida -U -n MASTestApp -l script.js -o output.json

Observation

In the output we can see all instances of deleteAllData() of WebStorage called at runtime:

output.json
 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
Enumerating chromium.
WebStorage.deleteAllData hooked.
ContentSettingsAdapter.setDomStorageEnabled hooked.
ContentSettingsAdapter.setDomStorageEnabled called, enabled is true.

Backtrace:
com.android.webview.chromium.ContentSettingsAdapter.setDomStorageEnabled(Native Method)
org.owasp.mastestapp.MastgTestWebView.mastgTest(MastgTestWebView.kt:20)
org.owasp.mastestapp.MainActivityWebViewKt$WebViewScreen$3.invoke$lambda$1(MainActivityWebView.kt:81)
org.owasp.mastestapp.MainActivityWebViewKt$WebViewScreen$3.$r8$lambda$mzfzcYkVhAtctugnbe9BC6jefsk(Unknown Source:0)
org.owasp.mastestapp.MainActivityWebViewKt$WebViewScreen$3$$ExternalSyntheticLambda0.invoke(D8$$SyntheticClass:0)
androidx.compose.ui.viewinterop.ViewFactoryHolder.<init>(AndroidView.android.kt:329)
androidx.compose.ui.viewinterop.AndroidView_androidKt$createAndroidViewNodeFactory$1$1.invoke(AndroidView.android.kt:261)
androidx.compose.ui.viewinterop.AndroidView_androidKt$createAndroidViewNodeFactory$1$1.invoke(AndroidView.android.kt:260)


WebStorage.deleteAllData called.

Backtrace:
com.android.webview.chromium.e.deleteAllData(Native Method)
org.owasp.mastestapp.MainActivityWebView.onStop(MainActivityWebView.kt:41)
android.app.Instrumentation.callActivityOnStop(Instrumentation.java:1623)
android.app.Activity.performStop(Activity.java:8838)
android.app.ActivityThread.callActivityOnStop(ActivityThread.java:5368)
android.app.ActivityThread.performStopActivityInner(ActivityThread.java:5348)
android.app.ActivityThread.handleStopActivity(ActivityThread.java:5413)
android.app.servertransaction.TransactionExecutor.performLifecycleSequence(TransactionExecutor.java:240)

We can also see that the output of the adb shell grep command shows no matches for the sensitive data used in the WebView:

output_adb_deletion_succeeded.txt
1
adb shell 'grep -nri -E "SECRET_TOKEN_123456|DL-987654321" /data/data/org.owasp.mastestapp/app_webview'

Evaluation

The test passes as the application properly cleans up all storage data from the WebView cache using the WebStorage API.

If you want to demonstrate the failure case, you can comment out the WebStorage.getInstance().deleteAllData() line in the code and rerun the test. In this case, the test fails because the sensitive data remains in the WebView storage directory after closing the app.

output_adb_deletion_failed.txt
1
2
3
adb shell 'grep -nri -E "SECRET_TOKEN_123456|DL-987654321" /data/data/org.owasp.mastestapp/app_webview'
Binary file /data/data/org.owasp.mastestapp/app_webview/Default/Local Storage/leveldb/000003.log matches
Binary file /data/data/org.owasp.mastestapp/app_webview/Default/Local Storage/leveldb/000003.log matches