猫逃出Chrome沙箱 | STAR Labs
首页 | 关于 | 安全公告 | 博客 | 成就 | 出版物 | 搜索
首页 » 博客
猫逃出Chrome沙箱
2022年1月21日 · 12分钟阅读 · Hung Tien Tran (@hungtt28)
目录
- 引言
- 漏洞概览
- 利用过程
- Use-After-Free
- 控制向量
- 利用步骤
- 选择目标对象和控制RIP
- 完整漏洞链
- 演示
- 结论
- 参考文献
引言
2021年9月13日,Google发布了Chrome的安全公告,公告中提到Google已知两个在野被利用的漏洞:CVE-2021-30632(远程代码执行)和CVE-2021-30633(沙箱逃逸)。本文将重点讨论沙箱逃逸漏洞CVE-2021-30633。Man Yue Mo已经发布了一篇详细的博客文章解释CVE-2021-30632,这是一个类型混淆漏洞,导致Chrome中的RCE。
简而言之,沙箱逃逸是由于IndexedDB API中的Use-After-Free(UAF)漏洞,与V8中的越界写入(OOB Write)漏洞链式结合,并通过Mojo IPC连接触发。需要声明的是,这不是我发现的漏洞。我写这篇文章是为了帮助自己整理思路,理解漏洞和利用过程。我将进行沙箱逃逸的根本原因分析,并讨论我对完整漏洞链的观察和理解。
漏洞修复已经过去4个月,我认为发布分析是相对安全的。对于这篇博客,我们将使用问题修复前的最后一个稳定版Linux 64位桌面Chrome,稳定通道版本93.0.4577.0或开发通道版本95.0.4638.0。
根据以下提交的描述,我们能够确定攻击者可能使用的漏洞类型。
这个攻击面很有趣,因为它通过MojoJS绑定将复杂功能直接暴露给Javascript。要理解这个漏洞,你必须对Mojo接口有一定的了解。如果读者已经阅读了先决条件并对Mojo有基本了解,那将非常有帮助。
Robert Chen的《SBX Intro》和《Cleanly Escaping the Chrome Sandbox》是很好的文章,涵盖了Mojo的很多内容。
漏洞概览
为了利用这个漏洞,必须详细研究漏洞细节。CVE-2021-30633被描述为Indexed DB API中的Use after free,我找到了3个可能相关的补丁。
- [IndexedDB] Add browser-side checks for committing transactions.
- [IndexedDB] Don’t ReportBadMessage for Commit calls.
- [IndexedDB] Don’t kill the renderer for IPCs on finished transactions.
审查补丁后,我们注意到补丁最重要的部分是添加了函数IsAcceptingRequests
。
1
2
3
4
5
6
7
|
// Returns false if the transaction has been signalled to commit, is in the
// process of committing, or finished committing or was aborted. Essentially
// when this returns false no tasks should be scheduled that try to modify
// the transaction.
bool IsAcceptingRequests() {
return !is_commit_pending_ && state_ != COMMITTING && state_ != FINISHED;
}
|
我们将查看TransactionImpl::Put
中的补丁。补丁的要点总结如下,代码片段是修补后的版本。
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
|
void TransactionImpl::Put(
int64_t object_store_id,
blink::mojom::IDBValuePtr input_value,
const blink::IndexedDBKey& key,
blink::mojom::IDBPutMode mode,
const std::vector<blink::IndexedDBIndexKeys>& index_keys,
blink::mojom::IDBTransaction::PutCallback callback) {
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
CHECK(dispatcher_host_);
std::vector<IndexedDBExternalObject> external_objects;
if (!input_value->external_objects.empty())
CreateExternalObjects(input_value, &external_objects);
if (!transaction_) {
IndexedDBDatabaseError error(blink::mojom::IDBException::kUnknownError,
"Unknown transaction.");
std::move(callback).Run(
blink::mojom::IDBTransactionPutResult::NewErrorResult(
blink::mojom::IDBError::New(error.code(), error.message())));
return;
}
+ if (!transaction_->IsAcceptingRequests()) {
+ mojo::ReportBadMessage(
+ "Put was called after committing or aborting the transaction");
+ return;
+ }
IndexedDBConnection* connection = transaction_->connection();
if (!connection->IsConnected()) {
IndexedDBDatabaseError error(blink::mojom::IDBException::kUnknownError,
"Not connected.");
std::move(callback).Run(
blink::mojom::IDBTransactionPutResult::NewErrorResult(
blink::mojom::IDBError::New(error.code(), error.message())));
return;
}
|
简而言之,这意味着在提交过程中、提交完成或中止后,事务不能被执行。新添加的IsAcceptingRequests
函数检查事务的三种状态:is_commit_pending_
、COMMITTING
和FINISHED
。
简单来说,我们需要控制三种状态中的至少一种,然后挂钩该状态,这意味着我们可以在事务处于该状态时调用其他请求。
从源代码中,我们可以看到is_commit_pending_
和COMMITTING
可以通过IndexedDBTransaction::Commit()
或TransactionImpl::Commit()
修改。两者都来自事务的提交。FINISHED
可以通过IndexedDBTransaction::Abort()
或IndexedDBTransaction::CommitPhaseTwo()
设置。
然而,这两个函数都会在IndexedDBConnection::RemoveTransaction()
结束,事务被移除后我们无法控制任何东西。is_commit_pending_
的控制流也在IndexedDBTransaction::Abort()
结束。
我们可以暂时忽略它,专注于COMMITTING
状态。
在跟踪IndexedDBTransaction::Commit()
内部的控制流时,我们在函数IndexedDBBackingStore::Transaction::WriteNewBlobs()
中发现了一段有趣的代码。
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
|
blob_storage_context->WriteBlobToFile(
std::move(pending_blob),
backing_store_->GetBlobFileName(database_id_,
entry.blob_number()),
IndexedDBBackingStore::ShouldSyncOnCommit(durability_),
last_modified, write_result_callback);
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));
|
事务将调用另外两个服务,blob_storage
或file_system_access
,将提交数据写入磁盘。你可以在DB Data Path中查看这一点。
有趣的是,其他服务是异步执行的,这意味着当写入完成时,它将调用write_result_callback
并继续提交执行。
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
|
auto write_result_callback = base::BindRepeating(
[](base::WeakPtr<Transaction> transaction,
storage::mojom::WriteBlobToFileResult result) {
if (!transaction)
return;
DCHECK_CALLED_ON_VALID_SEQUENCE(transaction->sequence_checker_);
// This can be null if Rollback() is called.
if (!transaction->write_state_)
return;
auto& write_state = transaction->write_state_.value();
DCHECK(!write_state.on_complete.is_null());
if (result != storage::mojom::WriteBlobToFileResult::kSuccess) {
auto on_complete = std::move(write_state.on_complete);
transaction->write_state_.reset();
IDB_ASYNC_TRACE_END(
"IndexedDBBackingStore::Transaction::WriteNewBlobs",
transaction.get());
std::move(on_complete).Run(BlobWriteResult::kFailure, result);
return;
}
--(write_state.calls_left);
if (write_state.calls_left == 0) {
auto on_complete = std::move(write_state.on_complete);
transaction->write_state_.reset();
IDB_ASYNC_TRACE_END(
"IndexedDBBackingStore::Transaction::WriteNewBlobs",
transaction.get());
std::move(on_complete)
.Run(BlobWriteResult::kRunPhaseTwoAsync, result);
}
},
weak_ptr_factory_.GetWeakPtr());
|
这发生在::CommitPhaseOne()
中,数据写入后,它继续::CommitPhaseTwo()
。幸运的是,我们可以在渲染器中绑定blob_storage
或file_system_access
并挂钩其操作,这里是clone()
请求,你可以在poc中看到。在clone()
回调中,我们只需将事务挂在COMMITTING
状态,并将一些任务推入事务中。
1
2
3
4
5
6
7
|
// 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());
|
下一步,我们将接管事务的其他状态以触发漏洞。要创建UAF,我们认为如果可以在clone()
回调中释放一个对象,然后继续提交再次访问已释放的对象,那将很容易,但我们找不到任何可以用于此模式的对象。
幸运的是,我设法找到了一个可以使用的缓存原始对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
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));
|
entry
是存储在external_objects_
中的外部对象。它在file_system_access
回调请求中被缓存为原始指针。因此,如果我们在external_objects
中释放entry
,object
将成为悬空指针,我们可以通过用对象存储中的相同键名替换外部对象来实现这一点。
我们可以通过放置另一个具有相同键名的外部对象来释放旧的外部对象。在事务提交过程中,它会在等待外部对象写入时注册一些带有外部对象原始指针的回调。由于这个漏洞,我们可以通过在克隆请求中挂钩来在事务提交时调用put操作符。这意味着我们可以在回调触发之前释放外部对象,最终导致UAF。
总之,在事务提交过程中不应安排任何任务。这有几个含义。
但要完全理解这个漏洞对我们意味着什么,有必要理解IndexedDB的一些核心概念和数据库机制。我们还需要一些关于IndexedDB功能、设计及其在Chromium中实现的官方文档。以下链接中的信息是我们所需的。
最好阅读以下文档,以便对mojo、mojo绑定和IndexedDB概念有一个 perspective。
- IndexedDB API
- Basic Terminology
- Using IndexedDB
- DB Data Path
- Mojo docs (go/mojo-docs)
- Mojo JavaScript Bindings API
利用过程
此时,我们在浏览器端得到了一个UAF漏洞,利用这个漏洞可以帮助我们绕过沙箱。我们有两种方法,第一种是伪造虚拟调用,如《Virtually Unlimited Memory: Escaping the Chrome Sandbox》,第二种是强制浏览器从《Cleanly Escaping the Chrome Sandbox》中创建带有--no-sandbox
标志的新子进程。
在我们的利用中,如你所见,我们可以伪造一个完整的对象,帮助我们控制rip。但首先,让我们看看我们可以用这个漏洞做什么。
Use-After-Free
简而言之,我们可以在clone()
回调中释放所有external_objects_
,当返回到write_result_callback
时,缓存的对象已被释放并触发UAF。如上面的代码片段所示,释放对象后只调用:
1
2
3
4
5
6
7
|
object->set_file_system_access_token(serialized_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);
}
|
函数IndexedDBExternalObject::set_file_system_access_token
很短,如我们所见。它只设置值到file_system_access_token_
属性。file_system_access_token_
或token
是一个std::vector<uint8_t>
,包含文件名。我们可以控制内容,因此在这种模式下,我们可以覆盖一个向量。
控制向量
我不确定向量是如何实现的,但我们可以看到它包含三个指向它控制的堆的指针,即begin、end和end_cap。
1
2
3
4
5
6
7
8
9
10
|
// https://github.com/llvm-mirror/libcxx/blob/master/include/vector
template <class _Tp, class _Allocator>
inline _LIBCPP_INLINE_VISIBILITY
__vector_base<_Tp, _Allocator>::__vector_base()
_NOEXCEPT_(is_nothrow_default_constructible<allocator_type>::value)
: __begin_(nullptr),
__end_(nullptr),
__end_cap_(nullptr)
{
}
|
我们从这里收集了两个观察结果。首先,我们可以泄漏向量的堆指针。其次,我们可以在file_system_access_token_
中伪造一个向量,它将指向我们想要的任何堆。因为向量的operator=
会自动删除向量容器(如果存在),这意味着如果file_system_access_token_
不为空,它将被释放。因此,我们可以通过在被释放的对象中设置一个伪造的向量来释放任何堆内存地址。
我们只能得到一些泄漏的指针,但被释放的对象无法再被控制。为了有更多选项,我们可以释放泄漏的向量和位于该堆内存上的任何对象(记住我们可以释放任何地址)。然后,我们可以设置另一个UAF,针对我们想要的任何对象。
利用步骤
以下是利用步骤:
- 步骤1:喷洒伪造的external_object并获取泄漏的token作为p
- 步骤2:释放泄漏的地址p
- 步骤3:将目标对象喷洒到p中
- 步骤4:再次释放p,即释放目标对象
- 步骤5:喷洒伪造的目标对象
- 步骤6:触发目标对象的虚拟调用 => 控制rip
选择目标对象和控制RIP
当我们能够创建另一个任何对象的UAF时,这相当引人注目,但这种场景也带来了一些问题。
在创建目标对象时,可能会随之创建嘈杂的对象,这将使我们难以将目标对象喷洒到洞p中。
此外,目标对象应该易于调用虚拟方法,以便我们能够控制rip。
在我们的利用中,我们选择喷洒大小为504(0x1f8)的BlobDataItem对象。它不难喷洒,我们可以伪造一个BlobDataItem::DataHandle,它可以调用::GetSideDataSize()
方法作为虚拟调用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
void BlobImpl::ReadSideData(ReadSideDataCallback callback) {
handle_->RunOnConstructionComplete(base::BindOnce(
[](BlobDataHandle handle, ReadSideDataCallback callback,
BlobStatus status) {
...
const auto& item = items[0];
if (item->type() != BlobDataItem::Type::kReadableDataHandle) {
std::move(callback).Run(absl::nullopt);
return;
}
int32_t body_size = item->data_handle()->GetSideDataSize();
if (body_size == 0) {
std::move(callback).Run(absl::nullopt);
return;
}
|
完整漏洞链
为了制作完整漏洞链,我调整了由@Zeusb0X制作的CVE-2021-03632 PoC作为RCE部分。
我们将把该poc转换为任意读/写,并设置enabled_bindings_
标志以启用mojo绑定,并触发此利用以绕过沙箱。
在修复这些漏洞时,IndexedDB的javascript绑定默认未生成,因此你必须自己生成。
在制作演示的过程中,我使用了一些固定地址来测试易受攻击的版本。如果我们为不同版本编写利用,你将需要编写查找全局地址和rop小工具的部分。
完整的利用代码可以在我们的github仓库中找到。
演示
演示时间到了。
结论
最后,我相信有很多方法可以利用这个漏洞。上述场景也可以转换为任意读/写