uusama 发表于 2024-10-31 15:36

萌新的沪江小D词典app接口破解流程

本帖最后由 uusama 于 2024-11-6 10:00 编辑



## 说明

**注意:经过评论区老哥提醒,刚测试了一下,沪江小D这个APP已经被下架,相关接口的服务都不能用了,很可惜,所以不能复现这个流程了。**

其实这是今年早些时候弄的,当时为了学日语方便,刚接触移动端逆向,很多东西都还很懵懂,走了很多弯路。这次整理一下整个流程,希望能对和我一样的萌新有帮助,也欢迎和大家交流。

## 准备工具

- Android模拟器:[逍遥模拟器](https://www.xyaz.cn/)
- Anroid抓包代.理软件: ((https://drony.cn.uptodown.com/android))
- PC抓包软件: (https://www.telerik.com/download/fiddler)
- apk反编译工具: (https://github.com/skylot/jadx/releases)
- Android so库反编译工具:IDA Pro,站内有,这儿就不放链接了

相关工具以及破解的apk包可以通过百度云获取: <https://pan.baidu.com/s/1CjdDkXgVGJpMToXuc_8SRQ>,提取码`uurr`。

## 沪江小D词典

首先安装好模拟器,apk包,还有抓包工具。需要在Android配置根证书才能抓HTTPS的包,站内应该有不少抓包的教程,不行我回头补一篇。

进入沪江小D词典app以后,可以直接点击试用,不用注册。

沪江小D查单词分两个接口,一个是弹窗快速查询接口`/v10/quick/en/cn`,一个是搜索栏的查询详情接口`/v10/dict/en/cn`。



## 弹窗快速查询接口`/v10/quick/en/cn`分析

在Fiddler右边窗口中点击`Composer`,然后把请求拖过去可以看到请求的完整结构,包括请求头,请求body。

首先看`/v10/quick/en/cn`弹窗查词接口,`POST`请求,参数就`word`参数,返回值也很清晰,就是一个json结构。但是注意到请求头中有两个头`hujiang-appkey`和`hujiang-appsign`。从名称来开,`hujiang-appkey`是一个定值,但是`hujiang-appsign`是一个签名,而且不知道是如何签名的。



可以通过在postman中构造这个请求,主要填入下面几个关键信息:

- 请求方式为`POST`,请求URL为`https://dict.hjapi.com/v10/quick/en/cn`
- 请求参数为`webForm`格式,并且填入参数`word=seven`
- 填入两个头`hujiang-appkey`和`hujiang-appsign`

可以得到返回值,并且修改参数`word`以后,不能获取返回值,那么请求头中的`hujiang-appsign`签名构造比如包含参数`word`,而且看签名结构可以大胆假设其就是`md5`签名,接下来需要弄清楚参与签名的字符串。

## 查询详情接口`/v10/dict/en/cn`分析

另外看详情查询接口`/v10/dict/en/cn`,其请求参数包含两个参数`word`和`word_ext`,请求头同样有`hujiang-appkey`和`hujiang-appsign`。最恶心的是其返回值json的data域是加密过的。



而且在postman中构造请求时,如果修改`word_ext`的值,则查不到结果,说明签名参数中还包含了`word_ext`。

## 抓包分析总结

通过上面的抓包,基本确认了几个点:

- 单词查询接口没有做特殊的用户限制,没有cookie也可以查询
- 重要参数为`word`和`word_ext`,其含义为待查询的单词
- 另外参数中的`/en/cn`表示根据英文查中文,通过切换app可以得到`/jp/cn`表示根据日文查中文
- 需要弄清楚请求头中的`hujiang-appsign`签名规则
- 需要弄清楚`/v10/dict/en/cn`接口返回值的解密方式

## 反编译apk

通过上面的抓包分析,我们还是不能构造和复用沪江的查词接口,换一个词我们就查不了,而且关键的详情查询接口有更多我们需要的数据,但是返回值我们无法解析。

针对上面这两点,只能通过反编译apk来看代码实现了。

反编译也很简单,打开`jadx`软件,首先选择`文件 -> 首选项`,勾选`反混淆`,因为apk代码一般是混淆过的,启用反混淆更方便搜索代码。

反编译之后,可以通过搜索接口来定位关联代码。

## `/v10/quick/en/cn`反编译代码实现分析

直接搜索完整的url是没有结果的,说明这个url在代码里面是动态拼接的,尝试搜索`/v10/quick`,运气很好,只有一个匹配的代码。



双击进入包含`/v10/quick`所在的代码文件,可以看到这儿是定义了一个常量`URL_LINK`。



继续搜索常量`URL_LINK`看在哪些地方被调用,找到发送HTTP请求的位置。



可以看到搜索结果都是在`com.hujiang.supermenu.client.API`这个类中,很显然这个类就是HTTP请求的构造类了。点进这个类可以看到常量`URL_LINK`在两个方法中引用。



```java
public static void translateWord(Context context, String str, String str2, String str3, AbstractC12224a<String> aVar) {
    String format = String.format(getURL() + URL_LINK, str, str2);
    C19398b.m141a("划词API" + format);
    ((C12233h) ((C12233h) ((C12233h) ((C12233h) ((C12233h) new C12233h(context).m32079L(C12189g.f39935o)).m32058d("hujiang-appkey", APP_KEY)).m32058d("hujiang-appsign", getAppSign(str, str2, str3.trim()))).m32047j("word", str3)).m32051g0(format)).m32041p(String.class, aVar);
}

/* renamed from: d */
// 双击this.f40105o可以看到其定义为 protected final Map<String, String> f40105o = new HashMap();
public R m32058d(String str, String str2) {
    this.f40105o.put(str, str2);
    return this;
}

// 签名函数
public static String getAppSign(String str, String str2, String str3) {
    return md5(String.format(SIGN, str, str2, str3, "", APP_SECRET));
}

public static String md5(String str) {
    try {
      byte[] digest = MessageDigest.getInstance("MD5").digest(str.getBytes("UTF-8"));
      StringBuilder sb = new StringBuilder(digest.length * 2);
      for (byte b : digest) {
            int i = b & 255;
            if (i < 16) {
                sb.append("0");
            }
            sb.append(Integer.toHexString(i));
      }
      return sb.toString();
    } catch (UnsupportedEncodingException e) {
      throw new RuntimeException("Huh, UTF-8 should be supported?", e);
    } catch (NoSuchAlgorithmException e2) {
      throw new RuntimeException("Huh, MD5 should be supported?", e2);
    }
}
```

很显然方法`translateWord`就是构造http请求的地方,而且注意看`String.format(getURL() + URL_LINK, str, str2)`用于构造url,注意到最后一行代码中反复出现的那个方法,这儿是`m32058d`(可能反混淆成其他名称),就是往`HashMap`中加`key-value`,进而设置`hujiang-appkey`和`hujiang-appsign`的请求头。

注意到其中的`m32058d("hujiang-appsign", getAppSign(str, str2, str3.trim())))`就是我们想要的签名构造方法。

先不要急着跳转到`getAppSign`函数中,我们先弄清楚这儿的三个参数分别是什么。直接看这个函数内部的实现是不能分析三个参数的含义的。

从URL的构造`String.format(getURL() + URL_LINK, str, str2)`可以看错,`str`就是翻译的原语言`en`,`str2`是翻译的目标语言`cn`,从`m32047j("word", str3)`可以推测`str3`就是查询的单词。弄清楚参数含义可以点进签名函数`getAppSign`,其实现很显然是`md5`,并且签名串构造为`SIGN = "FromLang=%s&ToLang=%s&Word=%s&Word_Ext=%s%s"`。

到此就可以弄清楚弹窗查词的整个请求,然后可以写`python`爬虫实现了。

```python
import json
import requests


def query_hujiang_word(word: str, from_lang='cn', to_lang='jp') -> dict:
    api_url = 'http://dict.hjapi.com/v10/quick/{}/{}'.format(from_lang, to_lang)
    word_ext = ''
    app_secret = '3be65a6f99e98524e21e5dd8f85e2a9b'
    sign_str = 'FromLang={}&ToLang={}&Word={}&Word_Ext={}{}'\
      .format(from_lang, to_lang, word, word_ext, app_secret).encode(encoding='UTF-8')
    response = requests.post(
      api_url,
      data={
            "word": word,
            "word_ext": None
      },
      headers={
            "User-Agent": u_file.COMMON_USER_AGENT,
            "hujiang-appkey": "b458dd683e237054f9a7302235dee675",
            "hujiang-appsign": hashlib.md5(sign_str).hexdigest()
      },
    )
    log.info('end get info from web url: ' + api_url)
    if not (400 <= response.status_code < 500):
      response.raise_for_status()
    if response.text is None or response.text == '':
      log.error('The response text is empty.')
    query_result = json.loads(response.text)
    if 'data' not in query_result or query_result.get('status', -1) != 0:
      log.error('The response is not valid: {}'.format(response.text))
      return {}
    return query_result['data']
```

结语:可以说整个分析过程是很简单的,中途也没有遇到其他卡住的难题,我一度以为接下来的分析也会很顺利,然而,令我恐惧的在后面。

## `/v10/dict/en/cn`反编译代码请求构造实现分析

和上面一样的流程,同样是`jadx`反编译后直接搜索关键词`/v10/dict`,结果很多,但是不用慌,大部分一眼就可以排除掉。



从结果可以很容易找到目标代码,就是截图中那一行,其他的都是别的请求url,我们要找的是拼接路径,很容易排除。

双击进入目标代码,可见整个目标类都已经被混淆,不像`/v10/quick`弹窗查词接口那样简单从类名和方法名就可以知道结果。不过也不用急。



```java
public static void m40446y(String str, String str2, String str3, String str4, AbstractC12224a<JsonModel> aVar) {
    if (str4 != null) {
      String trim = str4.trim();
      if (str.equals("cn")) {
            trim = ZHConverter.convert(trim, 1);
      }
      // hVar是一个HttpClient实例
      C12233h hVar = (C12233h) new C12233h(C11627h.m33736x().m33749k()).m32058d(f31569u, C11132q0.m35609s());
      String d = m40467d(str, str2, trim, str3, "3be65a6f99e98524e21e5dd8f85e2a9b");
      ((C12233h) ((C12233h) ((C12233h) ((C12233h) hVar.m32049h0(C9643a.m40497p(), "/v10/dict/" + str + "/" + str2)).m32058d("hujiang-appkey", "b458dd683e237054f9a7302235dee675")).m32058d("hujiang-appsign", d)).m32047j("word", trim)).m32079L(C12189g.f39935o);
      C11110j.m35802l("getWordDetail", hVar.m32087A());
      long currentTimeMillis = System.currentTimeMillis();
      long abs = Math.abs(new Random().nextLong());
      String valueOf = String.valueOf(abs);
      String hexString = Long.toHexString(abs);

      // m32058d 为设置http的请求头
      hVar.m32058d("X-B3-SpanId", hexString);
      hVar.m32058d("X-B3-TraceId", hexString);
      hVar.m32058d("X-B3-Sampled", "1");
      if (!TextUtils.isEmpty(str3)) {
            hVar.m32047j("word_ext", str3);
      }

      // 参数 aVar 是http回调处理类实例
      ((C12233h) new C9649d(hVar).m40484b()).m32041p(JsonModel.class, new C9670l(valueOf, currentTimeMillis, hVar, aVar));
    }
}

// 签名参数构造
private static String m40467d(String str, String str2, String str3, String str4, String str5) {
    String str6 = "FromLang=" + str + "&ToLang=" + str2 + "&Word=" + str3 + "&Word_Ext=";
    if (!TextUtils.isEmpty(str4)) {
      str6 = str6 + str4;
    }
    return m40466e(str6 + str5);
}
```

可以右键函数或者类名进行改名,也可以添加注释,这样方便分析代码。

首先分析请求构造方法,其中的`C12233h`类型可以点进去看,通过父类看到包含一些诸如HTTP请求的参数,显然是一个`HttpRequestClient`类似的封装。

`m32058d`函数是添加请求头的`key-value`,其中请求头`hujiang-appsign`签名参数赋值变量`d`,而`d = m40467d(str, str2, trim, str3, "3be65a6f99e98524e21e5dd8f85e2a9b")`。此处的`str=en`,`str2=cn`,`str2=trim`是查询单词,并且看上面有个逻辑如果查询的是中文则要转义一下,`str3`就是`word_ext`参数。进入签名方法看其实现:

```java
private static String m40467d(String str, String str2, String str3, String str4, String str5) {
    String str6 = "FromLang=" + str + "&ToLang=" + str2 + "&Word=" + str3 + "&Word_Ext=";
    if (!TextUtils.isEmpty(str4)) {
      str6 = str6 + str4;
    }
    return m40466e(str6 + str5);
}

/* renamed from: e */
private static String m40466e(String str) {
    try {
      return m40471A(MessageDigest.getInstance("MD5").digest(str.getBytes()));
    } catch (NoSuchAlgorithmException e) {
      C11110j.m35811c("", "", e);
      return null;
    }
}
```

显然还是`md5`算法,并且签名串和`v10/quick`上面的一致,都是`FromLang={}&ToLang={}&Word={}&Word_Ext={}{app_secret}`。

同样用python实现请求,代码和上面的一致,只是url不一样,可以获取到json返回结果,显然请求构造破解完毕。

接下来就是重头戏,返回值的解密。

## `/v10/dict/en/cn`反编译代码返回值解密实现分析

继续看上面定位到的`/v10/dict`请求构造函数,注意到最后一行`((C12233h) new C9649d(hVar).m40484b()).m32041p(JsonModel.class, new C9670l(valueOf, currentTimeMillis, hVar, aVar));`。上面已经分析过`C12233h`是一个`HttpRequestClient`封装。

这个地方注意前面都是类型转换成`C12233h`,然后调用方法`m32041p`,这个方法两个参数,第一个参数是一个class类型参数`JsonModel.class`,第二个参数新建一个实例`C9670l`。找到这两个关键类型的定义:

```java
public class JsonModel extends C9645b<String> {
}

public class C9645b<T> {
    public static final int STATUS_SUCCESS = 0;
    private T data;
    private String message;
    private int status;
    ...
    ...
}

public static class C9670l extends AbstractC12224a<JsonModel> {

    /* renamed from: a */
    final /* synthetic */ String f31612a;

    /* renamed from: b */
    final /* synthetic */ long f31613b;

    /* renamed from: c */
    final /* synthetic */ C12233h f31614c;

    /* renamed from: d */
    final /* synthetic */ AbstractC12224a f31615d;

    C9670l(String str, long j, C12233h hVar, AbstractC12224a aVar) {
      this.f31612a = str;
      this.f31613b = j;
      this.f31614c = hVar;
      this.f31615d = aVar;
    }

    /* renamed from: onFailreason: avoid collision after fix types in other method */
    public void onFail2(int i, JsonModel jsonModel, Map<String, String> map, boolean z, long j, String str) {
      C9092b.m42463g().mo7827h(C11627h.m33736x().m33749k(), this.f31612a, this.f31613b, System.currentTimeMillis(), this.f31614c.m32087A(), i, "POST", str);
      this.f31615d.onFail(i, jsonModel, map, z, j, str);
    }

    @Override // com.hujiang.restvolley.webapi.AbstractC12224a
    public /* bridge */ /* synthetic */ void onFail(int i, JsonModel jsonModel, Map map, boolean z, long j, String str) {
      onFail2(i, jsonModel, (Map<String, String>) map, z, j, str);
    }

    /* renamed from: onSuccessreason: avoid collision after fix types in other method */
    public void onSuccess2(int i, JsonModel jsonModel, Map<String, String> map, boolean z, long j, String str) {
      C9092b.m42463g().mo7827h(C11627h.m33736x().m33749k(), this.f31612a, this.f31613b, System.currentTimeMillis(), this.f31614c.m32087A(), i, "POST", str);
      this.f31615d.onSuccess(i, jsonModel, map, z, j, str);
    }

    @Override // com.hujiang.restvolley.webapi.AbstractC12224a
    public /* bridge */ /* synthetic */ void onSuccess(int i, JsonModel jsonModel, Map map, boolean z, long j, String str) {
      onSuccess2(i, jsonModel, (Map<String, String>) map, z, j, str);
    }
}

public abstract class AbstractC12224a<T> {
    protected Exception mException;

    public Exception getException() {
      return this.mException;
    }

    public abstract void onFail(int i, T t, Map<String, String> map, boolean z, long j, String str);

    public void onFinished(AbstractC12235j jVar) {
    }

    public void onStart(AbstractC12235j jVar) {
    }

    public abstract void onSuccess(int i, T t, Map<String, String> map, boolean z, long j, String str);

    public void setException(Exception exc) {
      this.mException = exc;
    }
}
```

注意到其中的`JsonModel`继承自`C9645b<T>`,并且该类`C9645b`包含`data`,`status`,`message`三个参数,显然就是返回值的`json`结构。

另外注意到`C9670l`的父类`AbstractC12224a<JsonModel>`是一个抽象类,并且定义了`onFinished`,`onSuccess`这样的方法,而且包含泛型参数`JsonModel`,显然是一个HTTP请求的回调处理类。那么就要注意`onSuccess`方法的实现,其中有对返回值的处理,`data`的解密就在其中。

回到实现类`C9670l`,注意其中的`onSuccess`方法实现,直接调用了`onSuccess2`,其中的核心实现为`this.f31615d.onSuccess(i, jsonModel, map, z, j, str)`,调用了`this.f31615d`的请求成功处理函数,而`this.f31615d`在`C9670l`的构造函数中赋值。

而`C9670l`的构造在上层中:`new C9670l(valueOf, currentTimeMillis, hVar, aVar)`,比对参数发现`this.f31615d`就是这儿的`aVar`,而`aVar`是由上层传入的。



为了弄清楚`aVar`的值或者类型,我们搜索`m40446y`这个方法的调用:



可以看到除了函数声明外,有三个调用的地方,依次点开。

首先第一个调用:`C9658c.m40446y(localReviewWord.getFromLan(), localReviewWord.getToLan(), str2, localReviewWord.getWord(), new C9810a(localReviewWord));`显然`aVar= new C9810a(localReviewWord)`,其中`localReviewWord`是一个`localReviewWord`类型,里面是很复杂的词汇结构,显然是app端展示用的已经解密的数据结构。

再点进`C9810a`类,这是一个http请求返回值的回调处理类。



```java
/* renamed from: onSuccessreason: avoid collision after fix types in other method */
public void onSuccess2(int i, JsonModel jsonModel, Map<String, String> map, boolean z, long j, String str) {
    C11106i.m35822a("OnlineApi.postWordDetail" + this.f32035a.getWordServerRawId(), true);
    if (this.f32035a.getmRememberTimes() != 2) {
      C11110j.m35812b(C9808c.f32015i, "word setDate: " + this.f32035a.getWord());
      if (jsonModel != null && !TextUtils.isEmpty(jsonModel.getData())) {
            String data = jsonModel.getData();
            if (!TextUtils.isEmpty(data)) {
                try {
                  WordEntryResultDict wordEntryResultDict = (WordEntryResultDict) C11152w.m35489a(C9658c.m40470a(0, data, true), WordEntryResultDict.class);
                  List<WordEntry> wordEntries = wordEntryResultDict.getWordEntries();
                  if (wordEntries != null && wordEntries.get(0) != null && wordEntries.get(0).getDict(1) != null) {
                        this.f32035a.setContent(wordEntryResultDict);
                        this.f32035a.setContentFrom(3);
                  }
                } catch (Exception unused) {
                  C11110j.m35806h(C11155z.f36759w);
                }
            }
      }
    }
}

```

注意其中的`onSuccess2`方法显然是http请求成功的回调处理,注意到其中通过`jsonModel.getData()`获取返回值的`data`字段,然后通过代码`(WordEntryResultDict) C11152w.m35489a(C9658c.m40470a(0, data, true), WordEntryResultDict.class);`直接将加密的`data`字符串转成了`WordEntryResultDict`词汇详情结构。

显然解密就是在这句代码实现的,外层的`C11152w.m35489a`方法实现是把`string`转成`json`,点进入`C9658c.m40470a`方法的实现,显然就是解密函数了。



这儿用`android.util.Base64`进行解密,其中的参数`0`表示没有`wrap`,查询其源码可知。然后调用了`OfflinewordAPI.decodeAndUnzip`静态方法,然而这是一个`native`库方法,我们知道Android可以通过JNI NDK来实现这种native的方法,而其代码已经编译到so动态链接库中。



事情开始变得麻烦了!

## 反编译so获取解密密钥和实现

native的方法是通过JNI实现,只能去so库中寻找了,然而so是动态链接库,很多人会在这儿放弃吧,然而已经花了这么多时间看混淆代码,怎么可以轻言放弃!

继续看`OfflinewordAPI`这个类,可以看到上面有加载so库的代码`System.loadLibrary("decodermarker")`,看so库的名称,猜测解密函数的实现在`decodermarker`这个库中,还原路径就是apk包中的`lib/libdecodermarker.so`文件。



可以通过下面的方式提取到这个so文件:

1. 将`apk`包后置名改为`zip`然后解压
2. 使用`apktool`工具解压
   1. 点击[`apktool`](https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/windows/apktool.bat)页面直接另存为`apktool.bat`
   2. [点击页面](https://bitbucket.org/iBotPeaches/apktool/downloads/)下载最新版本的jar包并重命名为`apktool.jar`
   3. 将上面两个文件放在同一个文件加
   4. 可以选择配置刚才的文件夹为环境变量,或者直接在该文件夹下`cmd`打开控制台
   5. 执行命令`apktool b hujiang-dict.apk`解压
3. 直接`jadx`另存项目提取文件
4. 其他工具解压

这儿直接取`armeabe-v7a`架构下的`libdecodermarker.so`文件。

拿到so文件以后,打开下载好的`IDA`,不要打开`IDA64.exe`,64位版不支持将汇编反编译为C语言,不方便阅读源码。

打开时选择Load File `ELF for ARM(Shared object)`。其他的配置可以不用修改。



这个so文件不大,打开后首先看最左侧的`Function`一栏,可以看到`AES_CBC`的一些函数,可以大胆猜测使用了`AES_CBC`的解密方式,如果是用这种解密方式,就要找到`key`和`iv`了。不过先不急。

首先在左侧`Function Window`按`Ctrl+F`搜索`native`方法`decodeAndUnzip`找到其入口。native方法绑定的方式是包名称加方法名,很容易定位到,然后按`F5`可以将汇编反编译为`C`语言。

通过`Option -> General -> Auto Comments`可以打开汇编指令自动注解功能。



可见`Java_com_hujiang_offlineword_OfflinewordAPI_decodeAndUnzip`这个方法的实现,点击`return`语句中调用的方法,一路嵌套下来:

`j_j_decodeAndUnzip_0` -> `j_decodeAndUnzip` -> `off_16DB8`

到`off_16DB8`这个地址标记位置:



双击其中的函数`decodeAndUnzip`然后按`F5`反编译为`C`语言。



这个函数就是解密实现的核心逻辑了,注意到其中的函数调用`HJCryptoManager::decryptAndInflate`,双击跳转到该函数地址。

其中是一个嵌套函数,一直跟着函数地址跳转。

`HJCryptoManager::decryptAndInflate` -> `off_16DC4` -> `_ZN15HJCryptoManager17decryptAndInflateEiPhiPS0_Pii+1` -> `F5`

可以看到调用解密函数。



我寻寻觅觅,在汇编海洋中逐渐迷失了,根本找不到密钥在哪儿!!

换一种思路,比如动态调试,现在我不是已经知道函数入口了吗?能不能在Python种调用so库函数呢?模拟Android环境。

查了一下,还真有,有一个python库,(https://github.com/P4nda0s/AndroidNativeEmu),支持在python中调用so库中的方法,整个完全模拟Android环境,文档参考:<https://github.com/P4nda0s/AndroidNativeEmu/blob/master/README_cn.md>。

激动啊!

赶紧把代码clone到本地,照着文档中的方法,本地的python是3.7版本和文档匹配,直接安装依赖`pip install -r requirements.txt`,本来一切正常,但是到`keystone-engine`这个库的时候好像有报错。不过后面好像成功安装了,先不管了。

将项目导入到Pycharm中后,提示`androidemu`包找不到,只需要右键项目,选择`Mark Directory as Source Root`即可。

直接运行`samples`中的示例,结果肯定是运行不了。没办法,只能重新安装`keystone-engine`,仔细看了下报错,好像是版本的问题。

尝试着先把已经按照的库删掉`pip uninstall keystone-engine`,然后再重新安装`pip install keystone-engine`,居然没报错成功了!谢天谢地!

重新运行`samples`下的示例,居然跑起来!!!LUCKY!!

好的,接下来把我们的库移动进去,然后改一下`example_douyin.py`代码,首先定义一个目标类`OfflinewordAPI`对应上JNI里面的类,然后加载库。

```python
import logging
import posixpath
import sys

from unicorn import UcError, UC_HOOK_CODE, UC_HOOK_MEM_UNMAPPED
from unicorn.arm_const import *

from androidemu.emulator import Emulator
from androidemu.java.java_class_def import JavaClassDef
from androidemu.java.java_method_def import java_method_def

from samples import debug_utils

class OfflinewordAPI(metaclass=JavaClassDef, jvm_name='com/hujiang/offlineword/OfflinewordAPI'):
    def __init__(self):
      pass

    @java_method_def(name='leviathan', signature='(I[B)[B', native=True)
    def decodeAndUnzip(self, mu):
      pass


emulator.load_library("example_binaries/libdecodermarker.so")
```

先运行一下有没有问题,结果库加载不了,总是报各种莫名奇妙的问题,有可能是这个so库有问题,或者架构不兼容,或者其他的,而且定义`OfflinewordAPI`的时候,方法里面的`signature`也不知道写啥。

查了半天文档,找不到任何的线索,而且这个项目貌似star也不多,可能不行吧,放弃此路吧,还是找到密钥更爽。

劝劝自己,还是回去看汇编代码吧!继续下去!不能半途而废啊!它这个so库也没加壳没做安全处理,应该很容易找到的。

所以,又回到了`IDA`。这一次仔细研究了一下IDA的功能,首先是最上面的工具栏,能看到`Structures`,`Enums`,`Imports`,`Exports`等。然后之前在查资料的时候又看到,反编译so时,先看看export。



点开`export`窗口,里面有导出的函数名,变量等,仔细找可以发现导出的`key`和`iv`。

等等!难道!我知道我脸上的笑容已经开始逐渐放肆!

别激动,稳一手!深呼吸两口气,然后双击其中的`key`,进入代码地址。

这儿的DCB是指开辟两个字节的存储空间。



显然这就是苦苦寻找的`AES_CBC`解密的`key`和`iv`了!可给找到了啊!你们哥俩!

还不能确定,先在python中用这两个参数解密试试看。

`python`已经有现有的库实现了`AES_CBC`解密,`windows`安装`pycryptodome`库即可,之前爬取hlsv解密时用过。

解密函数的代码如下:

```python
import base64
from Crypto.Cipher import AES


def hujiang_des_cbc_decrypt(encode_data):
    """
    沪江小D单词详情查询接口返回的 data 是加密的,需要解密,AES_CBC解密
    :param encode_data: 加密数据
    :return: 解密后的json数据
    """
    key = 'ceh[Een,3d3o9neg}fH+Jx4XiA0,D1cT'.encode('UTF-8')
    iv = 'K+\\~d4,Ir)b$=paf'.encode('UTF-8')
    cipher = AES.new(key, AES.MODE_CBC, iv)
    # 注意不要遗漏调用的 android.util.Base64
    decode_data = base64.decodebytes(encode_data.encode('UTF-8'))

    decode_data = cipher.decrypt(decode_data)
    print(decode_data)
```

找一个抓包到的加密`data`传进去验证一下,解密应该能得到一个json字符串才对。

激动人心的时候到了。Run!

怎么结果是`b'\x1f\x8b\x08\x00\x00\x00\x00.....`这样的乱码。要遭啊!

这可咋搞?!难道提取的`key`和`iv`是错的?但是这个`key`和`iv`一个是32位,一个是16位,很显然是对的啊。或者解密算法还有其他的参数?

于是吭哧吭哧打开在线AES解密网站,把参数填进去,选择不同的填充方式,什么`no padding`啊,什么`pkcs7padding`啊都试过了,就是不能解密。

难道一切要重新开始来吗?

等等!!!

`\x1f\x8b\x08`这个不是压缩文件的开头标识吗?会不会解密的结果被压缩过了!

试一试!

加上`decode_data = gzip.decompress(decode_data)`的解压缩代码。

运行!

然而。。。报错了。

`OSError: Not a gzipped file (b'\x0f\x0f')`

这个`\x0f`是什么鬼东西?回头检查解压前的数据,搜索这两个字节码。

怎么回事!?怎么解密后的数据后面全是`\x0f`,这不对啊!

找资料,发现`AES_CBC`解密时,还需要去掉末尾填充的字符。

加上代码`decode_data = decode_data[:-decode_data[-1]]`

运行!

最后居然成功了,熟悉的json字符串出来了!想不到终于成功了!!!



最后的完整解密代码如下:

```python
import gzip
import base64
from Crypto.Cipher import AES


def hujiang_des_cbc_decrypt(encode_data):
    """
    沪江小D单词详情查询接口返回的 data 是加密的,需要解密,AES_CBC解密
    :param encode_data: 加密数据
    :return: 解密后的json数据
    """
    key = 'ceh[Een,3d3o9neg}fH+Jx4XiA0,D1cT'.encode('UTF-8')
    iv = 'K+\\~d4,Ir)b$=paf'.encode('UTF-8')
    cipher = AES.new(key, AES.MODE_CBC, iv)

    # 解密之前还需要 base64解码
    decode_data = base64.decodebytes(encode_data.encode('UTF-8'))
    decode_data = cipher.decrypt(decode_data)

    # 注意需要去掉尾部的填充字符,复杂解压失败
    print(decode_data)
    decode_data = decode_data[:-decode_data[-1]]
    print(decode_data)
    decode_data = gzip.decompress(decode_data)
    print(decode_data)
    decode_data = json.loads(decode_data.decode('UTF-8'))
    print(decode_data)
    return decode_data
```

## 结语

其实很辛运,对方做的安全措施也不是很严格。

- 代码没有加固,没有加壳
- 混淆也没那么恐怖
- url直接明文写,其实可以把url拆分,或者写成ascii吗收不到
- so库没有加壳,也没有混淆
- key和iv没有做特殊运算,直接写死的字符串

不过如果做了这些的话,可能需要动态调试,或者其他方法吧。

其实后面也对沪江开心词场等其他几个词典的APP接口进行了破解,方法都类似。

harbor2003yw 发表于 2024-11-1 12:04

简直太棒了

p297615 发表于 2024-11-1 12:42

感谢分享

zlzx01 发表于 2024-11-1 14:17

萌新这么牛,那牛人是什么级别!

airwenlee 发表于 2024-11-1 22:34

好像不能用了,只能显示简单的释义

lxy444 发表于 2024-11-2 00:11

感谢大佬分享 绝对不是萌新级别了

zaiwangshang200 发表于 2024-11-2 10:32

感谢分大神享!

学到了很多,感谢!

ohmadecade 发表于 2024-11-2 12:03

隐约记得今年有个沪江系的软件挂了来着

yemeng520 发表于 2024-11-2 13:24

已学习感谢大佬

nhhhh666 发表于 2024-11-2 16:01

哇哦,感谢分享学到了
页: [1] 2 3
查看完整版本: 萌新的沪江小D词典app接口破解流程