The Cat Escaped from the Chrome Sandbox | STAR Labs
目录
引言
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中的Out-of-Bounds(OOB)Write漏洞链式结合,并通过Mojo IPC连接触发。需要声明的是,这不是我发现的漏洞。我写这篇文章是为了帮助自己整理思路,理解漏洞和利用过程。我将进行沙箱逃逸的根本原因分析,并讨论我对完整链式利用的观察和理解。
漏洞已被修补4个月,我认为发布分析是相对安全的。对于本文,我们将使用问题修复前最后一个稳定的Linux桌面Chrome 64位版本,稳定通道的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个可能与此相关的补丁。
https://chromium-review.googlesource.com/c/chromium/src/+/3149409
[IndexedDB] 添加浏览器端检查以提交事务。
事务开始提交后不应再有新的IPC进入。此CL除了现有的渲染器端检查外,还添加了浏览器端检查。
Bug: 1247766
Change-Id: If9d69d5a0320bfd3b615446710358dd439074795
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3149409
Commit-Queue: Marijn Kruisselbrink mek@chromium.org
Reviewed-by: Joshua Bell jsbell@chromium.org
Cr-Commit-Position: refs/heads/main@{#919898}
https://chromium-review.googlesource.com/c/chromium/src/+/3154398
[IndexedDB] 不要为Commit调用ReportBadMessage。
我们似乎确实在事务已经开始提交或中止后收到很多提交调用,所以暂时避免杀死渲染器,直到我们找出这些调用的来源。
Bug: 1247766
Change-Id: If7a4d4b12574c894addddbfcaf336295bd90e0a3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3154398
Reviewed-by: Daniel Murphy dmurph@chromium.org
Commit-Queue: Marijn Kruisselbrink mek@chromium.org
Cr-Commit-Position: refs/heads/main@{#920304}
https://chromium-review.googlesource.com/c/chromium/src/+/3163121
[IndexedDB] 不要为已完成事务上的IPC杀死渲染器。
这些检查错误地假设事务只能由于先前的渲染器IPC而进入“完成”状态。然而,浏览器进程也可能启动中止事务。如果发生这种情况,我们应该简单地忽略传入的IPC。
理想情况下,我们仍然会区别对待在(渲染器启动的)提交之后发生的传入IPC,并在那些情况下杀死渲染器,但目前最安全的做法似乎是永远不杀死渲染器,因为我们目前没有跟踪做出这种区分所需的状态。
Bug: 1249439, 1247766
Change-Id: Ie1a39eade7505bd841230045031cd1eca5c6dbbd
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3163121
Reviewed-by: Joshua Bell jsbell@chromium.org
Commit-Queue: Marijn Kruisselbrink mek@chromium.org
Cr-Commit-Position: refs/heads/main@{#921750}
审查补丁后,我们注意到补丁最重要的部分是添加了函数IsAcceptingRequests。
1
2
3
4
|
// 如果事务已信号提交、正在提交过程中,或已完成提交或已中止,则返回false。基本上,当此函数返回false时,不应调度任何尝试修改事务的任务。
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函数检查事务的3种状态,即is_commit_pending_、COMMITTING和FINISHED。
简单来说,我们需要控制3种状态中的至少1种,然后挂钩该状态,这意味着我们可以在事务处于该状态时调用其他一些请求。
从源代码中,我们可以看到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操作符——通过挂钩clone请求。因此,这意味着我们可以在回调触发之前释放外部对象,最终导致UAF。
总之,在事务提交过程中不应调度任何任务。这有几个含义。
但要完全理解漏洞对我们意味着什么,有必要了解IndexedDB的一些核心概念并理解数据库机制。我们还需要一些关于IndexedDB功能、设计及其在chromium中实现的官方文档。以下链接中的信息是我们所需的。
最好阅读以下文档,以便对mojo、mojo绑定和IndexedDB概念有一个视角。
- 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时,这相当引人注目,但这种场景也带来了一些问题。
在创建目标对象时,可能会随之创建嘈杂