cxxjackie 发表于 2021-10-7 10:49:42

[逆向分析实战]三句话,让腾讯文档为我放下戒备

本帖最后由 cxxjackie 于 2021-10-7 16:12 编辑

FBI WARNING
由于油猴的一些古怪特性造成预料之外的问题,请临时新建以下脚本来复现文章中的操作:
// ==UserScript==
// @name         教程专用测试脚本
// @namespace    ...
// @author       ...
// @version      1.0
// @include      https://docs.qq.com/doc/DQ1BMTk1BWlVWcndF
// @grant      unsafeWindow
// @run-at       document-start
// ==/UserScript==
document;


其实大部分腾讯文档是没有限制的,只有作者设置了复制权限才会无法复制,本文将以这篇文档为例进行实战。腾讯文档的防复制手段与道客巴巴很相似,都是将文字绘制在canvas里(详情请看上文)。首先从事件监听入手,发现页面中存在copy事件,这可方便了不少(也可以从click事件或按键事件入手,但是事件很多分析起来要麻烦亿点)。copy事件逐个点进去看一眼,发现这样一段比较有趣的代码:

翻译一下就是判断然后执行a.onCopyByEvent(t),而a.onCopyByEvent就在这个函数的下面,继续往下看,可以大致看出这是复制函数的相关逻辑,开头用if判断了一下,我们在if这里打个断点,然后选一段文字ctrl c,成功断了下来。由于if条件内包含函数,这里要按F10继续(step over,调试图标第二个,逐行运行代码,但不进入子函数),结果if内的代码被跳过,说明条件不成立。观察if的条件,发现a.context.isCopyable()很可疑,可以把鼠标放上去跟进去看,也可以在控制台中输入a.context.isCopyable查看,函数内容很简单,就一句话:
return this._docEnv.copyable;由于isCopyable是a.context的属性,所以这里的this指的是a.context,即a.context.isCopyable函数读取的是a.context._docEnv.copyable的值。控制台查看一下这个值,发现是false,把他改成true看看,取消断点,再次ctrl c复制,结果成功了。现在的问题是怎么得到这个copyable的路径,我们当然不可能在代码中直接写a.context._docEnv.copyable,因为这个a是打了断点以后获得的局部引用,在全局环境中是没有a这个东西的。这就要祭出我做的这个脚本了(自卖自夸),注入函数,然后在全局环境中搜索copyable这个属性:

只找到一条路径,要验证路径是否正确,可以再执行一次断点,然后控制台判断一下:
a.context._docEnv === window['App']['CollabRoom']['collabRoomOptions']['notificationCenter']['_docEnv']结果为true,说明路径是正确的。这个判断的原理是copyable是前面那个对象的属性,我们只要证明两个引用属于同一个对象,那么改动其中一个必然会影响另一个。其实不是每个属性都能找到全局路径,这里算是比较取巧的做法,找不到的话就只能慢慢去跟代码逻辑了。接下来可以刷新页面,验证一下路径有没有变,控制台输入:
window['App']['CollabRoom']['collabRoomOptions']['notificationCenter']['_docEnv']['copyable'] = true;ctrl c复制一下看看,发现成功了。至此我们已经可以写出一个脚本,用这一行代码来解决问题。接下来进行一些优化操作,当我们按ctrl c时,虽然能复制成功,但还是会弹出权限被限制的提示,点击调试工具右上角的3个点,选择search,在所有js中搜索提示文字:文档作者限制了(后面可以省略),找到一堆乱七八糟的东西,逐个点开看看,可以发现大部分都只是一堆字符串,只有public-doc-pc开头的这个文件比较可疑:

显然p.qO.getInstance().canCopy决定了是否出现提示,这里打个断点,ctrl c后断下来,控制台输入p.qO.getInstance().canCopy = true,继续运行,结果发现并没有效果。这种情况往往是因为该属性通过Object.defineProperty修改了getter造成的,即我们访问这个属性时,实际上访问的是别的东西。要验证这一点,可以断点后在控制台输入p.qO.getInstance(),展开这个对象看看,发现canCopy这个属性是灰色的,这就是通过getter访问的属性。怎么查看他的getter呢?有一种简单的方法:

与前面相似,可以得出读取的是p.qO.getInstance().privilege.canCopy的值(isPublish是undefined),这次我们输入p.qO.getInstance().privilege.canCopy = true看看,结果成功屏蔽了提示。接下来的流程就跟前面一样,$searchKey('canCopy'),找到2个路径,验证一下可以得出window['pad']['permissionCtrl']['privilege']['canCopy']是正确的,然后在脚本中加入这行代码就行了:
window['pad']['permissionCtrl']['privilege']['canCopy'] = true;最后是解锁右键的复制,可以看到右键菜单中的复制是灰色的,一个简单的思路是监听节点插入(即监听右键菜单出现),获取元素后修改css,把复制这一项改成黑色,然后给他加一个click事件,模拟ctrl c按键即可。但是我们前面既然都能通过修改全局属性来达到目的,这里能不能也这么做呢?答案是可以的。
我们先定位到这个复制元素,查看他的click事件,可以看到一大堆,其实这些事件大部分是重复的,只是绑定在不同节点上,逐个点进去看一眼,可以找到这段代码:

