我们的 AI 引擎 Neutron 在加州大学伯克利分校的 CyberGym 基准测试中取得了 96.75% 的成绩。 了解更多

安全

安全

新冠肺炎接触者追踪应用 Wiqaytna 移动应用安全评估

对新冠肺炎接触者追踪应用 Wiqaytna 进行的移动应用安全测试

概要

本系列文章是我们与 RandoriSec 合作开展的接触者追踪应用评估。第一篇评估的是摩洛哥的 Wiqaytna 移动应用,涵盖 iOS 和 Android 两个版本。评估将探讨该应用的内部工作原理以及接触者追踪协议,并从流程透明度、安全性、隐私、追踪能力和无障碍性等方面对其进行审查。

背景

新冠肺炎(COVID-19)疫情是我们这个时代最具标志性的全球卫生危机,很可能也是这十年中最大的挑战。自去年年底在亚洲出现以来,该病毒已蔓延到几乎每一个大洲。非洲、美洲和欧洲的病例每天都在增加。

不幸的是,这场疫情已经夺去了数十万人的生命,导致许多国家全面封锁,迫使企业停业乃至破产,并使许多人滞留在异国他乡、无法回家,其中就包括本文的一位作者。

在缺乏有效疗法或疫苗的情况下,各国正竞相通过检测和治疗患者、限制出行、隔离公民以及取消体育赛事、音乐会和学校等大型聚集活动来减缓病毒传播。

技术也被用于抗击 COVID-19,借助的是全球 45 亿部移动电话。利用一个人的手机追踪其行动轨迹,以找出可能被其感染的人,这一想法获得了广泛关注;如今,大多数国家都已推出了实现接触者追踪的移动应用。

那么,什么是接触者追踪?简单来说,就是记录您可能接触过的所有人。如果发现某人被感染,其他人就会收到提醒,以便接受检测或隔离。

黄金标准

在评估任何接触者追踪应用之前,首先有必要就这些应用必须满足的一组期望或标准达成共识。

据我们所知,目前还没有任何地方制定过这样的标准。一些组织谈论隐私,另一些组织讨论追踪协议的有效性及其所提供的保障,但并没有可供我们依据的规范或指南。

因此,我们不得不根据所收集的一系列资料以及我们的最佳常识,拟定自己的期望。

这些要求是高层次的,并且有意与具体实现无关:

  • 透明度
    • 追踪协议有文档说明
    • 应用的架构和设计有文档说明
    • 应用设有处理隐私和安全问题的计划(漏洞赏金)
    • 应用开源,便于审查和社区审核
  • 隐私
    • 应用不收集个人身份信息(PII)
    • 应用不跟踪用户,也不收集位置数据
    • 应用在设备上只使用有限的权限,主要限于蓝牙发现和互联网访问
    • 应用让用户能够控制其数据的清除和分享
  • 安全
    • 应用保持较小的攻击面,没有广告、支付或其他无关功能
    • 应用使用经过审查且保持最新的第三方依赖
    • 应用通过安全的加密信道进行通信
    • 应用是安全的,不会向任何一方泄露任何追踪信息
  • 追踪有效性
    • 追踪协议不允许跟踪个人
    • 分享接触信息需由用户主动选择加入
    • 追踪不分享/收集位置数据或 PII 数据
    • 追踪不允许反向追溯感染者
    • 追踪检查不是集中式的,并由用户控制
    • 即使应用在后台运行或屏幕关闭,追踪在 Android 和 iOS 上也能正常工作
  • 无障碍性
    • 应用提供多种语言,包括面向非本地用户的语言
    • 非居民(因旅行禁令滞留在该国的人)也可以使用应用
    • 残障人士(色盲、视力障碍等)可以使用应用
    • 低端手机可以使用应用(应用大小、电池消耗、低带宽等)

方法论

我们的测试方法首先是汇编一份庞大的接触者追踪应用清单,优先关注政府应用,并按下载量对应用进行优先级排序。

由于部分应用需要居民信息,例如护照号码、本地电话号码,我们将重点放在了能够测试的应用上。

每款应用首先使用 Ostorlab Security Scanner 扫描安全漏洞,然后在 Android 和 iOS 上进行人工审查。每款应用都从 5 个方面进行了审查(透明度、隐私、安全、追踪有效性和无障碍性)。

由于应用数量众多,完整的评估工作仍在进行中。

太长不看版(TL;DR)

总体发现并不令人满意。多款应用实现了自定义的追踪协议,持续分享蓝牙和位置数据,要求提供 PII,攻击面过大而导致漏洞或滥用;有些应用通过明文通信,而且大多数应用没有任何透明度流程或文档。

下一节将深入分析一款使用 OpenTrace 协议的应用。我们的目的不是针对某一款特定应用,而是分享我们技术方法的一个示例。

关于 Wiqaytna 应用:

总体而言,该应用不存在严重漏洞,它注重隐私,而且团队做足了功课,确保应用适应摩洛哥市场的特点。

  • 在追踪协议的选择上注重隐私
  • 不收集个人身份信息(PII)或位置数据
  • 实现和技术栈的选择合理
  • 切实努力让流程透明并有文档说明

问题汇总:

问题 类别 严重程度
当应用不在前台时,iOS 版不收集临时 ID 接触者追踪 高危
即使超过有效期,仍在使用同一个临时 ID 接触者追踪 / 隐私 高危
缺少单元测试 代码质量 / 安全 高危
iOS 版存在有漏洞的依赖 安全 中危
PIN 校验可被暴力破解 安全 中危
蓝牙服务必须唯一,但它使用的却是新加坡 TraceTogther 的测试 UUID 接触者追踪 低危
缺少开源的服务器端实现 透明度 低危
语言支持仅限 2 种语言,应扩展到其他官方语言和外语 无障碍性 低危
使用了已弃用的 IPC 机制 代码质量 / 安全 低危


Wiqaytna 应用评估结果
移动应用安全测试

深入分析:Wiqaytna 应用

透明度

这款摩洛哥应用是我们所列应用中为数不多的开源应用之一。除后端源代码只是指向 OpenTrace 代码仓库之外,大部分源代码都可以在其 Github 代码仓库中获取。 对已编译应用的分析表明,编译版本确实与开源版本一致。

Wiqaytna 的 GitHub 代码仓库
移动应用安全测试

该应用依赖 OpenTrace 协议及其实现来进行接触者追踪。该协议有完善的文档,并被其他多款应用采用,例如新加坡和奥地利的应用。

不过,该协议曾出现过若干问题,并且在 iOS 上存在一些追踪方面的局限。

OpenTrace 协议
移动应用安全测试

然而,这款摩洛哥应用并没有以库的形式引入 OpenTrace 实现,而是将其代码直接嵌入应用之中。这带来了偏离标准实现的风险,也使添加修复和更新变得更加困难。Wiqaytna 团队需要密切关注代码仓库中的变更和更新,以确保所有相关变更都被纳入。

OpenTrace 是已发布的多种追踪协议之一,TCN、Robert 以及 Apple 和 Google 的追踪方案是其他实现。一些应用选择了使用自己的实现,从而导致了严重的隐私问题。

各新冠应用所用的追踪协议
新冠移动应用所用的协议
来源:https://en.wikipedia.org/wiki/COVID-19_apps

虽然这些协议都使用临时 ID 来防止跟踪,但主要分歧在于追踪判定过程,即判断某人是否与另一名感染者有过接触。

例如,OpenTrace 采用集中式的追踪实现,而 TCN 以及 Apple 和 Google 的实现则是本地式的。

后者——本地式——虽然提供了更多的隐私保障,但在带宽和电池消耗方面存在实现上的挑战。

Wiqaytna 团队选择该协议的理由已发表在一份白皮书中:

Wiqaytna 白皮书
移动应用安全测试

白皮书涉及应用的多个方面,包括测试流程,然而我们在代码仓库中没有发现任何单元测试或集成测试。团队还添加了一份用于报告安全问题的安全策略。

Wiqaytna 的安全策略
移动应用安全测试

安全、隐私与接触者追踪

评估一款移动应用的安全性是一个宏大的话题。出于本文的目的,我们只关注最有意思的部分。应用的多个方面已通过 Ostorlab 漏洞分析进行了自动审查。

该移动应用的攻击面较小。相比之下,我们评估过的其他几款接触者追踪应用添加了更多功能,例如二维码,引入了支付和广告库,暴露了 URL scheme、Content Provider 和 Javascript 接口。

