X1a0He 发表于 2024-2-28 03:42

MacCleaner Pro 分析学习


# 准备工作
喝水不忘挖井人,感谢恩师 [@QiuChenly](https://www.52pojie.cn/home.php?mod=space&uid=653608)



- IDA 8.3 或 IDA 7.7
- Hopper Disassembler 5.13.5
- Sublime Text 或 VS Code

**本文分析的是 Apple Silicon(Arm) 下的二进制文件,x86同理,望周知**

# 开始分析
本来想搜一下这个弹出框的,后来发现关了之后程序不会退出,那就暂时先不处理它





通过初步使用,发现在清理的时候弹出 "完整版评估期已到期"

找到界面上的关键字,在Hopper里面找到对应的地址







可以发现,前两个引用是同一个函数,我们点第一个进去,跳转到地址`100334ecc`



先埋个伏笔,记住程序入口地址`100014e34`



打开LLDB `lldb /Applications/MacCleaner\ Pro\ 3.app`

运行程序,并给程序入口下个断点,埋个伏笔

```
br s -a 100014e34
```

接着,对我们刚才根据字符串搜索到的地址 `100334ecc` 进行断点
```
br s -a 100334ecc
```

输入 `r` 重启,程序会断在入口点



输入 `c` 继续运行

重复我们的清理步骤,触发断点



输入 `bt` 查看堆栈,堆栈如下
```bash
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.1
* frame #0: 0x0000000100334ecc MacCleaner Pro 3`___lldb_unnamed_symbol22333 + 120
    frame #1: 0x000000010034f290 MacCleaner Pro 3`___lldb_unnamed_symbol22790 + 88
    frame #2: 0x00000001003494c8 MacCleaner Pro 3`___lldb_unnamed_symbol22757 + 1116
    frame #3: 0x000000010019e750 MacCleaner Pro 3`___lldb_unnamed_symbol11841 + 480
    frame #4: 0x000000010007ae50 MacCleaner Pro 3`___lldb_unnamed_symbol5191 + 68
    frame #5: 0x000000018c27df44 AppKit`- + 460
    frame #6: 0x000000018c27dd48 AppKit`- + 72
    frame #7: 0x000000018c27dc8c AppKit`__26-_block_invoke + 100
    frame #8: 0x000000018c27dbb4 AppKit`- + 204
    frame #9: 0x000000018c27dad8 AppKit`- + 88
    frame #10: 0x000000018c27b108 AppKit`NSControlTrackMouse + 1480
    frame #11: 0x000000018c27ab14 AppKit`- + 144
    frame #12: 0x000000018c27a9cc AppKit`- + 488
    frame #13: 0x000000018c279ea0 AppKit`- + 448
    frame #14: 0x00000001003ea0ec MacCleaner Pro 3`___lldb_unnamed_symbol28251 + 76
    frame #15: 0x000000018c278c6c AppKit`- + 3472
    frame #16: 0x000000018c2043ec AppKit`- + 364
    frame #17: 0x000000018c2040ac AppKit`- + 284
    frame #18: 0x000000018c8c2f60 AppKit`- + 1604
    frame #19: 0x000000018c5135bc AppKit`- + 60
    frame #20: 0x000000018c0cc3a0 AppKit`- + 512
    frame #21: 0x000000018c0a3640 AppKit`NSApplicationMain + 880
    frame #22: 0x000000018849d0e0 dyld`start + 2360
(lldb)
```

我们对#1, #2, #3, #4进行分析

## 堆栈 #1 分析
```
frame #1: 0x000000010034f290 MacCleaner Pro 3`___lldb_unnamed_symbol22790 + 88
```
复制地址 `0x000000010034f290`,打开IDA后,按 `g` 输入地址进行跳转



跳转后部分伪代码如下
```c
if ( result ) {
    v5 = v4;
    v9 = result;
    v29 = sub_100341690();
    if ( (a2 & 1) != 0 ) {
      v11 = sub_100334E54(v10 != 0);    // <-------- #1 跳转到这里
    } else {
      v30 = 0LL;
      sub_100351E9C(&v30);
      v11 = sub_100335164(v30, *(v4 + 56), v12 != 0);
    }
```
从伪代码中发现,若 `(a2 & 1) != 0` 则走 `#1`
该处判断汇编如下
```
__text:000000010034F280    TBZ    W26, #0, loc_10034F2B4
```

给地址 `10034F280` 进行下断点,尝试修改跳转逻辑
```bash
br s -a 10034F280
```

打印出 `W26` 的值为1
```bash
(lldb) po $w26
1
```

尝试修改为0
```bash
(lldb) po $w26 = 0
<nil>

(lldb) c
```
修改无果,发现弹窗依旧,继续往 `#2` 进行分析

## 堆栈 #2 分析
```bash
frame #2: 0x00000001003494c8 MacCleaner Pro 3`___lldb_unnamed_symbol22757 + 1116
```

复制地址 `0x00000001003494c8`,打开IDA后,按 `g` 输入地址进行跳转

跳转后部分伪代码如下

```c
if ( a1 || sub_100351860(v16) < 2u ) {
    v17 = 1LL;
} else {
    sub_10034F238(a6, 1, 0LL, 0LL);
    v17 = 0LL;            // <-------- #2 跳转到这里
}
return a7(v17);
```

稍微有点意思了,从伪代码可以知道,最终 `v17 = 0`,`a7(0)`

也就是说,弹框时,`v17 = 0`,进入函数 `sub_100351860` 看看

```c
__int64 sub_100351860() {
_QWORD v1; // BYREF
__int64 v2; // BYREF

sub_100351B88(0xD000000000000014LL, 0x8000000100517F90LL);
sub_1003365A0(v2, v1);
if ( !v1 ) return 0LL;
sub_1003365E8(v1);
v2 = 0LL;
sub_100351E9C(v2);
if ( v2 > 0 ) return 1LL;
else return 2LL;
}
```

发现该函数的返回值有三个,分别为
- 0
- 1
- 2

结合刚才的伪代码得出如下结论

当返回值为 `2` 时,`v17 = 0`,弹框

我们可以大胆猜测一下,当返回值为 `0` 或者 `1` 时,`v17 = 1` 是不是就不弹框呢

与其大胆猜测,不如动手出真知

`#2` 伪代码判断汇编如下
```
__text:0000000100349160    CMP    W8, #2
```
对地址 `100349160` 进行断点

```bash
br s -a 0x100349160
```

此时打印 `W8` 的值可以看到为 `2`

```bash
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 4.1
    frame #0: 0x0000000100349160 MacCleaner Pro 3`___lldb_unnamed_symbol22757 + 244
MacCleaner Pro 3`___lldb_unnamed_symbol22757:
->0x100349160 <+244>: cmp    w8, #0x2
    0x100349164 <+248>: b.hs   0x1003494b0               ; <+1092>
    0x100349168 <+252>: mov    w0, #0x1
    0x10034916c <+256>: ldp    x20, x8,
Target 0: (MacCleaner Pro 3) stopped.

(lldb) po $w8
2
```

那就尝试把逻辑修改为不成立,即将 `W8` 修改为 `0` 或 `1`,这里,我们修改为 `1`

```bash
(lldb) po $w8 = 1
1
```

继续运行



哦?好像找到点了吗?当我们尝试修改 `W8` 的值为 `0` 或 `1` 时,程序并没有弹窗,而是进入了清除阶段,这是好事

那么到这里,我们确定 `sub_100351860` 返回 `0` 或 `1` 可以影响功能的使用,那到这里就结束了吗

## 堆栈 #3 分析
```bash
frame #3: 0x000000010019e750 MacCleaner Pro 3`___lldb_unnamed_symbol11841 + 480
```

复制地址 `0x000000010019e750`,打开IDA后,按 `g` 输入地址进行跳转

跳转后的函数如下,比较长,这里比较重要

```c
void *sub_10019E570() {
v1 = v0;
if ( *(v0 + 104) == 1 ) return sub_10019D664(v0);
if ( qword_100703BE0 != -1LL ) swift_once(&qword_100703BE0, sub_1001AC77C);
v3 = qword_100747BE8;
v4 = objc_retainAutoreleasedReturnValue(objc_msgSend(*(v0 + 80), "view"));
v5 = objc_retainAutoreleasedReturnValue(objc_msgSend(v4, "window"));
objc_release(v4);
v6 = OBJC_IVAR____TtC16MacCleaner_Pro_322BaseFeaturesController_licenseManager;
v7 = *(*(v3 + OBJC_IVAR____TtC16MacCleaner_Pro_322BaseFeaturesController_licenseManager) + 40LL);
v8 = *(*v7 + 200LL);
swift_retain(v7);
v9 = swift_retain_n(v1, 2LL);
LOBYTE(v8) = v8(v9);
swift_release(v7);
if ( (v8 - 1) < 3u || (v10 = *(*(v3 + v6) + 48LL),
      v11 = *(*v10 + 104LL),
      v12 = swift_retain(v10),
      LODWORD(v11) = v11(v12),
      swift_release(v10),
      v11 == 1) )
{
    sub_10019D664(v1);
    goto LABEL_8;
}
if ( NSApp ) {
    v14 = objc_retainAutoreleasedReturnValue(objc_msgSend(NSApp, "mainWindow"));
    if ( v14 ) {
      v15 = v14;
      v16 = objc_retainAutoreleasedReturnValue(objc_msgSend(v14, "nk_topSheetSelfIfNoSheets"));
      objc_release(v15);
      (*(**(v3 + v6) + 304LL))(
      0LL,
      0x6E5765766F6D6552LL,
      0xEC0000006E744264LL,
      0xD00000000000002FLL,
      0x80000001004E0D80LL,
      v16,
      sub_10019F1A8,
      v1);
      objc_release(v17);                // <-------- #3 跳转到这里
      swift_release_n(v1, 2LL);
      result = v16;
      goto LABEL_9;
    }
LABEL_8:
    swift_release_n(v1, 2LL);
    result = v13;
LABEL_9:
    objc_release(result);
    return result;
}
result = swift_release(v1);
__break(1u);
return result;
}
```

对函数进行初步分析,我们可以得知,`#3` 位于伪代码 `objc_release(v17);` 处

而想要走到这里

```c
if ( (v8 - 1) < 3u || (v10 = *(*(v3 + v6) + 48LL),
      v11 = *(*v10 + 104LL),
      v12 = swift_retain(v10),
      LODWORD(v11) = v11(v12),
      swift_release(v10),
      v11 == 1))
{
    sub_10019D664(v1);
    goto LABEL_8;
}
```

该判断就不应该成立,否则,程序无法走到 `#3`处

继续往上看伪代码,在这之前
```c
v7 = *(*(v3 + OBJC_IVAR____TtC16MacCleaner_Pro_322BaseFeaturesController_licenseManager) + 40LL);
v8 = *(*v7 + 200LL);
swift_retain(v7);
v9 = swift_retain_n(v1, 2LL);
LOBYTE(v8) = v8(v9);
swift_release(v7);
```

该段对应的汇编如下
```
__text:000000010019E610    LDR    X20,
__text:000000010019E614    LDR    X8,
__text:000000010019E618    LDR    X22,
__text:000000010019E61C    MOV    X0, X20
__text:000000010019E620    BL   _swift_retain
__text:000000010019E624    MOV    X0, X19
__text:000000010019E628    MOV    W1, #2
__text:000000010019E62C    BL   _swift_retain_n
__text:000000010019E630    BLR    X22
__text:000000010019E634    MOV    X22, X0
__text:000000010019E638    MOV    X0, X20
__text:000000010019E63C    BL   _swift_release
```

通过对该段汇编进行断点并打印相关信息得出如下结果

```bash
(lldb) po $x8
NKLicenseManager.NKLicenseManager
```

```bash
(lldb) po $x20
NKLicenseManager.LicenseStateStorage
```

由上述可得
- `X8` 为 `NKLicenseManager.LicenseStateStorage`
- `` 为 `NKLicenseManager.LicenseStateStorage` 下的某个属性

通过对``进行打印得出

```bash
(lldb) p/x $x8 + 0xc8
(unsigned long) 0x0000000100729060
```

IDA中跳转到该地址



发现一个关键函数`sub_10033F0FC`,但在这之前,我们先计算一下$x8的地址

已知 `$x8 + 0xc8 = 0x0000000100729060`

可得 `$x8 = 0x0000000100728f98`

跳转到地址 `0x0000000100728f98`



其实上面已经猜到了 `` 为 `NKLicenseManager.LicenseStateStorage` 下的某个属性

这里只是验证

结合接下来的伪代码和汇编可知

将 `W8` 减 `1` 后的值跟 `-1` 进行 `&` 运算,再将结果和 `3` 进行对比

```c
if ( (v8 - 1) < 3u || (v10 = *(*(v3 + v6) + 48LL),
      v11 = *(*v10 + 104LL),
      v12 = swift_retain(v10),
      LODWORD(v11) = v11(v12),
      swift_release(v10),
      v11 == 1))
{
    sub_10019D664(v1);
    goto LABEL_8;
}
```
```
__text:000000010019E640    SUB    W8, W22, #1
__text:000000010019E644    AND    W8, W8, #0xFF
__text:000000010019E648    CMP    W8, #3
```

此时 `W8` 的最终值为 255,比 `3` 大,所以,该处逻辑没走通,所以弹框

> 为什么 `W8` 的最终值为 255?
> 该处可通过对地址 `10019E648` 进行断点打印,不再赘述

综上所属,虽然在 `#2` 的堆栈分析中,我们知道函数 `sub_100351860` 的返回值 `0` 或 `1` 可以影响功能使用,但考虑到 `#3` 先运行于 `#2` ,所以函数`sub_10033F0FC`的返回值会影响判断逻辑,从而影响 `#2` 的执行

而当我们并非完整版时,`sub_10033F0FC`的返回值为 0,即 `W8` 为 0,所以,这时候,我们只需要将函数 `sub_10033F0FC` 的返回值修改为 1 即可达到解锁目的

还记得一开始我们埋下的伏笔吗

LLDB中输入 `r` 重启程序,程序断下来后,我们对函数 `sub_10033F0FC` 的返回值进行修改,该处可通过以下方法进行修改,本文仅使用LLDB进行临时修改测试
- FridaHook
- 静态注入
- 修改二进制

输入

```bash
memory write 10033F0FC 20 00 80 D2 C0 03 5F D6
```

回车后,输入`c`继续运行程序



可以看到,原本左下角的绿色按钮 "解锁完整版" 就不存在了,测试一下功能



可以看到功能也是正常的,到这里,我们就完成了对功能的解锁分析

真的结束了吗,好像并没有,还记得启动的时候还有一个小弹窗吗



**《您的免费试用已结束》**

# 免费试用弹窗分析
搜索对应字符串



到Hopper进行字符串搜索





进入函数 `sub_100345a08`

跳转地址为 `100345a2c`,老规矩断点,看堆栈
```bash
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
* frame #0: 0x0000000100345a2c MacCleaner Pro 3`___lldb_unnamed_symbol22677 + 36
    frame #1: 0x0000000100335300 MacCleaner Pro 3`___lldb_unnamed_symbol22334 + 412
    frame #2: 0x000000010034f2d8 MacCleaner Pro 3`___lldb_unnamed_symbol22790 + 160
    frame #3: 0x00000001003502a8 MacCleaner Pro 3`___lldb_unnamed_symbol22815 + 492
    frame #4: 0x0000000100217b88 MacCleaner Pro 3`___lldb_unnamed_symbol14926 + 236
    frame #5: 0x0000000100153758 MacCleaner Pro 3`___lldb_unnamed_symbol10053 + 672
    frame #6: 0x0000000100153860 MacCleaner Pro 3`___lldb_unnamed_symbol10054 + 100
    frame #7: 0x00000001888f0500 CoreFoundation`__CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__ + 148
    frame #8: 0x000000018898462c CoreFoundation`___CFXRegistrationPost_block_invoke + 88
    frame #9: 0x0000000188984574 CoreFoundation`_CFXRegistrationPost + 440
    frame #10: 0x00000001888bf16c CoreFoundation`_CFXNotificationPost + 764
    frame #11: 0x00000001899b3f50 Foundation`- + 88
    frame #12: 0x000000018c0e08ac AppKit`- + 284
    frame #13: 0x000000018c0e065c AppKit`- + 172
    frame #14: 0x000000018c0deba4 AppKit`- + 504
    frame #15: 0x000000018c0de7a0 AppKit`- + 492
    frame #16: 0x00000001899dc374 Foundation`- + 316
    frame #17: 0x00000001899dc168 Foundation`_NSAppleEventManagerGenericHandler + 80
    frame #18: 0x000000018f866dc0 AE`___lldb_unnamed_symbol866 + 1624
    frame #19: 0x000000018f8666e8 AE`___lldb_unnamed_symbol865 + 44
    frame #20: 0x000000018f85fcf8 AE`aeProcessAppleEvent + 488
    frame #21: 0x0000000192ed22d4 HIToolbox`AEProcessAppleEvent + 68
    frame #22: 0x000000018c0d91dc AppKit`_DPSNextEvent + 1440
    frame #23: 0x000000018c8c3eec AppKit`- + 716
    frame #24: 0x000000018c0cc37c AppKit`- + 476
    frame #25: 0x000000018c0a3640 AppKit`NSApplicationMain + 880
    frame #26: 0x000000018849d0e0 dyld`start + 2360
```
那么我们重点关注 `#1` 到 `#6`

经过分析,在 `#4` 的时候,发现非常熟悉的代码
```
frame #4: 0x0000000100217b88 MacCleaner Pro 3`___lldb_unnamed_symbol14926 + 236
```

复制地址 `0x0000000100217b88`,打开IDA后,按 `g` 输入地址进行跳转
部分伪代码如下
```c
if ( qword_1007039F8 != -1 ) swift_once(&qword_1007039F8, sub_1000AE644);
v0 = qword_10070BC80;
v1 = OBJC_IVAR____TtC16MacCleaner_Pro_322BaseFeaturesController_licenseManager;
v2 = qword_100703BE0;
v3 = objc_retain(qword_10070BC80);
if ( v2 != -1 ) {
    swift_once(&qword_100703BE0, sub_1001AC77C);
    v1 = OBJC_IVAR____TtC16MacCleaner_Pro_322BaseFeaturesController_licenseManager;
}
v4 = *(*(qword_100747BE8 + v1) + 40LL);
v5 = *(*v4 + 200LL);
```
再看看汇编
```
__text:0000000100217AF0 88 29 00 90    ADRP    X8, #qword_100747BE8@PAGE
__text:0000000100217AF4 17 F5 45 F9    LDR   X23,
__text:0000000100217AF8 E8 6A 78 F8    LDR   X8,
__text:0000000100217AFC 14 15 40 F9    LDR   X20,
__text:0000000100217B00 88 02 40 F9    LDR   X8,
__text:0000000100217B04 18 65 40 F9    LDR   X24,
__text:0000000100217B08 E0 03 14 AA    MOV   X0, X20
```

这跟刚才我们分析了半天的功能解锁有什么区别

由 `#4` 的地址跳转到上述代码的下半部分其中一行
```c
objc_release(v3);
```
而 `v3` 的来源是
```c
v3 = objc_retain(qword_10070BC80);
```
结合汇编代码
```
__text:0000000100217AE4 F6 03 00 AA    MOV    X22, X0
```
可得,`X0` 为
```bash
(lldb) po $x0
<MacCleaner_Pro_3.MainWindow: 0x1401061d0>
```

综上所述,此时主窗口已经加载,这里没有可检查的必要,我们继续回溯到 `#5`
```bash
frame #5: 0x0000000100153758 MacCleaner Pro 3`___lldb_unnamed_symbol10053 + 672
```
通过跳转地址后发现
```c
if ( qword_100703CF8 != -1 ) swift_once(&qword_100703CF8, sub_1002979F8);
if ( qword_100703B58 != -1 ) swift_once(&qword_100703B58, sub_1001740C0);
if ( qword_100703BE0 != -1 ) swift_once(&qword_100703BE0, sub_1001AC77C);
sub_100217A9C();
swift_release(v34);             // <-------- #5 跳转到这里
```

注意到这里`#5`上的一个函数 `sub_100217A9C`

`#4` 的地址值为 `100217b88`
`#5` 的地址值为 `100153758`

而这个`sub_100217A9C`刚好夹在`#4`和`#5`之间,你觉得这真的没有问题吗

那我们就关注一下这个函数 `sub_100217A9C`

对其进行 交叉引用查看,发现有个非常熟悉的东西



```c
void __cdecl -(
      _TtC16MacCleaner_Pro_322BaseFeaturesController *self,
      SEL a2)
{
_TtC16MacCleaner_Pro_322BaseFeaturesController *v2; // x19

v2 = objc_retain(self);
sub_100217A9C();
objc_release(v2);
}
```

怎么这里也在执行这个函数?那很难令人不怀疑,进入这个函数后,前半部分懒得赘述了,直接看后半部分

```c
v10 = sub_10017F754(v8);
v12 = v11;
(*(*v13 + 376LL))('hcnuaL', '\xE6\0\0\0\0\0\0\0', v10, v11, v0, 1LL);
objc_release(v3);
return swift_bridgeObjectRelease(v12);
```

看起来感觉是在启动某些东西

此时,有两个选择

```c
(*(*v13 + 376LL))('hcnuaL', '\xE6\0\0\0\0\0\0\0', v10, v11, v0, 1LL);
```
1. 直接找到上述位置修改二进制nop掉

2. 把函数`sub_100217A9C`整个ret掉

我的选择是`2`,毕竟我没看到这个函数有什么作用

```bash
memory write 100217A9C 20 00 80 D2 C0 03 5F D6
```

这时,我们发现



弹窗没有了,功能也是正常的,到此,我们就完成了对 MacCleaner Pro的学习分析

# 总结
入口点
```bash
br s -a 100014e34
```

单独解锁功能,不去除弹框,不去除 "解锁完整版" 按钮
```bash
memory write 100351860 20 00 80 D2 C0 03 5F D6
# 或者
memory write 100351860 00 00 80 D2 C0 03 5F D6
```

移除 "解锁完整版" 按钮并解锁功能
```bash
memory write 10033F0FC 20 00 80 D2 C0 03 5F D6
```

移除 "您的免费试用已结束" 弹窗
```bash
memory write 100217A9C 20 00 80 D2 C0 03 5F D6
```

X1a0He 发表于 2024-2-28 14:58

经验证,利用该分析方法,还适用于Nektony旗下的App
1. MacCleaner Pro
2. App Cleaner & Uninstaller
3. Duplicate File Finder
4. Disk Space Analyzer

diaosini 发表于 2024-3-2 16:52

本帖最后由 diaosini 于 2024-3-2 19:07 编辑

同样是M1芯片,经过复现 br s -a 100334ecc 一直无法断,咋回事

```
$ lldb /Applications/MacCleaner\ Pro\ 3.app
(lldb) target create "/Applications/MacCleaner Pro 3.app"
Current executable set to '/Applications/MacCleaner Pro 3.app' (arm64).
(lldb) br s -a 100014e34
Breakpoint 1: address = 0x0000000100014e34
(lldb) br s -a 100334ecc
Breakpoint 2: address = 0x0000000100334ecc
(lldb)
```

wwbhl 发表于 2024-10-19 07:05

本帖最后由 wwbhl 于 2024-10-19 20:07 编辑

直接memory write 100173038 写让这个函数sub_100173038 返回 1 处理不掉弹窗。图片里断点间 nop 掉可以解决问题,伪代码如下,请教一下如何用 frida 处理它,或者 使用memory write(已解决)附上答案和截图memory write 10017336c 1F 20 03
D5

zhuhuaicheng 发表于 2024-2-28 07:48

学习了一下

timoer 发表于 2024-2-28 09:13

牛,学习学习。

clyy 发表于 2024-2-28 10:23

大佬,学习一下。厉害了

Bruce_HD 发表于 2024-2-28 11:29

好久没看文章了,写的不错。

gaoyanchen 发表于 2024-2-28 11:45

xiaohe 太强了,收获很大,感谢发帖

WPF0414 发表于 2024-2-28 12:10

大师兄牛逼

gaoyanchen 发表于 2024-2-28 12:48

在ARM64架构中(例如在iOS或者某些Android设备上),这串数据对应的汇编指令是:
20 00 80 D2 对应 mov x0, #0x1,意思是将寄存器 x0 的值设置为1。
C0 03 5F D6 对应 ret,表示函数返回指令

L__ 发表于 2024-2-28 13:42

谢谢分享这么实在的文章

zkyaoyahui 发表于 2024-2-28 13:47

牛X,学习了
页: [1] 2 3 4
查看完整版本: MacCleaner Pro 分析学习