某两个Zotero插件激活的分析
某两个Zotero插件激活的分析
由于Zotero是基于Firefox的,且分析过程为纯js分析,不涉及native层功能,故发布于Web逆向。
基础
经搜索,Zotero 8在高级-设置编辑器-devtools.debugger.remote-enabled设为true后,以zotero.exe -jsdebugger启动后,可打开完整的devtool。但由于开启时需要对话框同意调试,暂未明确如何实现在页面加载时下断点,故本文多采用静态分析。
两个插件的原始文件:https://flt.lanzouv.com/iVsAA3ief4je
插件1
在Zotero未登录时加载插件后,点击设置时会弹窗请点击同步,登录Zotero账户后继续激活。。devtool搜索字符串得到

显然字符串常量被抽象出去了,核心逻辑如下,简单分析了一下
function ret_str_const() {
var str_consts = [
// ...
];
ret_str_const = function () {
return str_consts;
};
return ret_str_const();
}
function _0x1e9c(p1, p2) {
var str_consts = ret_str_const();
return _0x1e9c = function (query, _) {
query = query - 332;
var _0x46a071 = str_consts[query];
return _0x46a071;
},
_0x1e9c(p1, p2);
}(
function (_0x1d2697, _0x383be7) {
var _0x586549 = {
_0x18b563: 2886,
_0x493728: 1459,
_0x1f126d: 6305,
_0x39b065: 845,
_0x4c2401: 2893
},
_0x145a44 = _0x1e9c,
_0x4f5461 = _0x1d2697();
while (!![]) {
try {
var _0x59db2c = parseInt(_0x145a44(_0x586549._0x18b563)) / 1 + parseInt(_0x145a44(3962)) / 2 * ( - parseInt(_0x145a44(_0x586549._0x493728)) / 3) + - parseInt(_0x145a44(7417)) / 4 * ( - parseInt(_0x145a44(_0x586549._0x1f126d)) / 5) + parseInt(_0x145a44(1588)) / 6 * ( - parseInt(_0x145a44(3364)) / 7) + - parseInt(_0x145a44(_0x586549._0x39b065)) / 8 + - parseInt(_0x145a44(_0x586549._0x4c2401)) / 9 * (parseInt(_0x145a44(3379)) / 10) + - parseInt(_0x145a44(6324)) / 11 * (parseInt(_0x145a44(4084)) / 12);
if (_0x59db2c === _0x383be7) break;
else _0x4f5461['push'](_0x4f5461['shift']());
} catch (_0x10544a) {
_0x4f5461['push'](_0x4f5461['shift']());
}
}
}(ret_str_const, 458581)
);
是一个重排算法,有两个方向,一个是分析算法确认上述字符串对应编号。另一种是在devtool直接枚举找到对应编号。观察算法后,注意到8118行函数第二个参数是废的,外_0x1e9c是包装器最终会将内部的_0x1e9c暴露出来,观察后续用法确认query是整数。直接从332开始暴力枚举找到目标字符串index

后来用AI逆向了算法,结论一致,均为3524
全局搜索3524得(在13行):
async function _0x1aa450() {
var _0x514d8e = {
_0x1898c6: 2723,
_0x27ba1e: 5052,
_0x414762: 6774
},
// ...
_0x4e2b42 = _0x5e6bba;
if (
addon[_0x4e2b42(_0x43deb7._0x4ec66d)][_0x4e2b42(_0x43deb7._0x3e8517)]?.[_0x4e2b42(_0x43deb7._0x2e0e9a)] == void 0
) return;
if (Zotero['Users'][_0x4e2b42(5841)]() == '') {
window[_0x4e2b42(_0x43deb7._0xf7901a)](_0x4e2b42(3524)); // >>>>> 3524在这里 <<<<
return;
}
let _0xd54ebb = ![]; // >>>> _0xd54ebb -> isActivated <<<<
try {
await _0x228894(0) &&
(_0xd54ebb = !![]);
} catch (_0x1c8e7e) {
ztoolkit[_0x4e2b42(_0x43deb7._0x392c7f)](_0x1c8e7e),
window[_0x4e2b42(6774)](_0x4e2b42(6444) + JSON[_0x4e2b42(8157)](_0x1c8e7e));
}
// ...
if (_0xd54ebb) {
_0x1d85b2(_0x4e2b42(4864)) [_0x4e2b42(_0x43deb7._0x311b83)][_0x4e2b42(_0x43deb7._0x41c421)] = '',
_0x1d85b2(_0x4e2b42(_0x43deb7._0x3c003d)) [_0x4e2b42(_0x43deb7._0x1096c2)] = _0x4e2b42(4626) + Zotero['Users'][_0x4e2b42(7926)]();
const _0x1becfd = await _0x3cae33();
if (_0x1becfd != '0') {
const _0x3b37b3 = _0x1becfd['split'](/,\s*/);
_0x1d85b2(_0x4e2b42(_0x43deb7._0x2b3d32)) [_0x4e2b42(_0x43deb7._0x1096c2)] = '🎉 您已邀请 ' + _0x3b37b3[_0x4e2b42(_0x43deb7._0x362a35)] + _0x4e2b42(7433) + _0x3b37b3['slice']( - 1) [0];
} else _0x1d85b2(_0x4e2b42(_0x43deb7._0x2b3d32)) ['innerHTML'] = _0xdba85b([_0x4e2b42(_0x43deb7._0x26dfbf)]);
_0x1d85b2('#zotero-magic-not-activate') [_0x4e2b42(_0x43deb7._0x495d13)][_0x4e2b42(_0x43deb7._0x4fa9ac)] = _0x4e2b42(773),
_0x1d85b2(_0x4e2b42(_0x43deb7._0x401d80)) [_0x4e2b42(_0x43deb7._0x227240)]['display'] = _0x4e2b42(773),
_0x1d85b2(_0x4e2b42(_0x43deb7._0x26849c)) ['style'][_0x4e2b42(7534)] = _0x4e2b42(_0x43deb7._0x589e50),
// ...
}
}
注意到31行出现“邀请”字样,与当前界面对比,推测其为激活后界面。