第三方依赖

应用中添加的依赖非常少,然而在审计时,iOS 应用中的一个依赖存在一个已知漏洞。该依赖是通过 Frameworks 列表识别出来的,也可以在 Podfile.lock 源代码中得到确认。

Payload/Wiqaytna.app/Frameworks/nanopb.framework/Info.plist:

{
    "BuildMachineOSBuild": "19E287",
    "CFBundleDevelopmentRegion": "en",
    "CFBundleExecutable": "nanopb",
    "CFBundleIdentifier": "org.cocoapods.nanopb",
    "CFBundleInfoDictionaryVersion": "6.0",
    "CFBundleName": "nanopb",
    "CFBundlePackageType": "FMWK",
    "CFBundleShortVersionString": "0.3.9011",
    "CFBundleSignature": "????",
    "CFBundleSupportedPlatforms": [
        "iPhoneOS"
    ],
    "CFBundleVersion": "1",
    "DTCompiler": "com.apple.compilers.llvm.clang.1_0",
    "DTPlatformBuild": "17F65",
    "DTPlatformName": "iphoneos",
    "DTPlatformVersion": "13.5",
    "DTSDKBuild": "17F65",
    "DTSDKName": "iphoneos13.5",
    "DTXcode": "1150",
    "DTXcodeBuild": "11E608c",
    "MinimumOSVersion": "8.0",
    "UIDeviceFamily": [
        1,
        2
    ]
}

该存在漏洞的依赖是来自 Firebase 库的传递依赖。不过,被利用的可能性不大。该漏洞影响 nanopb 的 0.3.9011 版本。CVE-2020-5235 的描述如下:

在 0.4.1、0.3.9.5 和 0.2.9.4 之前的 Nanopb 中,存在一个可能被利用的内存不足情况。当 nanopb 在编译时启用了 PB_ENABLE_MALLOC,待解码的消息包含重复的 string、bytes 或 message 字段,并且 realloc() 在扩展数组时内存耗尽,nanopb 最终可能会对一个来自未初始化内存的指针值调用 free()。视平台而定,这可能导致崩溃或进一步的内存破坏,在某些情况下可能被利用。该问题已在 nanopb-0.4.1、nanopb-0.3.9.5、nanopb-0.2.9.4 中修复。

后端与网络通信

该应用依赖 Firebase 来托管其后端实现、执行身份验证以及上传追踪文件。

通信通过 TLS 进行,并且经过了正确的校验。服务器端实现主要基于 OpenTrace 的开源实现,但其他几个端点,例如统计和更新个人资料(性别、所在省份等),并未开源。

应用使用 Firebase Functions 处理所有 API 调用,使用 Firebase Storage 上传追踪文件,并使用 Firebase config 管理配置。

  • Firebase Functions 上的端点示例
POST /getTempIDs HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Ijc0Mzg3ZGUyMDUxMWNkNDgzYTIwZDIyOGQ5OTI4ZTU0YjNlZTBlMDgiLCJ0eXAiOiJKV1QifQ.eyJwcm92aWRlcl9pZCI6ImFub255bW91cyIsImlzcyI6Imh0dHBzOi8vc2VjdXJldG9rZW4uZ29vZ2xlLmNvbS9vcGVudHJhY2UtYTA2NWMiLCJhdWQiOiJvcGVudHJhY2UtYTA2NWMiLCJhdXRoX3RpbWUiOjE1OTE2NTIyOTUsInVzZXJfaWQiOiJsWGgzSUlNRVpDZ1VkQzF3QU9heVZBeHJSQW0yIiwic3ViIjoibFhoM0lJTUVaQ2dVZEMxd0FPYXlWQXhyUkFtMiIsImlhdCI6MTU5MTY1MjI5NSwiZXhwIjoxNTkxNjU1ODk1LCJmaXJlYmFzZSI6eyJpZGVudGl0aWVzIjp7fSwic2lnbl9pbl9wcm92aWRlciI6ImFub255bW91cyJ9fQ.IVuITUlZH3ch254qhThumeIKNYPaIvnFsj3Bdnp_51J4eKp7HYhy8l5TZAHm9pW51VwC5mEbKrLXbrnmHhSgDjeXnNnAWfUHGRl_5BJUSvsrXIiVhUNT6uWFwq85oRzM95fhUQzP3zA1V-40Rt91-pUTBbq548mrabdFwxHgbW7WlbT2kM75V2al0ZwRTcci5TpOACf_zlNVUcia4L2K1R28vE_EGTPYPYvDPuEa4Bv5zz5GX0gL4PgQJGgStqp-Q4r1o9MuXBdyaxBr8cMNNXXlOT-Doin2Xavhzxy1A73sDbGeA9TkwntwOUwacL6bkQzoeg_SaAU5pQa4SsyOhg
Firebase-Instance-ID-Token: eVusymDJTJWMmm8tG4nQUZ:APA91bHLa36gjJeGnE-ZiTTEnXKwwLE6AOEZa7gps0BKiJuujVDXVG9_onEp6z2QUIsdco8QWQ3jpsxKW2Tbe8-4pI82qGWmXLheRZ1_vChXqJmjt9kCCQUSS9XGaOQwPVnxCfXDyu9T
Content-Type: application/json; charset=utf-8
Content-Length: 13
Host: europe-west1-opentrace-a065c.cloudfunctions.net
Connection: close
Accept-Encoding: gzip, deflate
User-Agent: okhttp/3.12.1

{"data":null}
  • Firebase Storage 上的端点示例
POST /v0/b/opentrace-a065c.appspot.com/o?uploadType=resumable&name=streetPassRecords%2F20200608%2FStreetPassRecord_LGE_Nexus%205_2020-06-08_23-36-01.json HTTP/1.1
Authorization: Firebase eyJhbGciOiJSUzI1NiIsImtpZCI6Ijc0Mzg3ZGUyMDUxMWNkNDgzYTIwZDIyOGQ5OTI4ZTU0YjNlZTBlMDgiLCJ0eXAiOiJKV1QifQ.eyJwcm92aWRlcl9pZCI6ImFub255bW91cyIsImlzcyI6Imh0dHBzOi8vc2VjdXJldG9rZW4uZ29vZ2xlLmNvbS9vcGVudHJhY2UtYTA2NWMiLCJhdWQiOiJvcGVudHJhY2UtYTA2NWMiLCJhdXRoX3RpbWUiOjE1OTE2NTIyOTUsInVzZXJfaWQiOiJsWGgzSUlNRVpDZ1VkQzF3QU9heVZBeHJSQW0yIiwic3ViIjoibFhoM0lJTUVaQ2dVZEMxd0FPYXlWQXhyUkFtMiIsImlhdCI6MTU5MTY3MDIxMiwiZXhwIjoxNTkxNjczODEyLCJmaXJlYmFzZSI6eyJpZGVudGl0aWVzIjp7fSwic2lnbl9pbl9wcm92aWRlciI6ImFub255bW91cyJ9fQ.BFa9yg_l03TeeFuBL8Obo1NYSN80xSpmtcRlA-lFCXlYuS6kX8azYMfrik6G1S0x9PuxmftBxxd7Yn6rsueJtdoTchrF67XDMbqlnCiqtlNJuPItPKFyaeuB3IgY219yZxO4KWlQYmxy5IOL71jKj_RNS7iQpi0G3fG_lGa01CD6xaafBuSC3MmZyu-yeaeY7kNsdZRF7_YHPQerINR6C9sQH0ZZepcwv5onzh5j3sALfvaQVVTL1VyA6gZEAeZzPYPR_c2n5Izkk0VsNcgqKejHRte-Qh5heJ8MZAzRZep6_rUjMhCxv7DdbmBC_gTBvYs8ZXDdhgdUESPAMfDlvA
X-Firebase-Storage-Version: Android/20.15.15 (040308-306758586)
X-Goog-Upload-Header-Content-Type: application/octet-stream
X-Goog-Upload-Protocol: resumable
x-firebase-gmpid: 1:319155529705:android:f102bb0f7be8a8bac2cd78
X-Goog-Upload-Command: start
Content-Length: 0
Content-Type: application/x-www-form-urlencoded
User-Agent: Dalvik/2.1.0 (Linux; U; Android 7.1.2; Nexus 5 Build/NJH47F)
Host: firebasestorage.googleapis.com
Connection: close
Accept-Encoding: gzip, deflate
文件上传