从代码上可以推测出,t.props.disabled判断元素是否禁用,从而决定后续代码是否执行。虽然可以通过修改disabled的值来验证我们的想法,但由于这个事件绑定的节点非常多,每次t都不一样,调试起来比较困难。我们可以试着$searchKey('disabled')看看,找到6个结果,除了两个jquery的明显不对,其他4个是非常明显的React路径(实际上不止4个,因为我脚本设置了最大搜索深度,而React路径通常非常长),一堆return、child、stateNode,以及最关键的__reactInternalInstance这个字段。为了验证我们的想法,可以打个断点,鼠标放到t上看看,可以看到context、props、state、updater等属性,这就是典型的React对象,说明这些元素是用React库构建的。这里要用到一个chrome插件:React Developer Tools(链接就不提供了,请自行搜索下载),可以调试React,安装后会在调试工具处出现一个React标签。定位到右键菜单的元素后切换至React标签,可以看到这些东西:

注意这里我选中了copy组件,在右边的Props中出现了disabled: true这一项,前面还有个勾,去掉勾试试,disabled变成了false,右键菜单中的复制变成了黑色!点击一下,成功复制到了文字(如果你刷新过页面,别忘了前面的2行代码)。这个disabled要在脚本中修改是比较复杂的,首先copy不是页面中实际存在的节点,但他的上一级组件是(可以看到ul标签,className属性),在ul组件上右键选择"Find the DOM node"会跳到页面中的节点,此时切换到控制台,输入$0.__reactInternalInstance,后面还有一串像乱码一样的东西会自动补齐。在控制台中$0是我们当前选中的节点(这也是一个调试小技巧),__reactInternalInstance是React注入的,后面那一串东西是标识符,每次刷新页面都会变。展开这个对象,由于我们要找的copy是他的子组件,要从child里找,展开child后可以看到key是cut,这是第一个组件,我们要找的copy是第二个,路径就是child.sibling(sibling代表下一个),展开sibling后看到key是copy,他的props位于stateNode中,所以完整路径就是$0.__reactInternalInstance$标识符.child.sibling.stateNode.props.disabled(我知道这有点绕,但React就这尿性)。我们姑且用一个sn来存储stateNode,将disabled修改为false:
const sn = $0.__reactInternalInstance$标识符.child.sibling.stateNode;
sn.props.disabled = false;此时点击复制已经有效果了,但复制还是灰色的,这是因为组件没有更新,再加一行代码即可:
sn.updater.enqueueForceUpdate(sn);由于每次右键菜单都会重新生成,调试起来比较麻烦。要在脚本中实现,具体步骤就是监听节点插入,获取右键菜单(即$0),然后遍历这个元素的属性,找到__reactInternalInstance开头的属性(用正则或者startsWith),之后的代码就跟上面一样了。嗯,听上去不比自己绑一个click事件简单多少(这其实是在讲怎么处理React),好吧,让我们回归初心。我们已经知道这个组件的key是"copy",那么在设置disabled的地方修改判断逻辑不就行了吗?
在所有文件中搜索copy,找到一大堆,这是当然的,页面中的copy事件也在搜索范围内,还有其他一些奇奇怪怪的copy,分析起来很麻烦。换个思路,切换到React观察一下,可以看到右键菜单中还有一个“插入链接”,他的key是"insert_link",这个应该不常见,搜索一下看看,只有2个结果(注意带:formatted的是我们点击了格式化后产生的副本),点开第一个按ctrl f,点一下右边的.*,搜索copy.*disabled(这是用正则搜索的意思,搜索所有文件也可以正则,但是会很慢),没有结果,搜索第二个,找到了这样一个有趣的东西:

这里显然是设置disabled的地方,不过代码经过混淆,读起来比较费劲,我可以稍微整理一下,让他看起来可读性好一点:
la([
    (0, ci.v)("cut", "shouldDisabled"),
    (0, sa.x)("cut", "shouldDisabled"),
    da(0, (0, ji.M)()),
    pa("design:type", Function),
    pa("design:paramtypes", ["function" == typeof (t = void 0 !== Fe.z && Fe.z) ? t : Object]),
    pa("design:returntype", void 0)
], e.prototype, "cannotCut", null),
la([
    (0, ci.v)("copy", "shouldDisabled"),
    (0, sa.x)("copy", "shouldDisabled"),
    da(0, (0, ji.M)()),
    pa("design:type", Function),
    pa("design:paramtypes", ["function" == typeof (n = void 0 !== Fe.z && Fe.z) ? n : Object]),
    pa("design:returntype", void 0)
], e.prototype, "cannotCopy", null),
对于这种混淆过的代码,不必去纠结每一步的含义,可以从前后文推敲。比较上下两段代码的差异,区别在于最后的"cannotCut"和"cannotCopy",猜测这就是设置disabled的关键。这次我们搜索cannotCopy,只找到一个文件(就是这段代码所在的文件),点开找到cannotCopy的定义处,看到有一个notCopyable函数,这里我们可以打个断点,右键一下试试能不能断下来,成功断下来后跟进这个notCopyable函数看看:

