一些有趣的问题(1)——Edge/IE是如何处理history.pushState的?
在活动(http://www.freebuf.com/fevents/102205.html)中我收到了很多有意思的问题,比如:
打开view-source:http://crashsafari.com可以看到该网页的代码如下:
实际有用的内容也就是红框一段了。代码逻辑很简单,不断地pushState,这个操作会不断向object History中添加信息并立刻改变地址栏(但不导航)。在树莓派中添加下列页面:
使用Safari访问,发现Safari疯狂吃内存和CPU,但是只是因为过于密集的代码执行使页面不响应用户输入,但是整体还是在运行的(我可以清晰地看到脚本计时器在连续读秒)。
但是我显然等不到让他执行完了,时间实在是太长了。让我们简单地计算一下,在执行完后,history对象的length应该是多少?答案是大于等于100002(命令行打开/直接打开等的情况下也有可能是100001)。
就这么简单吗?当然不是,length只是元素个数。每个history条目中还保存了url信息,因此,这是一个简单的等差数列的累计。假设1个字符占用2字节(wchar_t),我们的base url是http://192.168.2.113/crashsafari.htm?一共37字节。
不考虑Base url,从i=0起,
到i=9时,数字长度是 1,2,3,4……,10
到i=99时,数字长度是 12,14,16……,190
到i=999时,数字长度是 193,196,199,……,2890
到i=9999时,数字长度是 2894,2898,……,6494
到i=99999时,数字长度是 6499,6504,……,51499
到i=100000时,数字长度是 51505。
看起来不大吗?现在把它们累加起来。同时,把37字节也加进每个条目中,最终我们的到的数字是:
var s = 0;
var t = 0;
for(i = 0; i < 100000; ++i)
{
t += i.toString().length;
s += t;
}
console.log(s);
s == 23939749495
再加上10万条,每条37字节的baseurl,将结果再乘2,得到最终内存占用47880238990字节(当然,这还是偏少的,因为每条History纪录都要维护额外的类信息)。最终计算得到至少需要44GB内存支撑。
虽然44GB看起来很吓人,但是对64位系统来说并不算什么,所以在系统上就一直分配分配,吃着系统内存。而在32位系统上可能就有点问题了,不过我没有32位的Mac,所以暂时没法测试,喊朋友帮在iOS做了测试,结果也是类似:没有崩溃。iOS的内存管理机制提示占用内存过高,只要拒绝再次分配即可终止代码执行。
说了这么多,不如直接试一试Windows上的浏览器——IE和Edge。由于我的测试机器Fuzz过久风扇烧了,只剩下一台Windows 7 32Bit的电脑可以试验,因此这次我先尝试使用IE 11来执行上述代码。看看IE下的表现如何。因为是32位系统,没开任何内存扩展,所以理论上,分配到2GB就差不多了。
在IE11上执行,一段时间后浏览器就出现了未响应的提示。但是内存占用却很少。这是为什么呢?从代码看,IE对历史条目做了限制。数量是500-1209(不同版本的IE/Edge有区别),但是对比Chrome(100)和Safari(50),IE的数量明显要多,但是内存占用却最少,这是为什么呢?看来得再仔细看下它的机制了。
IE中历史操作的接口为IOmHistory(对应类COmHistory),对应F12里面的history对象。可以在MSDN查到它的相关信息:https://msdn.microsoft.com/en-us/library/hh774261(v=vs.85).aspx。
因为今天机器修好了,所以,我就以Edge为样本开始分析吧。jscript9.dll中调用pushState后,传给CFastDOM::CHistory::Trampoline_pushState(PVOID, CallInfo*,PPVOID)去处理。trampoline的代码十分简单,我简单人肉反编译一下,原始代码大致如下(为了避免各种vtguard代码,以下使用的是64位EdgeHTML.dll):
class CFastDOM
{
public:
static PVOID CFastDOM::CHistory::Trampoline_pushState(PVOID pv, CallInfo* pci,PPVOID ppv)
{
CBase* pBase = m_base;
IActiveScriptDirect * pIASD = ValidateCallT<0>(pv, ppv, pci, 1, 0x10B2, &pBase);
DWORD dwResult = COmHistory::SetStateHelper(pIASD, ppv, pci & 0xffffff, 1);
if(!dwResult)
{
CFastDOM::ThrowDOMError(pIASD, pci, dwResult, pBase);
}
return 0;
}
}
反观这段代码,唯一“有效”(我们能看得见)的操作就是SetStateHelper了。这个函数里面,如果一切正常,Edge会更新窗口数据,为文档添加浏览记录(CDoc::AddTravelEntery(pWindow, TRUE);
)、更新后退状态(CDoc::UpdateBackForwardState(TRUE);
)并更新了历史记录管理器中的URL信息(HRESULT hr = COmHistory::UpdateUrl(pWindow, (OLECHAR*)&pszNewURL);
)。
但是事实确实如此吗?显然不是,当传入的URL大于INTERNET_MAX_PATH_LENGTH (2048)字节时,COmHistory::PrepareUrlForUpdate
调用的MSHTML!CMarkup::ExpandUrl
中拼接URL的API CoInternetCombineUrl会返回0x80004003。
#define INTERNET_MAX_HOST_NAME_LENGTH 256
#define INTERNET_MAX_USER_NAME_LENGTH 128
#define INTERNET_MAX_PASSWORD_LENGTH 128
#define INTERNET_MAX_PORT_NUMBER_LENGTH 5 // INTERNET_PORT is unsigned short
#define INTERNET_MAX_PORT_NUMBER_VALUE 65535 // maximum unsigned short value
#define INTERNET_MAX_PATH_LENGTH 2048
#define INTERNET_MAX_PROTOCOL_NAME "gopher" // longest protocol name
#define INTERNET_MAX_URL_LENGTH ((sizeof(INTERNET_MAX_PROTOCOL_NAME) - 1) \
+ sizeof("://") \
+ INTERNET_MAX_PATH_LENGTH)
0x80004003的含义是:E_POINTER | pwzResult is NULL, or the buffer is too small.
但这是为什么呢,这个API只是拼接URL而已,为什么会管URL长度?回头看MSHTML!CMarkup::ExpandUrl
调用该API时的代码,传入的BUFFER只有2048字节呀。
hr = CoInternetCombineUrl(pwzBaseUrl, pwzRelativeUrl, dwCombineFlags, pszResult, INTERNET_MAX_PATH_LENGTH, &pcchResult, 0);
所以后续插入、更新操作都没有了,顺便还调用了ThrowDOMError,导致后面的代码都无法执行了(在IE/Edge中的体现是SCRIPT16387:无效指针,就是0x80004003的解释)。这也就能解释为什么Edge为啥如此“高效”了。所以Edge实际上做的是:
var s = 0;
var t = 0;
for(i = 0; i < 100000; ++i)
{
t += i.toString().length;
**if(t > 2048) break;**
s += t;
}
console.log(s);
占用702425字。Chrome和Safari的我之后有空再看吧。