在后端实现中发现的最重要的问题之一影响追踪文件上传功能。上传需要一个由卫生部门提供的 PIN,该 PIN 用于获取上传令牌。

文件上传流程
移动应用安全测试

PIN 校验通过 getUploadToken 方法完成:

/**
 * Get upload token by passing in a secret string as `data`
 */
const getUploadToken = async (uid: string, data: any, context: functions.https.CallableContext) => {
  console.log("getUploadToken:", "uid", uid, "data", data, "ip", context.rawRequest.ip);

  let valid = false;
  if (data) {
    const uploadCodes = await retrieveUploadCodes();
    console.log("getUploadToken:", "obtained ${uploadCodes.length} upload codes");
    valid = uploadCodes.find(x => x === data) !== undefined;
    console.log("getUploadToken:", "data is ${valid ? "valid" : "not valid"} code");
  }
  valid = uploadCodes.find(x => x === data) !== undefined;
    console.log("getUploadToken:", "data is ${valid ? "valid" : "not valid"} code");
  }

  if (valid) {
    const payload = Buffer.from(JSON.stringify(
      {
        uid,
        createdAt: Date.now() / 1000,
        upload: data
      }
    ));
    console.log("getUploadToken:", "uid:", "${uid.substring(0, 8)}***", "createdAt:", formatTimestamp(Date.now() / 1000));

    // Prepare encrypter
    const encryptionKey = await getEncryptionKey();
    const customEncrypter = new CustomEncrypter(encryptionKey);

    // Encode payload
    const payloadData = customEncrypter.encryptAndEncode(payload);
    console.log("getUploadToken: Completed. Payload byte size: ${payloadData.length}");

    return {
      status: "SUCCESS",
      token: payloadData.toString("base64")
    };
  } else {
    console.log("getUploadToken:", "Invalid data: ${data}");
    throw new functions.https.HttpsError("invalid-argument", "Invalid data: ${data}");
  }

然而,令牌校验并不与特定用户绑定。在 Wiqaytna 应用中,PIN 是一个 6 位数字。借助 Firebase 的规模,整个 6 位数字空间在几分钟内就被暴力破解完毕。

实际上,上传追踪文件并不需要 PIN:一旦通过身份验证,就可以向 /o 存储库上传任意名称的任意文件,请参见下面的请求示例:

POST /v0/b/opentrace-a065c.appspot.com/o?uploadType=resumable&name=streetPassRecords%2F20200608%2FStreetPassRecord_LGE_Nexus%205_2020-06-08_23-36-01.json&upload_id=AAANsUkiafDezgJ4QQJa9JsncG1RoKvvi08qh40ZhmOrbOU5_tare9a_3kQABvsYS9m1pVmRDCiFS9WxVkq2Aq_NZrM&upload_protocol=resumable HTTP/1.1
Authorization: Firebase eyJhbGciOiJSUzI1NiIsImtpZCI6Ijc0Mzg3ZGUyMDUxMWNkNDgzYTIwZDIyOGQ5OTI4ZTU0YjNlZTBlMDgiLCJ0eXAiOiJKV1QifQ.eyJwcm92aWRlcl9pZCI6ImFub255bW91cyIsImlzcyI6Imh0dHBzOi8vc2VjdXJldG9rZW4uZ29vZ2xlLmNvbS9vcGVudHJhY2UtYTA2NWMiLCJhdWQiOiJvcGVudHJhY2UtYTA2NWMiLCJhdXRoX3RpbWUiOjE1OTE2NTIyOTUsInVzZXJfaWQiOiJsWGgzSUlNRVpDZ1VkQzF3QU9heVZBeHJSQW0yIiwic3ViIjoibFhoM0lJTUVaQ2dVZEMxd0FPYXlWQXhyUkFtMiIsImlhdCI6MTU5MTY3MDIxMiwiZXhwIjoxNTkxNjczODEyLCJmaXJlYmFzZSI6eyJpZGVudGl0aWVzIjp7fSwic2lnbl9pbl9wcm92aWRlciI6ImFub255bW91cyJ9fQ.BFa9yg_l03TeeFuBL8Obo1NYSN80xSpmtcRlA-lFCXlYuS6kX8azYMfrik6G1S0x9PuxmftBxxd7Yn6rsueJtdoTchrF67XDMbqlnCiqtlNJuPItPKFyaeuB3IgY219yZxO4KWlQYmxy5IOL71jKj_RNS7iQpi0G3fG_lGa01CD6xaafBuSC3MmZyu-yeaeY7kNsdZRF7_YHPQerINR6C9sQH0ZZepcwv5onzh5j3sALfvaQVVTL1VyA6gZEAeZzPYPR_c2n5Izkk0VsNcgqKejHRte-Qh5heJ8MZAzRZep6_rUjMhCxv7DdbmBC_gTBvYs8ZXDdhgdUESPAMfDlvA
X-Firebase-Storage-Version: Android/20.15.15 (040308-306758586)
X-Goog-Upload-Offset: 0
X-Goog-Upload-Protocol: resumable
x-firebase-gmpid: 1:319155529705:android:f102bb0f7be8a8bac2cd78
X-Goog-Upload-Command: upload, finalize
Content-Length: 32739
Content-Type: application/x-www-form-urlencoded
User-Agent: Dalvik/2.1.0 (Linux; U; Android 7.1.2; Nexus 5 Build/NJH47F)
Host: firebasestorage.googleapis.com
Connection: close
Accept-Encoding: gzip, deflate