可以看出e.permission.isCopyable是关键属性,在return前打个断点,运行到这后鼠标放上去,看到是false,控制台改成true试试,结果复制还是灰的,点击也没效果。这并不是说我们的方向错了,还记得前面说过的getter吗?鼠标放到e.permission上看看,发现isCopyable属性是灰的,控制台输入:
e.permission.__lookupGetter__('isCopyable')这次的函数不太直观,可以点一下跟进去看看:

这里的代码就是判断是否为编辑状态(isEditable)或复制是否被禁用(view_forbid_copy_print),isEditable其实跟isCopyable一样是个判断函数,我们去跟这个有点绕,直接从后面入手。还是在return前打个断点断下来,看一下e.view_forbid_copy_print是1,所以等于1是true,加上前面的!,返回的结果就是false。我们在控制台把e.view_forbid_copy_print改成0试试(这次记得看一眼e确定view_forbid_copy_print属性不是灰的),复制变成了黑色!接下来取消断点,多划几个地方试试,每次右键菜单的复制都是黑色的,而且点击也都能生效,说明改对了地方。接下来就是传统艺能了,搜索view_forbid_copy_print的全局路径,最终确定为window['App']['CollabRoom']['collabRoomOptions']['clientVars']['advPolicy']['view_forbid_copy_print']。至此,给我们的脚本加上第3行,就算完美收工了:
window['App']['CollabRoom']['collabRoomOptions']['clientVars']['advPolicy']['view_forbid_copy_print'] = 0;
结语
虽然本文的废话洋洋洒洒写了一大篇,但最后的代码只有3行,用简单的代码解决复杂的需求,这正是编程的艺术啊~


cxxjackie 发表于 2021-10-7 12:53:51

在shouldDisabled这段代码的分析中,由于截图和整理代码是我在不同时间进行的,文件混淆发生了变化(可能你读到这篇文章的时候又变了),所以出现上下不一致的情况,不过不影响理解,我就不改了。

李恒道 发表于 2021-10-7 12:57:08

大佬copy事件是在哪里找的
我试了一下没看到...

cxxjackie 发表于 2021-10-7 13:29:18

本帖最后由 cxxjackie 于 2021-10-7 13:32 编辑

李恒道 发表于 2021-10-7 12:57
大佬copy事件是在哪里找的
我试了一下没看到...在最顶层的html上就有啊,应该随便哪个元素都能找到的。



是不是装了什么插件把copy干掉了,可能有些通用的解除复制限制的插件或脚本会这么做。

李恒道 发表于 2021-10-7 13:48:23

cxxjackie 发表于 2021-10-7 13:29
在最顶层的html上就有啊,应该随便哪个元素都能找到的。




好像开什么插件了

换个找到了,但是还是跟大佬的不太一样

![图片.png](data/attachment/forum/202110/07/134804kzux51vgyvh0izd0.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/300 "图片.png")

这种情况正常么



![图片.png](data/attachment/forum/202110/07/134817gcyu5vvpa5ih2huv.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/300 "图片.png")

cxxjackie 发表于 2021-10-7 14:02:02

李恒道 发表于 2021-10-7 13:48
好像开什么插件了

换个找到了,但是还是跟大佬的不太一样


不太正常,函数应该在feature-pc开头的文件里,你把插件都关了试试?

李恒道 发表于 2021-10-7 14:12:55

cxxjackie 发表于 2021-10-7 14:02
不太正常,函数应该在feature-pc开头的文件里,你把插件都关了试试?

全关了...始终搞不出来
等我晚上换个电脑试一下
邪了

cxxjackie 发表于 2021-10-7 14:41:03

李恒道 发表于 2021-10-7 14:12
全关了...始终搞不出来
等我晚上换个电脑试一下
邪了

研究了一下,是我的一个脚本引起的,原因似乎是在document-start阶段读取了一下document,结果就出现了这个非常奇怪的现象,这难道是油猴的某些“特性”?有点难以理解,我再折腾一下,你可以先用这种方法试试。

李恒道 发表于 2021-10-7 14:58:49

cxxjackie 发表于 2021-10-7 14:41
研究了一下,是我的一个脚本引起的,原因似乎是在document-start阶段读取了一下document,结果就出现了这 ...

具体是document-start的时候操作了什么
大佬你的那个监听器是因为脚本误触搞出来了的
感觉好奇怪啊...

cxxjackie 发表于 2021-10-7 15:16:33

李恒道 发表于 2021-10-7 14:58
具体是document-start的时候操作了什么
大佬你的那个监听器是因为脚本误触搞出来了的
感觉好奇怪啊... ...

console.log(document)就可以触发,我debugger跟了一下油猴的代码,他在读取document的时候会执行一些自己的逻辑,劫持addEventListener等等,可能是document-start阶段使这些逻辑被过早的执行,从而导致了这个有点奇怪的问题出现。
页: [1] 2 3 4
查看完整版本: [逆向分析实战]三句话,让腾讯文档为我放下戒备