向上找,注意到关键变量_0xd54ebb即判定是否激活,尝试直接修改文件patch成恒true,成功显示激活界面,但功能仍旧无法使用,显然后续在某处验证了。
注意到正常流程下,18行处,当_0x228894(0)成功返回时会将_0xd54ebb改为true,找到该函数定义:
async function _0x228894(_0x1e3a7d) {
var _0x566b28 = _0x5e6bba;
let _0x28dc67 = Zotero['Prefs'][_0x566b28(_0x289548._0xaf5552)](_0x6b1c85);
_0x28dc67 &&
(_0x28dc67 = _0x1b397f(_0x28dc67));
let _0x4e26c7 = String(Zotero[_0x566b28(3049)]['getCurrentUserID']()),
_0x3b7f78 = _0x28dc67 ||
await _0x516b66(_0x4e26c7);
const _0x44fdd6 = Date[_0x566b28(_0x289548._0x40b441)]() / 1000 - Number(_0xd08cbe(_0x3b7f78));
_0x44fdd6 > _0x1e3a7d * 60 * 60 * 24 &&
(_0x3b7f78 = await _0x516b66(_0x4e26c7));
if (_0x3b7f78[_0x566b28(_0x289548._0x52e883)] == 0) return ![];
const _0x24c725 = _0xd08cbe(_0x3b7f78);
return _0x43ebed(_0x4e26c7, _0x24c725) ? (
Zotero[_0x566b28(_0x289548._0x18ddb6)][_0x566b28(_0x289548._0x38b095)](_0x6b1c85, _0x5b0a2d(_0x3b7f78)),
!![]
) : (
Zotero[_0x566b28(1625)][_0x566b28(_0x289548._0x9ed18d)](_0x6b1c85),
![]
);
}
经过进一步分析后整理得
// Line 19541
_0x289548 = {
_0xaf5552: 2800,
_0x40b441: 3024,
_0x52e883: 3616,
_0x18ddb6: 1625,
_0x38b095: 2562,
_0x9ed18d: 6499
},
// function name map:
// decrypt_from_AES: _0x1b397f
// checkUserStatus: _0x516b66
// extract_timestamp: _0xd08cbe
async function _0x228894(v1) {
var map_str = map_str;
let v2 = Zotero['Prefs']["get"](v0); // 进一步逆向得v0='thirdPartyCache'
v2 &&
(v2 = decrypt_from_AES(v2));
let currentId = String(Zotero["Users"]['getCurrentUserID']()),
v3 = v2 ||
await checkUserStatus(currentId);
const v4 = Date["now"]() / 1000 - Number(extract_timestamp(v3));
v4 > v1 * 60 * 60 * 24 &&
(v3 = await checkUserStatus(currentId));
if (v3["length"] == 0) return false;
const key = extract_timestamp(v3);
return _0x43ebed(currentId, key) ? (
Zotero["Prefs"]["set"](v0, encrypt_AES(v3)),
true
) : (
Zotero["Prefs"]["clear"](v0),
false
);
}
事实上为了搞清楚这个函数具体在做什么,尝试做注册机,浪费了大量时间,最终证明插件验证key的使用的是API,试用超时判定也是API
直接爆破让函数返回true验证成功。
插件2
考虑到插件2和插件1是同一作者,观察使用的混淆也很像,推测关于注册判定的逻辑很有可能相似。

注意到插件1缓存有效期判定* 60 * 60 * 24在该插件代码中全局唯一,直接在插件2中搜索60 * 60 * 24,成功定位激活判定函数