{"token":"klklklk","records":[],"events":[{"id":1,"msg":"Scanning Started","timestamp":1591635248},{"id":2,"msg":"Scanning Stopped","timestamp":1591635256},{"id":3,"msg":"Scanning Started","timestamp":1591635293},{"id":4,"msg":"Scanning Stopped","timestamp":1591635301},

不过,PIN 会被用于根据用户的 UID 生成一个令牌。该令牌随后会被添加到上传的文件中,并在收集和解析临时 ID 之前接受校验。

export async function _processUploadedData(filePath: string, validateTokenTimestamp: boolean = true): Promise<{ status: string; message?: string; filePath?: string }> {
  const fileName = path.basename(filePath, ".json");
  let uid = "", uploadCode = "", step = "";

  try {
    //
    // Step 1: load file content into memory
    //
    step = "1 - load file";
    const { token, records, events } = JSON.parse(await getStorageData(config.upload.bucketForArchive, filePath));
    console.log("processUploadedData:", "step ${step}", "File is loaded, record count:", records.length);

    //
    // Step 2: Validate upload token to get uid
    //
    step = "2 - validate upload token";
    ({ uid, uploadCode } = await validateToken(token, validateTokenTimestamp));
    console.log("processUploadedData:", "step ${step}", "Upload token is valid, id:", uid);

覆盖其他用户的文件,或者直接读取已上传文件的风险,通过 Firebase 安全策略得到了缓解。文件在上传后也会被归档并删除,从而限制了发生任何类似攻击的可能性。

rules_version = "2";
service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow create: if request.auth != null; // Only allow write, Cloud Functions have read/write access by default.
    }
  }
}

Firebase 的默认策略本来是不安全的,因为它允许所有经过身份验证的用户进行读写访问。

身份验证

身份验证通过 firebase 服务完成,该服务会获取一个临时令牌,用于与后端通信。该令牌会在后台持续刷新,但完成身份验证握手需要发送多个请求。请参见下面的示例请求:

POST /c2dm/register3 HTTP/1.1
Authorization: AidLogin 3751703094547129981:5572975265165158895
app: covid.trace.morocco
gcm_ver: 201515018
User-Agent: Android-GCM/1.5 (hammerhead NJH47F)
Content-Length: 1153
content-type: application/x-www-form-urlencoded
Host: android.clients.google.com
Connection: close
Accept-Encoding: gzip, deflate

X-subtype=319155529705&sender=319155529705&X-app_ver=67&X-osv=25&X-cliv=fiid-20.1.6&X-gmsv=201515018&X-appid=davxJEzSQ-KU19cQzA_uoT&X-scope=*&X-Goog-Firebase-Installations-Auth=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJmaWQiOiJkYXZ4SkV6U1EtS1UxOWNRekFfdW9UIiwicHJvamVjdE51bWJlciI6MzE5MTU1NTI5NzA1LCJleHAiOjE1OTIyNTQ4MjAsImFwcElkIjoiMTozMTkxNTU1Mjk3MDU6YW5kcm9pZDpmMTAyYmIwZjdiZThhOGJhYzJjZDc4In0.AB2LPV8wRAIgMgJx98gRA6_SU6N8nW6wgTZVuddlyyBevprKhZo6dHoCIA4Iy3e5EJyGHeoFc2Qe_iYGdaiJsx_6HifTuSC3v46n&X-gmp_app_id=1%3A319155529705%3Aandroid%3Af102bb0f7be8a8bac2cd78&X-Firebase-Client=fire-auth%2F19.3.1+fire-iid%2F20.1.6+fire-analytics%2F17.4.0+fire-abt%2F19.0.1+fire-android%2F+fire-rc%2F19.1.4+fire-cls%2F17.0.0+fire-core-ktx%2F19.3.0+fire-fcm%2F20.1.6+kotlin%2F1.3.61+fire-installations%2F16.2.2+fire-fn%2F19.0.2+fire-perf%2F19.0.7+fire-gcs%2F19.1.1+fire-core%2F19.3.0&X-firebase-app-name-hash=R1dAH9Ui7M-ynoznwBdw01tLxhI&X-Firebase-Client-Log-Type=1&X-app_ver_name=1.0.67&app=covid.trace.morocco&device=3751703094547129981&app_ver=67&info=k9JSinrwmBIcQKri541rkWV16eIluRY&gcm_ver=201515018&plat=0&cert=ad695288cfbac51f5d7a1c01b70dc9d17bfdee75&target_ver=28
POST /v1/token?alt=proto&key=AIzaSyAoR1Pr0U0xhT52a3FlnLK2OeGEjtUNJic HTTP/1.1
Content-Type: application/x-protobuf
X-Firebase-Locale: 
X-Client-Version: Android/GmsCore/X19003001/FirebaseCore-Android
Accept-Language: en-US
X-Android-Package: covid.trace.morocco
X-Android-Cert: AD695288CFBAC51F5D7A1C01B70DC9D17BFDEE75
X-Goog-Spatula: CjMKE2NvdmlkLnRyYWNlLm1vcm9jY28aHHJXbFNpTSs2eFI5ZGVod0J0dzNKMFh2OTduVT0SIHJZIRStW1F0J8MBBVCOImgRlh8OUv/zaX9g98To5sncGP3M3Onkwq6INCCI4u/0m5zAmDQqSQBeaoCcvd7j4G9W0tHHPxFfDghEUwL14YXByaqT5AoHKQjFCAIyjLFGBRfafsYYmjO71FlNW7YtO+W7X97HZvZmUw67FR2vvn8=
User-Agent: Mozilla 5.0 (Linux; U; Android 7.1.2; en_US; Nexus 5; Build/NJH47F); com.google.android.gms/201515018; FastParser/1.1; ApiaryHttpClient/1.0; (gzip) (hammerhead NJH47F); gzip
Content-Length: 222
Host: securetoken.googleapis.com
Connection: close
Accept-Encoding: gzip, deflate



refresh_tokenÌAE0u-Nepqs-8lFOdGMvgfgNcJA9fyrDqMrSXfbVgtY811m5NNcHv5XsmssQdkv3csqJr1LCSQ4m_aSCvPa2KjKNGJsHMAGs-jmbN_GYS64PFOMzq8bY-78vf9u6QMUHBgwDziJlrHQioVk843vPorA3LIyrDPDOCAcHbU8fndIzA5ySj-X7kMA_RK0t5evIOy2cGxrXbhU4M
HTTP/1.1 200 OK
Expires: Mon, 01 Jan 1990 00:00:00 GMT
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Date: Tue, 09 Jun 2020 01:33:34 GMT
Content-Type: application/x-protobuf
Content-Disposition: attachment
Vary: Origin
Vary: X-Origin
Vary: Referer
Server: ESF
X-XSS-Protection: 0
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Alt-Svc: h3-27=":443"; ma=2592000,h3-25=":443"; ma=2592000,h3-T050=":443"; ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q049=":443"; ma=2592000,h3-Q048=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; ma=2592000; v="46,43"
Connection: close
Content-Length: 1957


ÐeyJhbGciOiJSUzI1NiIsImtpZCI6Ijc0Mzg3ZGUyMDUxMWNkNDgzYTIwZDIyOGQ5OTI4ZTU0YjNlZTBlMDgiLCJ0eXAiOiJKV1QifQ.eyJwcm92aWRlcl9pZCI6ImFub255bW91cyIsImlzcyI6Imh0dHBzOi8vc2VjdXJldG9rZW4uZ29vZ2xlLmNvbS9vcGVudHJhY2UtYTA2NWMiLCJhdWQiOiJvcGVudHJhY2UtYTA2NWMiLCJhdXRoX3RpbWUiOjE1OTE2NTIyOTUsInVzZXJfaWQiOiJsWGgzSUlNRVpDZ1VkQzF3QU9heVZBeHJSQW0yIiwic3ViIjoibFhoM0lJTUVaQ2dVZEMxd0FPYXlWQXhyUkFtMiIsImlhdCI6MTU5MTY2NjQxNCwiZXhwIjoxNTkxNjcwMDE0LCJmaXJlYmFzZSI6eyJpZGVudGl0aWVzIjp7fSwic2lnbl9pbl9wcm92aWRlciI6ImFub255bW91cyJ9fQ.DiTkt4EC3IUM8Qx4BKkX_Wu-DSt_59wX_bS0-8SVHA94M4vmcVQ7Bn4ixk-QkaT-AW6qxzcJyffc726TNaoG6KIYO4AHsdb-1ZO6ipBtetOx53vLxeF7L-5fwqerSzGqXvL4UDbetAWFxQylbcV0YbWzgXBrvBNoawG8osiBgZ-qC-b6VyElMwcm5s7Gt8mv_cdS8c4MB6xBppUYFBBdaUXzfAJqse6w0KR1CwUqhI_SojcuIWF9e0O7du0zngnThoixluqHM9bmYzoWcOD8QKfhLYgRDzet6r_sJCMlc48tzb8r3XeWodktMnIgj__P7N4tglLofjDX65BzISwwZgBearer"ÌAE0u-Nepqs-8lFOdGMvgfgNcJA9fyrDqMrSXfbVgtY811m5NNcHv5XsmssQdkv3csqJr1LCSQ4m_aSCvPa2KjKNGJsHMAGs-jmbN_GYS64PFOMzq8bY-78vf9u6QMUHBgwDziJlrHQioVk843vPorA3LIyrDPDOCAcHbU8fndIzA5ySj-X7kMA_RK0t5evIOy2cGxrXbhU4M*ÐeyJhbGciOiJSUzI1NiIsImtpZCI6Ijc0Mzg3ZGUyMDUxMWNkNDgzYTIwZDIyOGQ5OTI4ZTU0YjNlZTBlMDgiLCJ0eXAiOiJKV1QifQ.eyJwcm92aWRlcl9pZCI6ImFub255bW91cyIsImlzcyI6Imh0dHBzOi8vc2VjdXJldG9rZW4uZ29vZ2xlLmNvbS9vcGVudHJhY2UtYTA2NWMiLCJhdWQiOiJvcGVudHJhY2UtYTA2NWMiLCJhdXRoX3RpbWUiOjE1OTE2NTIyOTUsInVzZXJfaWQiOiJsWGgzSUlNRVpDZ1VkQzF3QU9heVZBeHJSQW0yIiwic3ViIjoibFhoM0lJTUVaQ2dVZEMxd0FPYXlWQXhyUkFtMiIsImlhdCI6MTU5MTY2NjQxNCwiZXhwIjoxNTkxNjcwMDE0LCJmaXJlYmFzZSI6eyJpZGVudGl0aWVzIjp7fSwic2lnbl9pbl9wcm92aWRlciI6ImFub255bW91cyJ9fQ.DiTkt4EC3IUM8Qx4BKkX_Wu-DSt_59wX_bS0-8SVHA94M4vmcVQ7Bn4ixk-QkaT-AW6qxzcJyffc726TNaoG6KIYO4AHsdb-1ZO6ipBtetOx53vLxeF7L-5fwqerSzGqXvL4UDbetAWFxQylbcV0YbWzgXBrvBNoawG8osiBgZ-qC-b6VyElMwcm5s7Gt8mv_cdS8c4MB6xBppUYFBBdaUXzfAJqse6w0KR1CwUqhI_SojcuIWF9e0O7du0zngnThoixluqHM9bmYzoWcOD8QKfhLYgRDzet6r_sJCMlc48tzb8r3XeWodktMnIgj__P7N4tglLofjDX65BzISwwZg2lXh3IIMEZCgUdC1wAOayVAxrRAm28éϛù¤ 
POST /getOTPCode HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Ijc0Mzg3ZGUyMDUxMWNkNDgzYTIwZDIyOGQ5OTI4ZTU0YjNlZTBlMDgiLCJ0eXAiOiJKV1QifQ.eyJwcm92aWRlcl9pZCI6ImFub255bW91cyIsImlzcyI6Imh0dHBzOi8vc2VjdXJldG9rZW4uZ29vZ2xlLmNvbS9vcGVudHJhY2UtYTA2NWMiLCJhdWQiOiJvcGVudHJhY2UtYTA2NWMiLCJhdXRoX3RpbWUiOjE1OTE2NTIyOTUsInVzZXJfaWQiOiJsWGgzSUlNRVpDZ1VkQzF3QU9heVZBeHJSQW0yIiwic3ViIjoibFhoM0lJTUVaQ2dVZEMxd0FPYXlWQXhyUkFtMiIsImlhdCI6MTU5MTY1MjI5NSwiZXhwIjoxNTkxNjU1ODk1LCJmaXJlYmFzZSI6eyJpZGVudGl0aWVzIjp7fSwic2lnbl9pbl9wcm92aWRlciI6ImFub255bW91cyJ9fQ.IVuITUlZH3ch254qhThumeIKNYPaIvnFsj3Bdnp_51J4eKp7HYhy8l5TZAHm9pW51VwC5mEbKrLXbrnmHhSgDjeXnNnAWfUHGRl_5BJUSvsrXIiVhUNT6uWFwq85oRzM95fhUQzP3zA1V-40Rt91-pUTBbq548mrabdFwxHgbW7WlbT2kM75V2al0ZwRTcci5TpOACf_zlNVUcia4L2K1R28vE_EGTPYPYvDPuEa4Bv5zz5GX0gL4PgQJGgStqp-Q4r1o9MuXBdyaxBr8cMNNXXlOT-Doin2Xavhzxy1A73sDbGeA9TkwntwOUwacL6bkQzoeg_SaAU5pQa4SsyOhg
Firebase-Instance-ID-Token: eVusymDJTJWMmm8tG4nQUZ:APA91bHLa36gjJeGnE-ZiTTEnXKwwLE6AOEZa7gps0BKiJuujVDXVG9_onEp6z2QUIsdco8QWQ3jpsxKW2Tbe8-4pI82qGWmXLheRZ1_vChXqJmjt9kCCQUSS9XGaOQwPVnxCfXDyu9T
Content-Type: application/json; charset=utf-8
Content-Length: 229
Host: europe-west1-opentrace-a065c.cloudfunctions.net
Connection: close
Accept-Encoding: gzip, deflate
User-Agent: okhttp/3.12.1

{"data":{"token":"eVusymDJTJWMmm8tG4nQUZ:APA91bHLa36gjJeGnE-ZiTTEnXKwwLE6AOEZa7gps0BKiJuujVDXVG9_onEp6z2QUIsdco8QWQ3jpsxKW2Tbe8-4pI82qGWmXLheRZ1_vChXqJmjt9kCCQUSS9XGaOQwPVnxCfXDyu9T","os":"ANDROID","phoneNumber":"+212xxxxxxxxx"}}

在流量分析过程中,我们确认 Wiqaytna 不收集任何 PII,但需要一个摩洛哥电话号码来接收 OTP。

POST /getOTPCode HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Ijc0Mzg3ZGUyMDUxMWNkNDgzYTIwZDIyOGQ5OTI4ZTU0YjNlZTBlMDgiLCJ0eXAiOiJKV1QifQ.eyJwcm92aWRlcl9pZCI6ImFub255bW91cyIsImlzcyI6Imh0dHBzOi8vc2VjdXJldG9rZW4uZ29vZ2xlLmNvbS9vcGVudHJhY2UtYTA2NWMiLCJhdWQiOiJvcGVudHJhY2UtYTA2NWMiLCJhdXRoX3RpbWUiOjE1OTE2NTIyOTUsInVzZXJfaWQiOiJsWGgzSUlNRVpDZ1VkQzF3QU9heVZBeHJSQW0yIiwic3ViIjoibFhoM0lJTUVaQ2dVZEMxd0FPYXlWQXhyUkFtMiIsImlhdCI6MTU5MTY1MjI5NSwiZXhwIjoxNTkxNjU1ODk1LCJmaXJlYmFzZSI6eyJpZGVudGl0aWVzIjp7fSwic2lnbl9pbl9wcm92aWRlciI6ImFub255bW91cyJ9fQ.IVuITUlZH3ch254qhThumeIKNYPaIvnFsj3Bdnp_51J4eKp7HYhy8l5TZAHm9pW51VwC5mEbKrLXbrnmHhSgDjeXnNnAWfUHGRl_5BJUSvsrXIiVhUNT6uWFwq85oRzM95fhUQzP3zA1V-40Rt91-pUTBbq548mrabdFwxHgbW7WlbT2kM75V2al0ZwRTcci5TpOACf_zlNVUcia4L2K1R28vE_EGTPYPYvDPuEa4Bv5zz5GX0gL4PgQJGgStqp-Q4r1o9MuXBdyaxBr8cMNNXXlOT-Doin2Xavhzxy1A73sDbGeA9TkwntwOUwacL6bkQzoeg_SaAU5pQa4SsyOhg
Firebase-Instance-ID-Token: eVusymDJTJWMmm8tG4nQUZ:APA91bHLa36gjJeGnE-ZiTTEnXKwwLE6AOEZa7gps0BKiJuujVDXVG9_onEp6z2QUIsdco8QWQ3jpsxKW2Tbe8-4pI82qGWmXLheRZ1_vChXqJmjt9kCCQUSS9XGaOQwPVnxCfXDyu9T
Content-Type: application/json; charset=utf-8
Content-Length: 229
Host: europe-west1-opentrace-a065c.cloudfunctions.net
Connection: close
Accept-Encoding: gzip, deflate
User-Agent: okhttp/3.12.1

{"data":{"token":"eVusymDJTJWMmm8tG4nQUZ:APA91bHLa36gjJeGnE-ZiTTEnXKwwLE6AOEZa7gps0BKiJuujVDXVG9_onEp6z2QUIsdco8QWQ3jpsxKW2Tbe8-4pI82qGWmXLheRZ1_vChXqJmjt9kCCQUSS9XGaOQwPVnxCfXDyu9T","os":"ANDROID","phoneNumber":"+212612345678"}}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Etag: W/"1a-XRg53+foeSHn5xZ1DF/JVqySS30"
Function-Execution-Id: uohs4he3srne
Vary: Origin
X-Powered-By: Express
X-Cloud-Trace-Context: f52f429cd5b8842c69de05793722650b
Date: Mon, 08 Jun 2020 21:42:38 GMT
Server: Google Frontend
Content-Length: 26
Alt-Svc: h3-27=":443"; ma=2592000,h3-25=":443"; ma=2592000,h3-T050=":443"; ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q049=":443"; ma=2592000,h3-Q048=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; ma=2592000; v="46,43"
Connection: close

{"result":{"result":true}}
POST /verifyOTP HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Ijc0Mzg3ZGUyMDUxMWNkNDgzYTIwZDIyOGQ5OTI4ZTU0YjNlZTBlMDgiLCJ0eXAiOiJKV1QifQ.eyJwcm92aWRlcl9pZCI6ImFub255bW91cyIsImlzcyI6Imh0dHBzOi8vc2VjdXJldG9rZW4uZ29vZ2xlLmNvbS9vcGVudHJhY2UtYTA2NWMiLCJhdWQiOiJvcGVudHJhY2UtYTA2NWMiLCJhdXRoX3RpbWUiOjE1OTE2NTIyOTUsInVzZXJfaWQiOiJsWGgzSUlNRVpDZ1VkQzF3QU9heVZBeHJSQW0yIiwic3ViIjoibFhoM0lJTUVaQ2dVZEMxd0FPYXlWQXhyUkFtMiIsImlhdCI6MTU5MTY1MjI5NSwiZXhwIjoxNTkxNjU1ODk1LCJmaXJlYmFzZSI6eyJpZGVudGl0aWVzIjp7fSwic2lnbl9pbl9wcm92aWRlciI6ImFub255bW91cyJ9fQ.IVuITUlZH3ch254qhThumeIKNYPaIvnFsj3Bdnp_51J4eKp7HYhy8l5TZAHm9pW51VwC5mEbKrLXbrnmHhSgDjeXnNnAWfUHGRl_5BJUSvsrXIiVhUNT6uWFwq85oRzM95fhUQzP3zA1V-40Rt91-pUTBbq548mrabdFwxHgbW7WlbT2kM75V2al0ZwRTcci5TpOACf_zlNVUcia4L2K1R28vE_EGTPYPYvDPuEa4Bv5zz5GX0gL4PgQJGgStqp-Q4r1o9MuXBdyaxBr8cMNNXXlOT-Doin2Xavhzxy1A73sDbGeA9TkwntwOUwacL6bkQzoeg_SaAU5pQa4SsyOhg
Firebase-Instance-ID-Token: eVusymDJTJWMmm8tG4nQUZ:APA91bHLa36gjJeGnE-ZiTTEnXKwwLE6AOEZa7gps0BKiJuujVDXVG9_onEp6z2QUIsdco8QWQ3jpsxKW2Tbe8-4pI82qGWmXLheRZ1_vChXqJmjt9kCCQUSS9XGaOQwPVnxCfXDyu9T
Content-Type: application/json; charset=utf-8
Content-Length: 25
Host: europe-west1-opentrace-a065c.cloudfunctions.net
Connection: close
Accept-Encoding: gzip, deflate
User-Agent: okhttp/3.12.1

{"data":{"OTP":"415322"}}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Etag: W/"f-ayLlCL3PuzXSThdu78iReSEjl6Y"
Function-Execution-Id: wee1qz85dpid
Vary: Origin
X-Powered-By: Express
X-Cloud-Trace-Context: cdc7b708c0b133eeb7de7cd8171a498a
Date: Mon, 08 Jun 2020 21:54:55 GMT
Server: Google Frontend
Content-Length: 15
Alt-Svc: h3-27=":443"; ma=2592000,h3-25=":443"; ma=2592000,h3-T050=":443"; ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q049=":443"; ma=2592000,h3-Q048=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; ma=2592000; v="46,43"
Connection: close

{"result":true}
权限

应用申请的权限全部是合理的,主要涉及蓝牙和互联网访问。一些用户对位置权限(ACCESS_FINE_LOCATION)表示担忧,但 Android 访问蓝牙需要该权限,详情请参见 Android 文档。

我们的分析表明,该应用并未使用或收集任何位置坐标。

Android 上:

<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>

iOS 上:

<key>NSBluetoothAlwaysUsageDescription</key>
    <string>Le Bluetooth est utilisé pour échanger des codes anonymes avec les autres utilisateurs de Wiqaytna et ainsi déterminer si ils ont été à proximité de vous</string>
    <key>NSBluetoothPeripheralUsageDescription</key>
    <string>Le Bluetooth est utilisé pour échanger des codes anonymes avec les autres utilisateurs de Wiqaytna et ainsi déterminer si ils ont été à proximité de vous</string>
    <key>TRACER_SVC_ID</key>
...
<key>UIBackgroundModes</key>
    <array>
        <string>bluetooth-central</string>
        <string>bluetooth-peripheral</string>
        <string>fetch</string>
        <string>processing</string>
        <string>remote-notification</string>
    </array>
进程间通信(IPC)

Android 应用使用多种 IPC 机制进行内部通信。其中一些机制通过本地 Broadcast Manager 动态注册,并在处理完成后注销。

private fun prepare() {
    val deviceAvailableFilter = IntentFilter(ACTION_DEVICE_SCANNED)
    localBroadcastManager.registerReceiver(scannedDeviceReceiver, deviceAvailableFilter)

    val deviceProcessedFilter = IntentFilter(ACTION_DEVICE_PROCESSED)
    localBroadcastManager.registerReceiver(blacklistReceiver, deviceProcessedFilter)

    timeoutHandler = Handler()
    queueHandler = Handler()
    blacklistHandler = Handler()
}

然而,Local Broadcast manager 已被弃用,应当以 LiveData 或响应式流(reactive streams)替代。

接触者追踪

如上所述,接触者追踪使用 OpenTrace 协议。该协议通过生成仅在 15 分钟内有效的临时令牌来防止跟踪和重放攻击,不过其他实现,例如 COVIDSafe,将协议调整为 2 小时,增加了重放攻击的风险。

该协议是无状态的,也就是说,服务器上不存储任何临时 ID。相反,临时 ID 中包含了用后端密钥加密的用户 UID。

在注册时,服务器会生成一批临时 ID 并发送给移动应用,请参见下面的示例响应:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Etag: W/"11b78-pjrDe0QcfmRJJ5LFAbO0gM2nD5Q"
Function-Execution-Id: c8wiwl18rlh3
Vary: Origin
X-Powered-By: Express
X-Cloud-Trace-Context: b98efbdc458ce408e0c6de05fe37421c
Date: Tue, 09 Jun 2020 01:33:49 GMT
Server: Google Frontend
Content-Length: 72568
Alt-Svc: h3-27=":443"; ma=2592000,h3-25=":443"; ma=2592000,h3-T050=":443"; ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q049=":443"; ma=2592000,h3-Q048=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; ma=2592000; v="46,43"
Connection: close

{"result":{"status":"SUCCESS","tempIDs":[{"tempID":"RkL8XSgATVNi4BtqVnD5PEAQ/W4XbRowpSQI8GKuO929Q7Es0DwZRrsst2n2iYNDBupPFXBpeF8SzN7Xxg==","startTime":1591659229,"expiryTime":1591660129},{"tempID":"XMTgQ10Qcawdnkk6pvTnmRY7Lupp9iQZVvi8PQ2Rs34t0lLLnQphIyc0h/QuGMWytD7UqY3wShHT3h741Q==","startTime":1591660129,"expiryTime":1591661029},{"tempID":"4DWjqu4N6R1iJzFI15F1AhHqX3ZPYPwi+7vifSHJa3d7qYSrBgDS1dQWb+yb5PAcha23BqvxGKJWzeK+XA==","startTime":1591661029,"expiryTime":1591661929},{"tempID":"4IzfigFLAmvPjJgnJs74dAJIMhEWQGy1e86XgkcUlTWD4EL1pNKY0r1R8sHVjfIPIvgbbznCW358cVYISw==","startTime":1591661929,"expiryTime":1591662829},{"tempID":"JJ9QS14beseLakhTb6m8D15Ju7fZnz0T9nA5SiiNG1DobcyHVsceZWN/uaQ8gbzhNQScLYg+A63lNfzktQ==","startTime":1591662829,"expiryTime":1591663729},{"tempID":"daMVpZZWqR+SI8mYhQbw07VgdM4KCMcSobQDKzZUn9nq/Y8w5p5BSxeDox7ZTPQufa5zJ35Jf0E7pkzxJw==","startTime":1591663729,"expiryTime":1591664629},{"tempID":"xO+yM4u2AzNVSVWbiLw7xrSOyOVTy0PLW++VTtW7olkZ6cG4G4AFGRj5eDyIpqh4+GPKP9KQzAO8Q4ptSw==","startTime":1591664629,"expiryTime":1591665529},{"tempID":"55wlRGy2k2dpJQtL37dozYOU6Pz/W06LHYoeQ4z7Dkohg4veCAKYwe9LSKDC+zwQAqQXaq14/jud3SVBGA==","startTime":1591665529,"expiryTime":1591666429},{"tempID":"a5N6awZjMKzANIZyZUTFOzl2UnJsFH26Przt0PMwg/YGOBh9oBW7OMART5fZPMnd8fC/sKu3bq8VQzZtBA==","startTime":1591666429,"expiryTime":1591667329},{"tempID":"2MBbQrT3/9bqsHHKnmqkoops/xw64wmButkg0NQMrmw/mYlyaCtknRSW6TOowM07UsUQpLLQDVcUgMvEug==","startTime":1591667329,"expiryTime":1591668229},

随后,这些临时 ID 会通过低功耗蓝牙(BLE)进行广播。以下是从 Android 上的蓝牙服务中收集到的调用栈示例。生产构建版本经过了代码混淆,因此方法名都是晦涩的单个字母:

at void com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run()
at java.lang.Object java.lang.reflect.Method.invoke()
at void android.app.ActivityThread.main()
at void android.os.Looper.loop()
at void android.os.Handler.dispatchMessage()
at void covid.trace.morocco.services.b.handleMessage()
at void covid.trace.morocco.services.BluetoothMonitoringService.a()
at void covid.trace.morocco.services.BluetoothMonitoringService.O()
at long covid.trace.morocco.services.BluetoothMonitoringService.a()

at void com.android.internal.os.ZygoteInit.main()
at void com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run()
at java.lang.Object java.lang.reflect.Method.invoke()
at void android.app.ActivityThread.main()
at void android.os.Looper.loop()
at void android.os.Handler.dispatchMessage()
at void covid.trace.morocco.services.b.handleMessage()
at void covid.trace.morocco.services.BluetoothMonitoringService.a()
at void covid.trace.morocco.services.BluetoothMonitoringService.O()
at void covid.trace.morocco.services.b.a()
at void covid.trace.morocco.services.b.b()
at int covid.trace.morocco.services.BluetoothMonitoringService$b.a()

at void com.android.internal.os.ZygoteInit.main()
at void com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run()
at java.lang.Object java.lang.reflect.Method.invoke()
at void android.app.ActivityThread.main()
at void android.os.Looper.loop()
at void android.os.Handler.dispatchMessage()
at void covid.trace.morocco.services.b.handleMessage()
at void covid.trace.morocco.services.BluetoothMonitoringService.a()
at void covid.trace.morocco.services.BluetoothMonitoringService.O()
at void covid.trace.morocco.services.b.a()
at void covid.trace.morocco.services.b.a()
at int covid.trace.morocco.services.BluetoothMonitoringService$b.a()

at void com.android.internal.os.ZygoteInit.main()
at void com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run()
at java.lang.Object java.lang.reflect.Method.invoke()
at void android.app.ActivityThread.main()
at void android.os.Looper.loop()
at void android.os.Handler.dispatchMessage()
at void covid.trace.morocco.services.b.handleMessage()
at void covid.trace.morocco.services.BluetoothMonitoringService.a()
at void covid.trace.morocco.services.BluetoothMonitoringService.F()

at void com.android.internal.os.ZygoteInit.main()
at void com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run()
at java.lang.Object java.lang.reflect.Method.invoke()
at void android.app.ActivityThread.main()
at void android.os.Looper.loop()
at void android.os.Handler.dispatchMessage()
at void covid.trace.morocco.services.b.handleMessage()
at void covid.trace.morocco.services.BluetoothMonitoringService.a()
at void covid.trace.morocco.services.BluetoothMonitoringService.F()
at boolean covid.trace.morocco.idmanager.TempIDManager.needToUpdate()
at void covid.trace.morocco.c.a$a.c()

 With parameters:
1: : java.lang.String = "TempIDManager"
2: : java.lang.String = "Need to update and fetch TemporaryIDs? 1591864491000 vs 1591816764979: false"

at void com.android.internal.os.ZygoteInit.main()
at void com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run()
at java.lang.Object java.lang.reflect.Method.invoke()
at void android.app.ActivityThread.main()
at void android.os.Looper.loop()
at void android.os.Handler.dispatchMessage()
at void covid.trace.morocco.services.b.handleMessage()
at void covid.trace.morocco.services.BluetoothMonitoringService.a()
at void covid.trace.morocco.services.BluetoothMonitoringService.F()
at boolean covid.trace.morocco.idmanager.TempIDManager.needToUpdate()
at void covid.trace.morocco.c.a$a.c()
at boolean covid.trace.morocco.c.a$a.b()

该服务使用 UUID 17E033D3-490E-4BC9-9FE8-2F567643F4D3 和特征值 UUID 117BDD58-57CE-4E7A-8E87-7CCCDDA2A804,这些值是从二进制文件中提取出来的,也可以通过扫描蓝牙服务获得:

蓝牙服务扫描结果
移动应用安全测试

然而,该 UUID 与 TraceTogether 应用使用的预发布环境(staging)UUID 相同。Wiqytna 团队本应生成并使用他们自己的 UUID。

在 Android 上,可发现性很容易实现,但在 iOS 上就变得非常棘手。Apple 对应用在后台时能做的事情施加了限制,限制了广播追踪服务的可能性。Apple 有自己专有的方式来使用/滥用该标准:利用制造商数据值中的溢出区域,以哈希格式广播服务。

iOS 后台广播的蓝牙数据
移动应用安全测试

iOS 制造商数据中的溢出区域
移动应用安全测试

Open Trace 代码仓库中有一个很长的 issue,讨论如何绕过 Apple 的蓝牙限制。正是出于这个原因,一些国家选择使用 Google 和 Apple 的追踪协议,因为这些棘手的细节由操作系统处理,而 Apple 完全掌控蓝牙协议栈。Covid Watch 是另一款应用的例子,它使用了更高级的技巧,使跨平台发现效果更好。

该特征值暴露了一条 JSON 编码的消息,其中包含临时 ID 以及有助于计算距离的其他测量信息:

特征值中的 JSON 消息
移动应用安全测试

对于这款摩洛哥应用,我们的实验发现了两个不同的问题:

1- 在 iOS 设备上,当屏幕锁定或应用进入后台时,临时 ID 不会与其他设备交换。我们通过检查具有以下结构的 iOS 数据库确认了这一点:

sqlite> .schema ZENCOUNTER

CREATE TABLE ZENCOUNTER ( Z_PK INTEGER PRIMARY KEY, Z_ENT INTEGER, Z_OPT INTEGER, ZV INTEGER, ZRSSI FLOAT, ZTIMESTAMP TIMESTAMP, ZTXPOWER FLOAT, ZMODELC VARCHAR, ZMODELP VARCHAR, ZMSG VARCHAR, ZORG VARCHAR );

Select * from ZENCOUNTER;

2- 在 Android 和 iOS 设备上,应用都会在临时 ID 本应过期之后继续广播同一个临时 ID:

1|1591809886424|2|not_found|MAR|Nexus 5|iPhone 7|-84|
2|1591810481878|2|0jHiYi0oNFzNUgYEWmbDGu2MXdz+wS17FpRYpg3qGyu8vJzcXw2BRnj25vQc+NhA1hP+apjWC6W67Nz1Vw==|MAR|Nexus 5|iPhone 7|-90|
3|1591810486993|2|0jHiYi0oNFzNUgYEWmbDGu2MXdz+wS17FpRYpg3qGyu8vJzcXw2BRnj25vQc+NhA1hP+apjWC6W67Nz1Vw==|MAR|Nexus 5|iPhone 7|-85|
...
143|1591823491233|2|0jHiYi0oNFzNUgYEWmbDGu2MXdz+wS17FpRYpg3qGyu8vJzcXw2BRnj25vQc+NhA1hP+apjWC6W67Nz1Vw==|MAR|Nexus 5|iPhone 7|-53|
144|1591823554837|2|0jHiYi0oNFzNUgYEWmbDGu2MXdz+wS17FpRYpg3qGyu8vJzcXw2BRnj25vQc+NhA1hP+apjWC6W67Nz1Vw==|MAR|Nexus 5|iPhone 7|-59|
145|1591823611469|2|0jHiYi0oNFzNUgYEWmbDGu2MXdz+wS17FpRYpg3qGyu8vJzcXw2BRnj25vQc+NhA1hP+apjWC6W67Nz1Vw==|MAR|Nexus 5|iPhone 7|-54|
146|1591823672303|2|0jHiYi0oNFzNUgYEWmbDGu2MXdz+wS17FpRYpg3qGyu8vJzcXw2BRnj25vQc+NhA1hP+apjWC6W67Nz1Vw==|MAR|Nexus 5|iPhone 7|-52|
147|1591823730987|2|0jHiYi0oNFzNUgYEWmbDGu2MXdz+wS17FpRYpg3qGyu8vJzcXw2BRnj25vQc+NhA1hP+apjWC6W67Nz1Vw==|MAR|Nexus 5|iPhone 7|-58|
148|1591823790687|2|0jHiYi0oNFzNUgYEWmbDGu2MXdz+wS17FpRYpg3qGyu8vJzcXw2BRnj25vQc+NhA1hP+apjWC6W67Nz1Vw==|MAR|Nexus 5|iPhone 7|-58|
149|1591823851166|2|0jHiYi0oNFzNUgYEWmbDGu2MXdz+wS17FpRYpg3qGyu8vJzcXw2BRnj25vQc+NhA1hP+apjWC6W67Nz1Vw==|MAR|Nexus 5|iPhone 7|-59|
150|1591823911950|2|0jHiYi0oNFzNUgYEWmbDGu2MXdz+wS17FpRYpg3qGyu8vJzcXw2BRnj25vQc+NhA1hP+apjWC6W67Nz1Vw==|MAR|Nexus 5|iPhone 7|-57|
151|1591823973664|2|0jHiYi0oNFzNUgYEWmbDGu2MXdz+wS17FpRYpg3qGyu8vJzcXw2BRnj25vQc+NhA1hP+apjWC6W67Nz1Vw==|MAR|Nexus 5|iPhone 7|-62|
152|1591824032336|2|0jHiYi0oNFzNUgYEWmbDGu2MXdz+wS17FpRYpg3qGyu8vJzcXw2BRnj25vQc+NhA1hP+apjWC6W67Nz1Vw==|MAR|Nexus 5|iPhone 7|-56|

在以下来自 Android 数据库的摘录中,临时 ID 从未改变,即使超过了过期时间戳也是如此。检查 iOS 设备时,我们可以看到该临时 ID 本应在 1591821662(epoch 时间戳)过期,但实际上在该时间之后仍在广播:

<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">

<plist version="1.0">

<dict>
        <key>ADVT_DATA</key>
        <data>
       eyJtcCI6ImlQaG9uZSA3IiwiaWQiOiIwakhpWWkwb05Gek5VZ1lFV21iREd1Mk1YZHord1MxN0ZwUllwZzNxR3l1OHZKemNYdzJCUm5qMjV2UWMrTmhBMWhQK2FwaldDNlc2N056MVZ3PT0iLCJvIjoiTUFSIiwidiI6Mn0=
        </data>
        <key>ADVT_EXPIRY</key>
        <date>2020-06-11T10:26:02Z</date>
        <key>BROADCAST_MSG</key>
   <string>0jHiYi0oNFzNUgYEWmbDGu2MXdz+wS17FpRYpg3qGyu8vJzcXw2BRnj25vQc+NhA1hP+apjWC6W67Nz1Vw==</string>
        <key>BROAD_MSG_ARRAY</key>
        <array>
                <dict>
                        <key>expiryTime</key>
                        <integer>1591821662</integer>
                        <key>startTime</key>
                        <integer>1591820762</integer>
                        <key>tempID</key>
                       <string>0jHiYi0oNFzNUgYEWmbDGu2MXdz+wS17FpRYpg3qGyu8vJzcXw2BRnj25vQc+NhA1hP+apjWC6W67Nz1Vw==</string>
                </dict>
密码学

应用依赖密码学运算来生成和校验临时 ID 以及上传令牌。

应用对所有运算使用单一算法和同一个密钥,但支持多个密钥。开源实现中提到使用 AES,搭配 GCM(Galois Counter Mode,伽罗瓦/计数器模式)或 CBC(Cipher Block Chaining,密码分组链接模式),但默认没有设置任何一种。我们不清楚 Wiqaytna 使用的是哪种实现。

加密实现的配置
移动应用安全测试

GCM 提供更强的安全保障,因为它是一种同时提供加密和完整性保护的认证加密模式。而 CBC 则存在若干已知攻击,例如比特翻转(Bit Flipping)攻击和填充预言(Oracle Padding)攻击。

由于需要加密和解密的多项运算使用同一个密钥,这为攻击打开了大门,例如请求某个 API 加密我们可控的输入,再将其注入到负责解密的 API 中。

对源代码的分析并未显示存在上述任何攻击,因为除 6 位数字 PIN 之外,加密方法的所有输入都不受用户控制。

import crypto from "crypto";
import config from "../../config";

class CustomEncrypter {

  algorithm: string;
  key: Buffer;

  constructor(key: Buffer, algorithm: string = config.encryption.defaultAlgorithm) {
    this.algorithm = algorithm;
    this.key = key;
  }

  decode(encodedData: Buffer, lengths: number[]): string[] {
    const [cipherData, ivData, authTagData] = lengths.map((e, i) => Buffer.alloc(lengths[i]));
    encodedData.copy(cipherData, 0, 0, lengths[0]);
    encodedData.copy(ivData, 0, lengths[0], lengths[0] + lengths[1]);
    encodedData.copy(authTagData, 0, lengths[0] + lengths[1], lengths[0] + lengths[1] + lengths[2]);

    return [
      cipherData.toString("base64"),
      ivData.toString("base64"),
      authTagData.toString("base64")
    ];
  }

  decrypt(cipherText: string, iv: string, authTag: string) {
    const decipher = crypto.createDecipheriv(this.algorithm, this.key, Buffer.from(iv, "base64"));
    // Add ts-ignore due to build error TS2339: Property 'setAuthTag' does not exist on type 'Decipher'.
    // @ts-ignore
    decipher.setAuthTag(Buffer.from(authTag, "base64"));

    // @ts-ignore
    let plainText = decipher.update(cipherText, "base64", "base64");
    plainText += decipher.final("base64");
    return plainText;
  }

  encode(cipherText: string, iv: string, authTag: string): Buffer {
    const [cipherData, ivData, authTagData] = [cipherText, iv, authTag].map(e => Buffer.from(e, "base64"));
    const buffer = Buffer.alloc(cipherData.length + ivData.length + authTagData.length);
    cipherData.copy(buffer, 0);
    ivData.copy(buffer, cipherData.length);
    authTagData.copy(buffer, cipherData.length + ivData.length);

    return buffer;
  }

  encrypt(data: Buffer, ivLength = 16, authTagLength = 16): string[] {
    const dataB64 = data.toString("base64");
    const iv = crypto.randomBytes(ivLength);
    // @ts-ignore
    const cipher = crypto.createCipheriv(this.algorithm, this.key, iv, {authTagLength});

    let cipherText = cipher.update(dataB64, "base64", "base64");
    cipherText += cipher.final("base64");
    return [
      cipherText,
      iv.toString("base64"),
      cipher.getAuthTag().toString("base64")
    ];
  }

  encryptAndEncode(data: Buffer, ivLength = 16, authTagLength = 16): Buffer {
    const [cipherTextB64, ivB64, authTagB64] = this.encrypt(data, ivLength, authTagLength);
    return this.encode(cipherTextB64, ivB64, authTagB64);
  }

  decodeAndDecrypt(encodedData: Buffer, lengths: number[]): string {
    const [decodedCipherTextB64, decodedIvB64, decodedAuthTagB64] = this.decode(encodedData, lengths);
    return this.decrypt(decodedCipherTextB64, decodedIvB64, decodedAuthTagB64);
  }
}

export default CustomEncrypter;
其他

Android 应用中的日志记录是集中式的,并在生产构建版本中被禁用。为了进行动态分析,我们 Hook 了日志记录 API,以收集应用日志并了解追踪过程中发生了什么:

at void com.android.internal.os.ZygoteInit.main()
at void com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run()
at java.lang.Object java.lang.reflect.Method.invoke()
at void android.app.ActivityThread.main()
at void android.os.Looper.loop()
at void android.os.Handler.dispatchMessage()
at void covid.trace.morocco.services.b.handleMessage()
at void covid.trace.morocco.services.BluetoothMonitoringService.a()
at void covid.trace.morocco.services.BluetoothMonitoringService.F()
at void covid.trace.morocco.c.a$a.c()

 With parameters:
1: : java.lang.String = "BTMService"
2: : java.lang.String = "[TempID] Dont need to update Temp ID in actionScan"

对文件系统的访问也仅限于将日志写入 SD 卡,而这在生产版本中同样被禁用。

无障碍性

该应用提供法语和阿拉伯语版本。这是一个良好的开端,但并未覆盖该国的所有官方语言(阿拉伯语和塔马齐格特语),而且仍滞留在该国的许多游客和居民既不讲法语,也不讲阿拉伯语。增加英语和西班牙语等更多语言将有助于推广使用。

应用需要摩洛哥电话号码才能注册。虽然我们能够理解其防止滥用的用意,但游客需要购买一个电话号码才能使用该应用。

得益于所采用的技术栈(原生 Android 和 iOS),应用体积很小,适合低端手机,并且已根据该国的网络状况对追踪协议进行了调整。

该应用没有任何其他帮助残障人士使用的无障碍功能。

标签:

covid-19, privacy, MAST