massa wasm
这是一次失败尝试的复盘,这篇记录的内容耗费了我一整个星期的时间,基本是每天早起一睁眼就在尝试和翻阅文档。虽然失败了心有不甘,但是其中所踩过的坑和一些思考还是有必要记录一下,可以当作后续审计类似项目的参考依据。
Part 0: 起因
在massa的审计过程中,当审计readonly request相关代码时,我发现了一个对于智能合约执行可能造成影响的点https://github.com/massalabs/massa/blob/DEVN.28.3/massa-execution-worker/src/execution.rs#L1700:
match response {
Ok(Response { init_gas_cost, .. })
| Err(VMError::ExecutionError { init_gas_cost, .. }) => {
self.module_cache
.write()
.set_init_cost(&bytecode, init_gas_cost);
}
_ => (),
}
每一次massa的合约执行,都会向massa的module_cache中写入一个ini_cost,这个值代表的是massa合约的初始化的gas消耗。
在每一次执行massa合约调用的时候,会查询这个init_cost,如果这个值被cache过,那么就会判断一下当前的gas是否能够满足初始化需求,否则就会reverthttps://github.com/massalabs/massa/blob/DEVN.28.3/massa-module-cache/src/controller.rs#L130:
pub fn load_module(
&mut self,
bytecode: &[u8],
execution_gas: u64,
) -> Result<RuntimeModule, CacheError> {
// Do not actually debit the instance creation cost from the provided gas
// This is only supposed to be a check
execution_gas
.checked_sub(self.cfg.gas_costs.max_instance_cost)
.ok_or(CacheError::LoadError(format!(
"Provided gas {} is lower than the base instance creation gas cost {}",
execution_gas, self.cfg.gas_costs.max_instance_cost
)))?;
// TODO: interesting but unimportant optim
// remove max_instance_cost hard check if module is cached and has a delta
let module_info = self.load_module_info(bytecode);
let module = match module_info {
ModuleInfo::Invalid(err) => {
let err_msg = format!("invalid module: {}", err);
return Err(CacheError::LoadError(err_msg));
}
ModuleInfo::Module(module) => module,
ModuleInfo::ModuleAndDelta((module, delta)) => {
if delta > execution_gas {
return Err(CacheError::LoadError(format!(
"Provided gas {} is below the gas cost of instance creation ({})",
execution_gas, delta
)));
} else {
module
}
}
};
Ok(module)
}
值得注意的是,在创建合约时,massa只会记录合约地址以及存储bytecode,这个init_cost并不会被记录,只有当第一次调用这个合约时,合约所存储的bytecode才会被执行,init_cost 才会被记录。
这个看起来比较寻常的操作,在常规的执行逻辑下并没有什么问题。然而,在readonly这种场景下init_cost同样被记录,这可能会导致非常严重的问题。
在readonly的场景中,一些环境变量会与常规的执行不同,典型的比如:地址余额、创建合约地址、区块信息等等,这些环境的不同会对执行的结果产生影响。
试想下面的场景:
- Alice 部署了一个合约,在这个合约的初始化过程中,Alice通过某种方法判断出来当前的执行环境是常规执行或者readonly,并根据判断的结果,在readonly条件下消耗较多的gas,在常规条件下消耗较少的gas。
- 假设有一个节点Bob,Alice 首先使用readonly方法,通过节点Bob调用了这个合约,Bob将init_gas设置成了一个较大的值。值得注意的是,readonly request相当于ethcall方法,这一交易请求不会广播到网络其他节点中。
- Alice 然后使用常规方法调用了这个合约,Bob将这一常规交易广播到其他节点。
由于在第2步中设置了init_cost,所以第3步中,Bob执行常规交易时,他最终的计算结果状态是和其他网络节点不一样的,那么这就引发了一个分叉问题。
那么接下来,为了实现上述猜想,最主要的问题就是:WASM初始化逻辑在哪?这个逻辑可以自定义吗?
Part 1: WASM初始化
在进一步阅读massa的代码时,我重点关注了sc-runtime中的create_instance逻辑,在这部分相关代码逻辑中,发现了如下片段:
https://github.com/massalabs/massa-sc-runtime/blob/main/src/wasmv1_execution/env.rs#L70
// Create the instance
let instance = match Instance::new(store, &module.binary_module, import_object) {
Ok(instance) => instance,
Err(err) => {
// Filter the error created by the metering middleware when
// there is not enough gas at initialization
if let InstantiationError::Start(ref e) = err {
if let Some(trap) = e.clone().to_trap() {
if trap == TrapCode::UnreachableCodeReached && e.trace().is_empty() {
return Err(WasmV1Error::InstanciationError(
"Not enough gas, limit reached at instance creation".to_string(),
));
}
}
}
return Err(WasmV1Error::InstanciationError(format!(
"Error during instance creation: {}",
err
)));
}
};
// Create FFI for memory access
let ffi = Ffi::try_new(&instance, store)
.map_err(|err| WasmV1Error::RuntimeError(format!("Could not create FFI: {}", err)))?;
// Infer the gas cost of instance creation (_start function call)
let mut init_gas_cost = 0;
if cfg!(not(feature = "gas_calibration")) {
init_gas_cost = match metering::get_remaining_points(store, &instance) {
MeteringPoints::Remaining(remaining_points) => module
.gas_limit_at_compilation
.checked_sub(remaining_points)
.expect(
"Remaining gas after instance creation is higher than the gas limit at compilation",
),
MeteringPoints::Exhausted => {
return Err(WasmV1Error::InstanciationError(
"Not enough gas, gas exhausted after instance creation".to_string(),
));
}
};
}
从上面的代码逻辑和注释中可以知道,wasm字节码在初始化过程中,确实会调用一个名为 “_start”的函数,而且这个start函数看起来是可以自定义的。
那么这个函数该如何在assemblyscript(massa-sdk frontend)中定义呢?这一部分相关的文档描述并不多,在我进行了诸多尝试后,终于得知原来这个神秘的start function,其实就是独立于函数定义外的代码,比如下面代码中的if else部分,就是这个wasm的start函数:
import { print, generateEvent } from "@massalabs/massa-as-sdk"
if(true){
//do sth
}
else{
//do sth
}
export function receive(data: StaticArray<u8>): void {
let response: string = "message correctly received: " + data.toString();
generateEvent(response);
print(response);
}
关于start function,更详细的信息可以参考:
https://rustwasm.github.io/wasm-bindgen/reference/attributes/on-rust-exports/start.html#:~:text=The%20start%20function%20must%20take,only%20applications%20use%20this%20attribute.
https://webassembly.github.io/spec/core/syntax/modules.html#start-function
既然已经找到了初始化相关的逻辑,那么只需要编写一个if else逻辑,通过readonly和常规执行的差别,就能实现之前提到的效果了。
然而,当我尝试下面的start函数实现时,却出现了如下错误:
import { print, generateEvent, transactionCreator } from "@massalabs/massa-as-sdk"
if(transactionCreator() == ALICE){
//do readonly logic
}
else{
//do normal logic
}
//"ABI calls are not available during instantiation"
进一步阅读相关逻辑,可以看到massa在wasmv1版本执行的时候,已经考虑了这个问题,把相关的ABI给禁用了:
// Create the ABI imports and pass them an empty environment for now
let shared_abi_env: ABIEnv = Arc::new(Mutex::new(None));
let import_object = register_abis(&mut store, shared_abi_env.clone());
// save the gas remaining before subexecution: used by readonly execution
interface.save_gas_remaining_before_subexecution(gas_limit);
// Create an instance of the execution environment.
let execution_env =
ExecutionEnv::create_instance(&mut store, &module, interface, gas_costs, &import_object)
.map_err(|err| {
VMError::InstanceError(format!(
"Failed to create instance of execution environment: {}",
err
))
})?;
// Get gas cost of instance creation
let init_gas_cost = execution_env.get_init_gas_cost();
// Set gas limit of function execution by subtracting the gas cost of
// instance creation
let available_gas = match gas_limit.checked_sub(init_gas_cost) {
Some(remaining_gas) => remaining_gas,
None => {
return Err(VMError::ExecutionError {
error: "Available gas does not cover instance creation".to_string(),
init_gas_cost,
})
}
};
execution_env.set_remaining_gas(&mut store, available_gas);
// Get function to execute. Must follow the following prototype: param_addr:
// i32 -> return_addr: i32
let wasm_func =
execution_env
.get_func(&store, function)
.map_err(|err| VMError::ExecutionError {
error: format!(
"Could not find guest function {} for call: {}",
function, err
),
init_gas_cost,
})?;
// Allocate and write function argument to guest memory
let param_offset = execution_env
.create_buffer(&mut store, param)
.map_err(|err| VMError::ExecutionError {
error: format!(
"Could not write argument for guest call {}: {}",
function, err
),
init_gas_cost,
})?;
// Now that we have an instance, we can make the execution environment
// available to the ABIs. We avoided setting it before instance creation
// to prevent the implicit `_start` call from accessing the env and
// causing non-determinism in init gas usage.
shared_abi_env.lock().replace(execution_env);
但是其实这是多此一举,在官方文档描述中,start函数就是不能进行任何外部ABI调用的。
Note
The start function is intended for initializing the state of a module. The module and its exports are not accessible externally before this initialization has completed.
由于进行不了ABI调用,所以massa的相关状态都是获取不了的,进而就无法进行readonly和常规执行环境的判断,所以上述的所有猜想都不能实现。
Part 2: Global Variables in WASM
既然在start函数中进行不了外部ABI调用,但是WASM中总是有一些全局变量是可以读取的,那么这些变量中,有没有哪些是可以满足之前的设想的呢?
在这里,我主要参照了AssemblyScript的官方文档:https://www.assemblyscript.org/stdlib/globals.html?
起初是为了找到某个变量能够反映readonly和常规执行的区别,但是在翻阅过程中,我找到了如下几个可能出现问题的地方:
- usize:操作系统位数相关
- NaN:操作系统位数相关
- Date.now():系统时间
- random:系统随机数
- process:CPU相关
这些变量在不同的计算机,不同的运行环境、时间所得出的结果都会有所不同,并且并不局限于调用方式。在常规的合约逻辑中对不同的结果进行条件跳转,都会造成执行结果的不同。这样会导致整个链的硬分叉,危害性丝毫不亚于之前提到的猜想。
于是我对每一个提到的变量都进行了测试,结果却不尽如人意。
- usize:WASM中统一将其设定为32(除非运行的是WASM64,然而WASM64还在实验阶段)
- NaN:编译器选项设置:https://github.com/massalabs/massa-sc-runtime/blob/main/src/as_execution/mod.rs#L156
- Date.now():绑定为massa区块时间。
- random、process:ABI调用非法。
在这其中,date.now函数我写到了测试代码中,看着编译通过运行通过,当时就感觉八九不离十了,然后激动的去睡觉了。然而在第二天醒来再完善测试的时候,却发现事情并不像我想的那么简单,这个时间戳和区块的时间戳一样,一定是某种方式绑定了。然后我又试了一下random和process相关的函数,却发现是非法ABI调用,这令我十分费解。于是我重新阅读了一下massa相关的代码,发现了如下片段:
let imports = imports! {
"env" => {
// Needed by WASM generated by AssemblyScript
"abort" => Function::new_typed_with_env(store, &fenv, assembly_script_abort),
"seed" => Function::new_typed_with_env(store, &fenv, assembly_script_seed),
"Date.now" => Function::new_typed_with_env(store, &fenv, assembly_script_date_now),
"console.log" => Function::new_typed_with_env(store, &fenv, assembly_script_console_log),
"console.info" => Function::new_typed_with_env(store, &fenv, assembly_script_console_info),
"console.warn" => Function::new_typed_with_env(store, &fenv, assembly_script_console_warn),
"console.error" => Function::new_typed_with_env(store, &fenv, assembly_script_console_error),
"console.debug" => Function::new_typed_with_env(store, &fenv, assembly_script_console_debug),
"trace" => Function::new_typed_with_env(store, &fenv, assembly_script_trace),
"process.exit" => Function::new_typed_with_env(store, &fenv, assembly_script_process_exit),
},
原来massa对于这些全局的函数进行了部分的绑定,巧合的是我测试的date.now正好是绑定的函数之一(也算幸运,不然一晚上睡不着),更巧合的是,massa对于这些buildin函数,还有之前的start函数,都写了相关的测试:
https://github.com/massalabs/massa-sc-runtime/blob/main/wasm/unsupported_builtin_random_values.wasm
https://github.com/massalabs/massa-sc-runtime/blob/main/wasm/start_func_abi_call.wasm
原来我想到的思路,前人已经走过了。
而且这部分内容,关于在不同的系统、运行环境中,执行相同代码得出不一样结果的讨论,在WASM中有一个单独的话题,叫做Non-determinism:
https://github.com/WebAssembly/design/blob/390bab47efdb76b600371bcef1ec0ea374aa8c43/Nondeterminism.md
这个话题相关的链接,同样可以在massa代码中找到。所以显然,massa在设计合约运行环境时,明显是做过功课的。
Part 3: WASM Memory
那么还有没有什么地方有能够达成Non-determinism执行的变量呢?
进一步阅读assemblyscript文档,以及Nondeterminism相关的讨论,我发现WASM的memory似乎是一个可以利用的点。
首先,内存中可能存在未初始化的脏数据,这些数据很有可能是本地系统执行过程中残留的数据,这部分数据,如果有的话,一定是随机的。其次,WASM在运行时,是否有可能不同的运行环境其内存大小边界不同。
针对第一点,我进行了一些粗略的测试,并没有发现残留脏数据的内容。
针对第二点,我进一步的审计了massa的代码,在其运行的依赖库和massa代码中,找到了如下逻辑:
/// in massa-sc-runtime/src/as_execution/mod.rs
let base = BaseTunables::for_target(&Target::default());
let tunables = LimitingTunables::new(base, Pages(max_number_of_pages()));
/// wasmer/src/engine/tunnable.rs
impl BaseTunables {
/// Get the `BaseTunables` for a specific Target
pub fn for_target(target: &Target) -> Self {
let triple = target.triple();
let pointer_width: PointerWidth = triple.pointer_width().unwrap();
let (static_memory_bound, static_memory_offset_guard_size): (Pages, u64) =
match pointer_width {
PointerWidth::U16 => (0x400.into(), 0x1000),
PointerWidth::U32 => (0x4000.into(), 0x1_0000),
// Static Memory Bound:
// Allocating 4 GiB of address space let us avoid the
// need for explicit bounds checks.
// Static Memory Guard size:
// Allocating 2 GiB of address space lets us translate wasm
// offsets into x86 offsets as aggressively as we can.
PointerWidth::U64 => (0x1_0000.into(), 0x8000_0000),
};
// Allocate a small guard to optimize common cases but without
// wasting too much memory.
// The Windows memory manager seems more laxed than the other ones
// And a guard of just 1 page may not be enough is some borderline cases
// So using 2 pages for guard on this platform
#[cfg(target_os = "windows")]
let dynamic_memory_offset_guard_size: u64 = 0x2_0000;
#[cfg(not(target_os = "windows"))]
let dynamic_memory_offset_guard_size: u64 = 0x1_0000;
Self {
static_memory_bound,
static_memory_offset_guard_size,
dynamic_memory_offset_guard_size,
}
}
}
tunnable memory大概意思就是在host上给vm分配内存的东西。
可以看到,在初始化内存阶段,wasmer会根据不同的架构,设置不同的内存参数,主要就是 static_memory_bound, static_memory_offset_guard_size, dynamic_memory_offset_guard_size三个 这三个变量的含义,可以阅读:https://docs.rs/wasmtime/latest/wasmtime/struct.Config.html#method.static_memory_guard_size
当时,我并没有查阅这相关的文档,我误认为了static_memory_bound是一个页的大小(这个值确实是64K,WASM的页大小也是64K,注释写的页很模棱两可,给我造成了极大的误导),然而实际上这个值是最大页的数量。
当我将这个值误认为是页大小后,我设想通过分配一个64Kb的内存,然后查看当前内存大小。如果是64位系统,那么内存增长量应该为1,在32位系统中,这个值应该会更大。
在尝试如何使用32位系统无果后,我通过改变量的方式在scruntime中进行了测试:
//let base = BaseTunables::for_target(&Target::default());
let base = BaseTunables{
static_memory_bound: 0x4000.into(),
//static_memory_offset_guard_size: 0x10000,
static_memory_offset_guard_size: 0x8000_0000,
dynamic_memory_offset_guard_size: 0x2_0000
};
println!("base set");
let tunables = LimitingTunables::new(base, Pages(max_number_of_pages()));
然而,测试结果却并不如预期,不管是32位设置还是64位,页的大小都是固定的64KB,到这时我才翻阅了更多的文档,搞明白了原来这个值是最大页数的意思。
由于massa在运行时,额外限定了最大页大小为64个,所以在这个bound层面,32和64其实都是一样的,最终在massa中都是64(取最小值)。
进一步的调试和代码阅读,得知dynamic那个变量是没用的(涉及文件太多了,这里就不列了)。
那么,二者其实就只在static_memory_offset_guard_size上面有差别。
那么这个guard到底是个什么呢? 在这里我阅读了非常多的文档,这些文档除了之前提到的(也是最后才找到的) https://docs.rs/wasmtime/latest/wasmtime/struct.Config.html#method.static_memory_guard_size
都没有将这个概念讲清楚,这里稍微罗列一下: https://www.assemblyscript.org/runtime.html#variants https://github.com/WebAssembly/design/blob/390bab47efdb76b600371bcef1ec0ea374aa8c43/Semantics.md
通过我浅薄的理解,以及通过阅读wasmer的逻辑,似乎这段内存是紧跟着WASM linear memory之后的,然后越界读写到这个区域会产生一个trap。
这里似乎是一个可以利用的点,毕竟这是massa代码中明确写明的根据架构具有差异的部分了。
Part 4: Try Hard
我最开始的思路是,32位系统的这个guard非常小,如果我读这个区域以外的数据,会有什么效果?
|———–|————-|————–| linear mem guard wild
也就是上图中读wild区域。
我直接使用了load/store函数来操作wild区域的内存,发现在不同的架构设置中,并没有区别,都是报oob的错。
看起来常规的读写操作并不可行,于是乎我尝试了截断读法,类似于这样:
load<usize>(bound - 2);
load<u64>(bound - 1);
同样不可以。
那么有没有更特殊的方法呢?有的,而且我之前很熟,那就是堆操作(原来你一直与我同在)。
这个地方就不细说了,也是一个失败的尝试。大体来说就是通过修改free后的堆内容,使得fd bk指针落入一些乱七八糟的区域,这个区域可以是任意内存地址(前提是没有检查)。由于之前对ptmalloc和dlmalloc机制相对熟悉一些,所以尝试了一晚上加一个上午。
期间发现了一些有趣的点:
- 堆内存分配会产生很多碎片,比如alloc(1000), free(ptr), alloc(1000),实际上第二次alloc的ptr不是第一次的ptr
- 堆内存free后并不会还给host,也就是说linearmemory只增不减。
(其实这部分已经超纲了,就算挖出来也是和massa没关系,该报给wasmer,不过这一点是在我上头入魔后差不多一天后我才反应过来。)
结束堆部分尝试之后,我又找了别的环境变量测试了一下,还有一些wasmer的bugfix相关代码等。这里简单贴一下: https://www.assemblyscript.org/stdlib/globals.html?#control-flow
https://www.assemblyscript.org/stdlib/globals.html?#utilities
https://www.assemblyscript.org/concepts.html#branch-level-tree-shaking
https://www.assemblyscript.org/runtime.html#variants
https://www.assemblyscript.org/runtime.html#interface
https://github.com/AssemblyScript/assemblyscript/blob/main/std/assembly/shared/typeinfo.ts
https://github.com/wasmerio/wasmer/pull/4338
https://github.com/wasmerio/wasmer/issues/4519
https://github.com/bytecodealliance/wasmtime/security/advisories/GHSA-ff4p-7xrq-q5r8
Part 5: JIT
经过一天的尝试,我终于发现我走的有点偏了。重新整理了思路后,我认为最终的点还是要落在那个guard的值上,毕竟这是明确的,在massa中设置的值,而且这个值可定有他的用处,肯定有一种方式能够触发这一条件的检查。
在我求助gpt无果后,我终于找到了这段描述:
pub fn static_memory_guard_size(&mut self, guard_size: u64) -> &mut Self Configures the size, in bytes, of the guard region used at the end of a static memory’s address space reservation.
Configures the size, in bytes, of the guard region used at the end of a static memory’s address space reservation.
Note: this value has important performance ramifications, be sure to understand what this value does before tweaking it and benchmarking.
All WebAssembly loads/stores are bounds-checked and generate a trap if they’re out-of-bounds. Loads and stores are often very performance critical, so we want the bounds check to be as fast as possible! Accelerating these memory accesses is the motivation for a guard after a memory allocation.
Memories (both static and dynamic) can be configured with a guard at the end of them which consists of unmapped virtual memory. This unmapped memory will trigger a memory access violation (e.g. segfault) if accessed. This allows JIT code to elide bounds checks if it can prove that an access, if out of bounds, would hit the guard region. This means that having such a guard of unmapped memory can remove the need for bounds checks in JIT code.
For the difference between static and dynamic memories, see the
Config::static_memory_maximum_size
.How big should the guard be?
In general, like with configuring
static_memory_maximum_size
, you probably don’t want to change this value from the defaults. Otherwise, though, the size of the guard region affects the number of bounds checks needed for generated wasm code. More specifically, loads/stores with immediate offsets will generate bounds checks based on how big the guard page is.For 32-bit wasm memories a 4GB static memory is required to even start removing bounds checks. A 4GB guard size will guarantee that the module has zero bounds checks for memory accesses. A 2GB guard size will eliminate all bounds checks with an immediate offset less than 2GB. A guard size of zero means that all memory accesses will still have bounds checks.
Default
The default value for this property is 2GB on 64-bit platforms. This allows eliminating almost all bounds checks on loads/stores with an immediate offset of less than 2GB. On 32-bit platforms this defaults to 64KB.
Errors
The
Engine::new
method will return an error if this option is smaller than the value configured forConfig::dynamic_memory_guard_size
.
大致意思就是说,这个值主要是为了提升边界检查的速度,怎么提升呢,如果某一段代码能够证明他的边界是不超过guard这个范围的,那么根据这段代码生成的JIT,就不需要边界检查了。
也就是说,要触发这个值的判断,需要整个JIT出来。
那么怎么搞呢?
我对于WASM JIT相关的理解,停留在我之前做的大作业,一个关于浏览器的漏洞中:https://s3cunda.github.io/2021/10/30/CVE-2021-21224-%E5%88%86%E6%9E%90%E7%AC%94%E8%AE%B0.html 以我浅薄的理解,我唯一能想到的让WASM代码生成JIT的方式,就是让某一个函数成为hot code,简单来说就是搞个循环调用非常多次。
测试代码:
export function foo(offset:usize) : usize
{
let maxPtr = 0x20000-2;
let ptr = Math.min(maxPtr, offset);
return load<usize>(<usize>ptr);
}
export function main(_: StaticArray<u8>): void {
memory.grow(1);
for(let i = 0; i < 0x20000 - 4; i ++){
console.log("ptr: " + foo(i).toString());
}
}
看起来像是那么回事,如果是64位,那么foo肯定是在bound内,不会进行bound检查,如果是32,那么就会报错。
但是,测试结果还是一样,不管是32还是64,都一样会报错oob。
那么问题出在哪里呢?
我的理解是,JIT代码是生成在运行时的,当一个函数调用次数过多,WASM引擎可能会重新绑定这个函数到对应的JIT中。
我又重新阅读了相关的描述,经过与专门做WASM安全的同学的沟通后,似乎这个地方我理解的并不对,这个JIT代码并不是运行时生成,而是编译时生成的。
以32位为例,如果一开始分配的内存是64KB,那么如果没有64KB以外的内存读取,那么就不需要设置bound check了。 同理,64位,如果一开始分配了4GB的内存空间,那么运行时就不存在边界检查了。
上面测试代码的初始内存大小为128KB,那么,对于32位的系统,所有发生在64KB内的内存操作都不需要check。对于64位系统来说,所有发生在128KB内的内存操作都不需要check。
也就是说,这个东西就是设定了一个不用内存check的值,这个值是min(init_mem_size, guard_size)
那么这个值体现在哪里呢?效率。
那么怎么仅通过WASM代码来拿到这个值呢?这个得需要编译器漏洞了,已经超出范围了。
至此,整个故事结束。
Part 6: 总结
虽然是一个持续一周的失败的尝试,不过还是有部分值得借鉴的地方。
对于WASM,Nondeterminism是一个比较广泛的课题,在审计类似的项目时可以多加留意下面的内容:
- api binding
- api ban
- compiler feature
- block context variables
- start function
更加宽泛的,在审计大项目时,还是得理解和运行项目方提供的所有测试用例,之前踩到的坑很多地方项目方都考虑过并且写过测试(当时因为没看懂啥意思就没管)。