Author: Au1ge
Blog : http://www.au1ge.xyz/
文章由作者Au1ge整理博客后首发先知社区
上篇传送门: 从Chrome源码看JavaScript的执行(上)

DOM操作

回到正题,通过调试找到文章最开头两个例子的区别,先回想一下刚才调试的过程中我们拿到了什么能够帮助我们的信息

  1. 浏览器在获取到script end tag的token的时候会进入JS环境
  2. 浏览器在每次ProcessToken的时候都会Flush一遍当前的DOM树
  3. 如果浏览器在解析过程中遇到了可以执行的script,会在HTMLDocumentParser::ProcessTokenizedChunkFromBackgroundParser这个函数中pause并且进入JS环境
  4. PrepareScript可能是所有JS执行前需要执行的函数

那么就来调试一下简化的能触发alert的版本

<script type="text/javascript" id="id"></script>
<script>
var a="alert(1)";
document.getElementById("id").innerHTML=a;
</script>

首先看一下一共有多少token需要处理:

AtomicHTMLToken kStartTag name "script"
AtomicHTMLToken kEndTag name "script"
AtomicHTMLToken kCharacter data "
"
AtomicHTMLToken kStartTag name "script"
AtomicHTMLToken kCharacter data "
var a="alert(1)";
document.getElementById("id").innerHTML=a;
"
AtomicHTMLToken kEndTag name "script"
AtomicHTMLToken kCharacter data "alert(1)"
AtomicHTMLToken kEndOfFile
AtomicHTMLToken kCharacter data "
"
AtomicHTMLToken kEndOfFile

可以发现,alert(1)单独作为一个token出现了,并且他前后都没有自己的script token,他被插入的目标script token在最开始就已经被加入

这次我们断在script的入口点 ScriptLoader::PrepareScript ,很明显的,进入了三次 ScriptLoader::PrepareScript 函数,每进入一次scriptloader,chromium都会判断这个script能否被执行,第一次进入scriptloader的时候没有通过其中这个判断

if (!element_->HasSourceAttribute() && !element_->HasChildren())
    return false;

因为他是一个空的script标签,所以没有执行,而第二,三次进入scriptloader都通过了所有判断最后执行

先来看第三次进入scriptloader的时候,即执行alert的时候通过了什么函数

经过层层判断,并且按照是inline的script还是有src属性的script进行分类之后,进入ScriptLoader::ExecuteScriptBlock(TakePendingScript(), script_url) 这个函数里

然后又是经过一系列的判断和check...进入script->RunScript(frame, element_->GetDocument().GetSecurityOrigin());这个函数就是我们上个章节没有跟进去的地方

给RunScript下个断点,继续深入

进入了classic_script.cc里

void ClassicScript::RunScript(LocalFrame* frame,
                              const SecurityOrigin* security_origin) const {
  frame->GetScriptController().ExecuteScriptInMainWorld(
      GetScriptSourceCode(), BaseURL(), FetchOptions(), access_control_status_);
}

获取了sourcecode,baseurl和options之后,终于要进入v8的世界了

script_controller.cc

void ScriptController::ExecuteScriptInMainWorld(
    const ScriptSourceCode& source_code,
    const KURL& base_url,
    const ScriptFetchOptions& fetch_options,
    AccessControlStatus access_control_status) {
  v8::HandleScope handle_scope(GetIsolate());
  EvaluateScriptInMainWorld(source_code, base_url, fetch_options,
                            access_control_status,
                            kDoNotExecuteScriptWhenScriptsDisabled);
}

一条很长的调用链:

ScriptController::ExecuteScriptInMainWorld
ScriptController::ExecuteScriptAndReturnValue
V8ScriptRunner::RunCompiledScript
Script::Run(Local<Context> context)
MaybeHandle<Object> Execution::Call
MaybeHandle<Object> CallInternal
V8_WARN_UNUSED_RESULT MaybeHandle<Object> Invoke
IntrinsicsGenerator::Call

暂时就看到这里,我们先看看没有执行alert的例子1在目前这个阶段和当前的例子2有什么不同:

<div id="id"></div>
<script>
var a="<script>alert(1)</s"+"cript>";
document.getElementById("id").innerHTML=a;
</script>

还是先断在ProcessToken,看下当前这个html有几个token需要处理