(函数头部的return true;是patch代码)
插件1 - 杂谈
尝试设计注册机时,从_0x6b1c85即后续v0入手找,注意到
_0x6b1c85 = _0x1b397f(_0x5e6bba(3741)),
_0xb7602a = _0x1b397f('U2FsdGVkX1/GC5KPPy5tJ3yCqMN/dL/V6XIvZOAGbnE=');
base64解码U2FsdGVkX1/GC5KPPy5tJ3yCqMN/dL/V6XIvZOAGbnE=得Salted__���?.m'|���t���r/d�nq,解码_0x5e6bba(3741)得到类似内容。
一开始看到scalted怀疑hash加盐,问AI得知是对等加密数据。进一步翻_0x1b397f得
function _0x1b397f(_0x52bc40) {
var _0x4b26eb = _0x5e6bba;
const _0x4c707a = _0x3cf22c['AES'][_0x4b26eb(_0x9ebddc._0xdf0de2)](_0x52bc40, _0x4b26eb(8240)),
_0x542763 = _0x4c707a['toString'](_0x3cf22c[_0x4b26eb(7862)][_0x4b26eb(_0x9ebddc._0x127ca0)]);
return _0x542763;
}
注意到AES关键字,_0x4b26eb(_0x9ebddc._0xdf0de2)即decrypt,而_0x52bc40为输入,推测_0x4b26eb(8240)为key。注意到整个脚本下方有
/*! Bundled license information:
crypto-js/ripemd160.js:
(** @preserve
(c) 2012 by Cédric Mesnil. All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*)
推测使用crypto-js加密解密,问AI得知特征符合,python实现后带入U2FsdGVkX1/GC5KPPy5tJ3yCqMN/dL/V6XIvZOAGbnE=验证成功解密。
同理确认encrypt_AES和decrypt_from_AES含义。
关于extract_timestamp和Zotero['Prefs']["get"]('thirdPartyCache')的具体含义,点击试用后提取该变量用上述方式解密得
1****81 1770952682.4954
第一个数是我的用户ID打码处理,第二个数一开始以为是时间戳,将_0x43ebed找出来才发现小数部分是验证。依旧展开字符串后
function _0x43ebed(_0x1e08a5, _0x3ab625) {
var map_str = _0x5e6bba;
const [_0xf24629,
_0x14944c] = String(_0x3ab625) ['split']('.'),
_0x2984b8 = parseInt(_0xf24629['slice']( - 2) [0]) % _0x1e08a5['length'],
_0x556994 = parseInt(_0xf24629['slice']( - 1) [0]) % _0x1e08a5['length'],
_0x42807c = _0x1e08a5['charCodeAt'](_0x2984b8),
_0x2fba4e = _0x1e08a5['charCodeAt'](_0x556994);
return String(_0x42807c) + String(_0x2fba4e) === _0x14944c;
}
function _0x333a4d(_0x2d17f8) {
var _0x39594e = _0x5e6bba;
const _0x25c48b = String(
Math[_0x39594e(_0x721a82._0xe5de29)](Date[_0x39594e(_0x721a82._0x2d3029)]() / 1000)
),
_0x38292d = parseInt(_0x25c48b[_0x39594e(1699)]( - 2) [0]) % _0x2d17f8[_0x39594e(3616)],
_0x2157f2 = parseInt(_0x25c48b['slice']( - 1) [0]) % _0x2d17f8[_0x39594e(_0x721a82._0x207af9)],
_0x99931a = _0x2d17f8[_0x39594e(5580)](_0x38292d),
_0xfe5d90 = _0x2d17f8[_0x39594e(_0x721a82._0x2d34c5)](_0x2157f2);
return _0x25c48b + '.' + String(_0x99931a) + String(_0xfe5d90);
}
这里偷懒让AI解释逻辑
function validateStringByNumber(str, input) {
// input 形如: "12345.99100"
const [numberPart, targetPart] = String(input).split(".");
// 取倒数第二位数字
const secondLastDigit = parseInt(numberPart.slice(-2)[0], 10) % str.length;
// 取倒数第一位数字
const lastDigit = parseInt(numberPart.slice(-1)[0], 10) % str.length;
// 取字符串中对应位置的字符 ASCII 码
const charCode1 = str.charCodeAt(secondLastDigit);
const charCode2 = str.charCodeAt(lastDigit);
// 拼接两个 ASCII 码,判断是否等于小数点后部分
return String(charCode1) + String(charCode2) === targetPart;
}
验证上述1****81 1770952682.4954符合规则。进一步判断修改时间戳并按照上述逻辑设好验证可以屏蔽用户key是否有效的检验,但已经可以爆破了,这个没有意义。
关于checkUserStatus是API,无法修改,验证key也是API,无法修改。