AtomicHTMLToken kEndOfFile
AtomicHTMLToken kStartTag name "div"
AtomicHTMLToken kEndTag name "div"
AtomicHTMLToken kCharacter data "
"
AtomicHTMLToken kStartTag name "script"
AtomicHTMLToken kCharacter data "
var a="<script>alert(1)</s"+"cript>";
document.getElementById("id").innerHTML=a;
"
AtomicHTMLToken kEndTag name "script"
AtomicHTMLToken kStartTag name "script"
AtomicHTMLToken kCharacter data "alert(1)"
AtomicHTMLToken kEndTag name "script"
AtomicHTMLToken kEndOfFile
AtomicHTMLToken kEndOfFile

这下看出了一些问题,被DOM操作添加的script标签,竟然也在token里面,但是他并没有执行,这就比较好办,直接单步看最后一个script EndTag是如何处理的,和上一个html的alert token对比就行,要是没有这个token的话,就需要看执行DOM操作之后的回调操作了,这里也就验证了最开始的一个观点:

毫无疑问,innerHTML会执行回流操作,并且查到innerHTML中的东西会被加入DOM树

那么来看一下这里为什么没有进入JS的执行环境吧

把断点打在ProcessToken和PrepareScript上,执行到最后一个script EndTag

好的,出现问题了,最后一个script闭合标签根本没有走到PrepareScript里,需要再往前打断点

试试打在这里:HTMLParserScriptRunner::ProcessScriptElement

也没进入….需要直接在ProcessToken后单步调

在HTMLDocumentParser::PumpTokenizer() 这个函数里的IsPaused() 是false,所以没有进入JS环境,同时注意到这里的函数是PumpTokenizer而不是之前执行JS的时候进入的HTMLDocumentParser::ProcessTokenizedChunkFromBackgroundParser,而在PumpTokenizer里isPaused()函数就算判断成功了好像也不会进入JS环境?

PumpTokenizer的判断

if (IsPaused()) {
    DCHECK_EQ(tokenizer_->GetState(), HTMLTokenizer::kDataState);

    DCHECK(preloader_);
    // TODO(kouhei): m_preloader should be always available for synchronous
    // parsing case, adding paranoia if for speculative crash fix for
    // crbug.com/465478
    if (preloader_) {
      if (!preload_scanner_) {
        preload_scanner_ = CreatePreloadScanner(
            TokenPreloadScanner::ScannerType::kMainDocument);
        preload_scanner_->AppendToEnd(input_.Current());
      }
      ScanAndPreload(preload_scanner_.get());
    }
  }

ProcessTokenizedChunkFromBackgroundParser的判断

if (IsPaused()) {
      // The script or stylesheet should be the last token of this bunch.
      DCHECK_EQ(it + 1, tokens->end());
      if (IsWaitingForScripts())
        RunScriptsForPausedTreeBuilder();
      ValidateSpeculations(std::move(chunk));
      break;
    }

是在处理token之前就进行的判断

看下PumpTokenizer之前的函数调用栈,发现果然出现了 V8Element::innerHTMLAttributeSetterCallback ,

在其中的 V8Element::innerHTMLAttributeSetterCustom 下个断点,对比两个例子的执行差异

先来看不能执行JS的例子1

整个调用链是:

V8Element::innerHTMLAttributeSetterCallback
V8Element::innerHTMLAttributeSetterCustom
Element::setInnerHTML
Element::SetInnerHTMLFromString
ReplaceChildrenWithFragment

然后发现了封装的地方,是ContainerNode::AppendChild :

container_node->RemoveChildren();
  container_node->AppendChild(fragment, exception_state);

首先remove需要操作的node的所有children,然后再把当前node添加进去,其中container_node的数据类型是blink::HTMLDivElement *一个指向div元素的指针

Node* ContainerNode::AppendChild(Node* new_child,
                                 ExceptionState& exception_state) {
  DCHECK(new_child);
  // Make sure adding the new child is ok
  if (!EnsurePreInsertionValidity(*new_child, nullptr, nullptr,
                                  exception_state))
    return new_child;

  NodeVector targets;
  DOMTreeMutationDetector detector(*new_child, *this);
  if (!CollectChildrenAndRemoveFromOldParent(*new_child, targets,
                                             exception_state))
    return new_child;
  if (!detector.HadAtMostOneDOMMutation()) {
    if (!RecheckNodeInsertionStructuralPrereq(targets, nullptr,
                                              exception_state))
      return new_child;
  }

  NodeVector post_insertion_notification_targets;
  {
    ChildListMutationScope mutation(*this);
    InsertNodeVector(targets, nullptr, AdoptAndAppendChild(),
                     &post_insertion_notification_targets);
  }
  DidInsertNodeVector(targets, nullptr, post_insertion_notification_targets);
  return new_child;
}

其中ContainerNode包含了大多数的DOM操作,appendChild, getElementById, getElementByName...

之后经过了许多判断后返回,这里因为我们首先调试的是不能执行JS的,不好判断是哪里触发,所以准备先通过调试能执行JS的判断触发点

接着来看下能执行JS的回调链

整个调用链是:

V8Element::innerHTMLAttributeSetterCallback
V8Element::innerHTMLAttributeSetterCustom
Element::setInnerHTML
Element::SetInnerHTMLFromString
ReplaceChildrenWithFragment
ContainerNode::AppendChild

和前面的调用链一样,但是在ContainerNode::AppendChild中的DidInsertNodeVector(targets, nullptr, post_insertion_notification_targets); 执行后触发了JS,可以从这个函数开始调

DidInsertNodeVector调用了container_node里的ChildrenChange结构体的ForInsertion函数进行对node的添加

static ChildrenChange ForInsertion(Node& node,
                                       Node* unchanged_previous,
                                       Node* unchanged_next,
                                       ChildrenChangeSource by_parser) {
      ChildrenChange change = {
          node.IsElementNode() ? kElementInserted : kNonElementInserted, &node,
          unchanged_previous, unchanged_next, by_parser};
      return change;
    }

这里node.IsElementNode() 是false,因为这里的node只有alert(1),而前面不能执行JS的例子这里就是true,因为他是一个完整的script node

继续往下走,直到这个函数 html_script_element.cc

void HTMLScriptElement::ChildrenChanged(const ChildrenChange& change) {
  HTMLElement::ChildrenChanged(change);
  if (change.IsChildInsertion())
    loader_->ChildrenChanged();
}

直觉这可能就是我们要找的地方,对script标签的ChildrenChange,这里,于是调用了loader里的ChildrenChanged() script_loader.cc

void ScriptLoader::ChildrenChanged() {
  if (!parser_inserted_ && element_->IsConnected())
    PrepareScript();  // FIXME: Provide a real starting line number here.
}

看到了熟悉的PrepareScript,执行完这个函数后JS执行

于是我们可以猜测,有一个类似的 HTMLDivElement::ChildrenChanged 或者是其余常规标签单独一个ChildrenChanged,并且不会进入JS环境,调试看看

是后一种猜测:

void HTMLElement::ChildrenChanged(const ChildrenChange& change) {
  Element::ChildrenChanged(change);
  AdjustDirectionalityIfNeededAfterChildrenChanged(change);
}

对div标签进行的innerHTML操作调用了HTMLElement::ChildrenChanged而非能进入JS环境的ScriptLoader::ChildrenChanged,至此,关于开头提出的问题已经解释清楚了,最开始的猜想基本是正确的

思考延伸

1.除了script,还有什么标签独立设置了ChildrenChanged,为什么?

没有调用 HTMLElement::ChildrenChanged,有自己的处理的:

styleElement:

StyleElement::ProcessingResult StyleElement::ChildrenChanged(Element& element)

Object:

void HTMLObjectElement::ChildrenChanged(const ChildrenChange& change)

Menu:

void MenuItemView::ChildrenChanged()

调用了 HTMLElement::ChildrenChanged 但是有其他操作的:

Input_element:

void HTMLInputElement::ChildrenChanged(const ChildrenChange& change)

textarea:

void HTMLTextAreaElement::ChildrenChanged(const ChildrenChange& change)

title:

void HTMLTitleElement::ChildrenChanged(const ChildrenChange& change)

Svg:

void SVGElement::ChildrenChanged(const ChildrenChange& change)

2.innerHTML+=和innerHTML=会有什么不同

innerHTML+=和innerHTML在DOM树上的操作基本一样,都需要对目标节点里的东西重新添加一遍,而如果目标节点是script标签的话:

在PrepareScript函数中,第一步就是判断这个script是否已经start

// Step 1. If the script element is marked as having "already started", then
  // return. The script is not executed. [spec text]
  if (already_started_)
    return false;

在第九步的时候,设置already_started_为true

// Step 9. Set the element's "already started" flag. [spec text]
  already_started_ = true;

所以,如果对一个已经执行过的scirpt标签用innerHTML+=添加一个javascript语句,这个语句也是不会执行的,如果对一个空的script标签用innerHTML+=的话则会执行,因为在第五步的时候会判断script标签是否有内容,没有的话直接return,则没有进入第九步设置already_started_处

// Step 5. If the element has no src attribute, and source text is the empty
  // string, then return. The script is not executed.
  //
  // TODO(hiroshige): Update the behavior according to the spec.
  if (!element_->HasSourceAttribute() && !element_->HasChildren())
    return false;

3.什么时候会进入JS环境

如果需要进入JS环境,则必须要有PrepareScript的过程,通过查找PrepareScript函数的调用则可以明确知道什么时候的JS有机会执行,什么时候没机会执行

4.在文章的最开头缘由部分我还举了这样一个例子:

<div id="id"></div>
<script>
var a = document.createElement('script');
a.innerText = 'alert(/xx/)';
document.getElementById("id").appendChild(a);
</script>

这样是可以成功执行的,那么这里是为什么可以执行,JS的触发点在哪里?

根据之前的调试经验,先猜测这里一共会有这几个token:

div start
div end
data \n
script start
data "var a = document.createElement('script');..."
script end
script start
data alert
script end

并且很可能执行alert是在appencChild的回调函数里

来调试试试,首先断在ProcessToken处

token里并没有appendchild加入的script节点,因为断点断在ProcessToken,而这个函数是用于处理标签文本的,用createElement创建的节点不需要再经过一次这样的处理

AtomicHTMLToken kStartTag name "div"
AtomicHTMLToken kEndTag name "div"
AtomicHTMLToken kCharacter data "
"
AtomicHTMLToken kStartTag name "script"
AtomicHTMLToken kCharacter data "
var a = document.createElement('script');
a.innerText = 'alert(/xx/)';
document.getElementById("id").appendChild(a);
"
AtomicHTMLToken kEndTag name "script"
AtomicHTMLToken kEndOfFile

要注意的一个细节是,前面的例子里innerHTML的回调是在处理完所有的token之后才进行的, 那么这里的JS触发点到底在哪,有了前面的经验,可以直接在PrepareScript处下一个断点,查看调用栈,PrepareScript一共调用了3次,第三次时alert触发,这不意外,因为每一次刷新DOM树都会导致script节点进入PrepareScript函数,但是已经执行过的script节点则不会走到最后的执行阶段,于是断点下的再深一点,下在ScriptLoader::ExecuteScriptBlock函数里的script->run处,这次只调用了两次,第二次调用时alert执行

查看执行alert的时候的调用栈,直接调用PrepareScript函数的地方是这个

void ScriptLoader::DidNotifySubtreeInsertionsToDocument() {
  if (!parser_inserted_)
    PrepareScript();  // FIXME: Provide a real starting line number here.
}

往上看,在执行完 ContainerNode::AppendChild 之后,也就是插入子节点之后,调用了这个函数:

void ContainerNode::DidInsertNodeVector(
    const NodeVector& targets,
    Node* next,
    const NodeVector& post_insertion_notification_targets) {
  Node* unchanged_previous =
      targets.size() > 0 ? targets[0]->previousSibling() : nullptr;
  for (const auto& target_node : targets) {
    ChildrenChanged(ChildrenChange::ForInsertion(
        *target_node, unchanged_previous, next, kChildrenChangeSourceAPI));
  }
  for (const auto& descendant : post_insertion_notification_targets) {
    if (descendant->isConnected())
      descendant->DidNotifySubtreeInsertionsToDocument();
  }
  for (const auto& target_node : targets) {
    if (target_node->parentNode() == this)
      DispatchChildInsertionEvents(*target_node);
  }
  DispatchSubtreeModifiedEvent();
}

其中 descendant->DidNotifySubtreeInsertionsToDocument(); 即是调用的ScriptLoader::DidNotifySubtreeInsertionsToDocument(),所以又验证了一个猜想,如果appendChild的child是一个script节点,那么就会进入JS环境

源链接

Hacking more

...