作者声明:仅记录下学习过程,切勿商业,若产生任何有害于案例网站的后果,与作者无关。
1.准备阶段
1.1 安装软件
bash
adb install "Etsy_ Home, Style & Gifts_6.47.0_Apkpure.apk"

软件打开提示要升级,但是不用升级,直接做搜索操作。
1.2 charles设置
在Proxy当中勾选上External proxies settings,并且把本机电脑的ip地址和代理软件的port填写上去。

手机上的自然就是使用postern配置好代理IP,填写的是电脑的ip地址,方便做转发。
抓包没有问题了在开始后面的操作。
2. 分析需要解决的加密字段


通过随便搜索两个不同的关键词然后发现:x-etsy-signature、x-api-key、x-etsy-device这三个虽然看着是加密,但是后两个没有变化,一直是固定的。而且x-etsy-signature变化的还是里面的timestamp、signature两部分内容。
2.1 x-etsy-signature分析

搜索一下发现还是可以搜索得到的。

这里发现x-etsy-signature的值应该就是str3,然后str3的由来是pair4调用了方法,先看一下 Pair。
Pair 是 Kotlin 标准库 kotlin-stdlib 中的一个数据类,定义很简单------装两个值的小容器,泛型为 <A, B>。
官方定义要点
public data class Pair<out A, out B>(
public val first: A,
public val second: B )
特点:
- 声明为 data class,所以自动生成
equals()、hashCode()、toString()、copy()、component1()/component2()。 - first、second 是只读(val),构造后不可改。
- 两个泛型都是 out(协变)。
- 直接配带 to 中缀函数:1 to "a"
等价于 Pair(1, "a")。
// 取字段 p.first // "key" p.second // 123 // 解构 val (k, v) = p //
k = p.component1(), v = p.component2() // 复制 p.copy(second = 999) //
first 不变 // toString: (key, 123)
这里f函数点进去
也就是把两个参数添加。接着后面的(String) pair4.component1();进去看

也就是选择了个参数,也就是说str3的值就是android.support.v4.media.d.f(sb3, ", signature=", string2),总的来看就是拼接得到了x-etsy-signature的值。
jCurrentTimeMillis在查看过程当中发现他就是一个时间戳。

然后一路往上看

这里看着是HmacSHA256算法,问了ai说是标准的没有改动的HmacSHA256算法。那入参应该就是
String strI1 = kotlin.collections.t.I1(arrayList, StringUtils.LF, null, null, null, 62);
代码当中的arrayList很关键了。因为这里的StringUtils.LF查看发现就是:\n

2.1.1 定位主要生成逻辑代码解读
java
String strI1 = kotlin.collections.t.I1(arrayList, StringUtils.LF, null, null, null, 62);
Charset charset = kotlin.text.a.f33362b;
byte[] bytes2 = qVar2.f13503b.getBytes(charset);
kotlin.jvm.internal.o.f(bytes2, "this as java.lang.String).getBytes(charset)");
SecretKeySpec secretKeySpec = new SecretKeySpec(bytes2, "HmacSHA256");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(secretKeySpec);
byte[] bytes3 = strI1.getBytes(charset);
kotlin.jvm.internal.o.f(bytes3, "this as java.lang.String).getBytes(charset)");
mac.update(bytes3);
byte[] bArrDoFinal = mac.doFinal();
kotlin.jvm.internal.o.f(bArrDoFinal, "hmac.doFinal()");
String strEncodeToString2 = Base64.encodeToString(bArrDoFinal, 0);
kotlin.jvm.internal.o.f(strEncodeToString2, "encodeToString(this, flags)");
String string2 = kotlin.text.m.h2(strEncodeToString2).toString();
StringBuilder sb3 = new StringBuilder("key-id=");
sb3.append(qVar2.f13502a);
sb3.append(", version=1, timestamp=");
sb3.append(jCurrentTimeMillis);
Pair pair4 = new Pair(android.support.v4.media.d.f(sb3, ", signature=", string2), Boolean.valueOf(zBooleanValue2));
String str3 = (String) pair4.component1();
boolean zBooleanValue3 = ((Boolean) pair4.component2()).booleanValue();
okhttp3.v.a aVar2 = new okhttp3.v.a(vVar);
aVar2.a("x-etsy-signature", str3);
Charset charset = kotlin.text.a.f33362b;
获取编码格式,点击进去显示的是utf-8的格式。

byte\[\] bytes2 = qVar2.f13503b.getBytes(charset);

qVar2.f13503b按照传入的编码格式得到字节数据(HMAC-SHA256 的密钥)。
kotlin.jvm.internal.o.f(bytes2, "this as java.lang.String).getBytes(charset)");

进去看这个f函数就是判断一下bytes2是否是空的,空的会有异常。

SecretKeySpec secretKeySpec = new SecretKeySpec(bytes2, "HmacSHA256");
声明"我要用 HmacSHA256 算法,这是它的密钥"
Mac mac = Mac.getInstance("HmacSHA256");
给我一台会算 HmacSHA256 的计算器,这行只是把计算器领到手,里面空的,啥都还没算
mac.init(secretKeySpec);
- secretKeySpec 就是上一行代码做好的钥匙(密钥 = qVar2.f13503b 的字节)。
- mac.init(...) = 把钥匙插进计算器,告诉它"待会儿用这把钥匙来算"。
- 插完钥匙,计算器进入"待命"状态------数据还没喂,钥匙插好了。
byte\[\] bytes3 = strI1.getBytes(charset);
- strI1 = 要签名的字符串(比如 "a\nb\nc" 这种)。
- strI1.getBytes(charset) = 把这个字符串翻译成字节(机器只认 0/1 字节,不认字符;charset 指定翻译规则,一般 UTF-8)。
- byte\[\] bytes3 = = 翻译结果存进字节数组 bytes3。
- 注意:这行还没把数据给计算器,只是把字符串"备好料"。
kotlin.jvm.internal.o.f(bytes3, "this as java.lang.String).getBytes(charset)");
判断bytes3是否为空,和前面那个是一样的。
mac.update(bytes3);
- bytes3 = 上一行 strI1.getBytes() 转好的字节(要签名的原文)。
- mac.update(...) = 把这些字节投进计算器(相当于把食材倒进搅拌口)。
- 重要:投完之后还没结果,计算器在"消化中",这行只累计输入。
- 可以调用多次:分几次 update,等于一次性全喂,结果一样。
byte\[\] bArrDoFinal = mac.doFinal();
- mac.doFinal() = 按下"= 号",真正算出结果,返回 32 字节的摘要。
- byte\[\] bArrDoFinal = = 把结果存进数组。
- 调完 doFinal,计算器内部状态会被清空:要再算别的数据,必须重新 >
mac.init(secretKeySpec) 插钥匙。
- 到这里,HMAC-SHA256(key, strI1) 就完整算完了。
String strEncodeToString2 = Base64.encodeToString(bArrDoFinal, 0);
Base64 编码------把 HMAC 算出来的 32 字节二进制摘要,转成字符串
后面的就是参数的拼接和取值的操作了。
2.1.2 signature HmacSHA256加密参数
这里kotlin.collections.t.I1当中的参数arrayList不用去hook,因为他要作为后面Hmac-sha256的入参参数(最原始的)可以直接看后面算法的入参,当然也可以看看,属于可有可无的操作。
使用attach模式注入算法助手js然后联合抓包工具。



入参参数:
bash
digest:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=
host:openapi.etsy.com
key-id:gf665hj0kpsdkpllgek11zrz
target:get /etsyapps/v3/bespoke/member/search-with-ads/mmx?keywords=xiaomi 17pro¤cy=CNY&buyer_features[app_foreground_time]=1791598334139&buyer_features[app_initial_start_time]=1791531559532&buyer_features[device_model]=Google Pixel 4&buyer_features[network_type]=Wifi&with_static_filters=true&include_additional_listing_images=true&ship_to=CN&instant_download=null&category_prolist=1&sort_on=score&sort_order=down&include_featured_categories=false&with_deep_facets=1
timestamp:1791598423
version:1
x-etsy-device:wOiojvtESAKY6pFceuW3Hw
所以这个入参参数按照代码来看就是
String strI1 = kotlin.collections.t.I1(arrayList, StringUtils.LF, null, null, null, 62);
这里的strl1处理后的结果。然后所搜索了几次发现入参里面的digest、x-etsy-device、version、key-id、host都是不变的。其中key-id和请求头里面的x-api-key的值是一样的。
2.1.2.1 digest生成
这里看一下digest是怎么来的

既然这样那就还是使用算法自吐来看看。

这里看着应该是对一个空字符串进行了SHA-256得到的,然后验证一下看看。

结果是一样的
2.1.2.2 key-id生成
pairArr5 = new Pair("key-id", qVar2.f13502a);

同时后面x-etsy-signature的参数拼接代码的时候也是这个qVar2.f13502a
StringBuilder sb3 = new StringBuilder("key-id=");
sb3.append(qVar2.f13502a);
这里对这个q方法hook一下看看
bash
function hook_method_q() {
Java.perform(function () {
var q = Java.use("com.etsy.android.lib.config.q");
q["$init"].implementation = function (apiKey, apiSecret) {
console.log(`q.$init is called: apiKey=${apiKey}, apiSecret=${apiSecret}`);
this["$init"](apiKey, apiSecret);
};
})
}

这里使用的是spwan模式,可以看到这两个应该是可以固定死的。
而且这wwzr1l5lkn其实就是后面signature的Hmac-sha256的秘钥(前面代码解读当中分析的)
2.1.2.3 target生成

从给的参数格式来看d1.J(string)一定是个True,走的上面的红框,应为target当中有?这个符号, StringUtils.SPACE看的是一个空格。

d1.J(string)代码当中就是一个判断,入参肯定不是null,所以取反之后就是True

而且这个参数组成里面也就是搜索的关键词在变,然后加上两个时间戳,其他的都是固定的。所以后面可以直接替换。
2.1.3 x-etsy-signature的signature生成验证
还是用前面的参数作为验证
bash
入参:
digest:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=
host:openapi.etsy.com
key-id:gf665hj0kpsdkpllgek11zrz
target:get /etsyapps/v3/bespoke/member/search-with-ads/mmx?keywords=xiaomi 17pro¤cy=CNY&buyer_features[app_foreground_time]=1791598334139&buyer_features[app_initial_start_time]=1791531559532&buyer_features[device_model]=Google Pixel 4&buyer_features[network_type]=Wifi&with_static_filters=true&include_additional_listing_images=true&ship_to=CN&instant_download=null&category_prolist=1&sort_on=score&sort_order=down&include_featured_categories=false&with_deep_facets=1
timestamp:1791598423
version:1
x-etsy-device:wOiojvtESAKY6pFceuW3Hw
Hmac-sha256 doFinal result Hex:37bd05753b968c2c536b8352b3f4a229a4e2ef93fd71eddff033d552d69e27f8
Hmac-sha256 doFinal result Base64:N70FdTuWjCxTa4NSs/SiKaTi75P9ce3f8DPVUtaeJ/g=


2.2 x-api-key分析
前面已经看了,其实就是固定就行。
2.3 x-etsy-device分析
这个参数关系到前面x-etsy-signature的signature生成,但是写死问题不大,还是可以看一下逻辑。
从我们分析x-etsy-signature的signature的加密之前的字段来看就是和pairArr有关,然后加密字段是有7个的,但是这里pairArr就操作了6个元素,那么下图中间红框范围的代码可能就是在操作x-etsy-device。

然后再细看,看pairArr后面的操作

所以可以hook一下linkeHashMap看看
bash
function hookLinkedHashMap() {
var LHM = Java.use('java.util.LinkedHashMap');
// // 构造
// LHM.$init.overload().implementation = function() {
// this.$init();
// console.log('[LinkedHashMap] 创建实例');
// };
// put 写入
LHM.put.implementation = function(key, value) {
var result = this.put(key, value);
console.log('[LinkedHashMap.put] key = ' + key +
', value = ' + value +
', 结果 = ' + result +
', size = ' + this.size());
return result;
};
}

搜索可以看到很多,但是主要是一下几种情况



依次是:browser_id、x-etsy-device、X-Etsy-Device,其中browser_id出现的是最多的,依次看一下。
browser_id:

搜索出来没有看到明显的赋值操作
x-etsy-device:

这个就一处,而且也不是赋值。
X-Etsy-Device:

此时看能感觉到明显的有赋值的操作。
其中 aVar2.a("X-Etsy-Device", str);看进去就是可以看到


map.put("X-Etsy-Device", com.etsy.android.lib.config.u.a().f13513a);点进去你会发现也和上面一样,也就是说他们最终都是this.f13513a = new InstallPersistence(context).a();
那就直接看这个a。

SharedPreferences获取到了EtsyUUID的值。SharedPreferences 是 Android 里自带的"轻量级本地存储"------用来保存小的键值对数据(设置项、token、开关等),本质就是读写一个 XML 文件。
所以这就是为什么他一直不变,是从缓存当中读取。


回看代码

应该要走红色框线的部分,如果EtsyUUID存在的话走的是那个if判断。在细看代码发现主要的部分就是String strA = w.a();
因为最后是返回的strA,进入w.a()后

bash
第一行:生成随机 UUID
UUID uuidRandomUUID = UUID.randomUUID();
- 生成一个"随机的 UUID"(标准格式:8-4-4-4-12,如 550e8400-e29b-41d4-a716-446655440000)。
- 注意:它只是被当作"16 字节随机数来源",后面输出的不是这个标准字符串。
第二~四行:把 UUID 的 16 字节写进 ByteBuffer
ByteBuffer byteBufferWrap = ByteBuffer.wrap(new byte[16]);
// ↓ 新建一块 16 字节的缓冲区
byteBufferWrap.putLong(uuid.getMostSignificantBits());
// ↑ 写入 UUID 的前 8 字节(高位)
byteBufferWrap.putLong(uuid.getLeastSignificantBits());
// ↑ 写入 UUID 的后 8 字节(低位)
// 结果:16 字节 = 这个 UUID 的原始二进制
方法 含义
getMostSignificantBits() UUID 的高 64 位(前 8 字节)
getLeastSignificantBits() UUID 的低 64 位(后 8 字节)
ByteBuffer.putLong 按大端序写入 8 字节
| 方法 | 含义 |
|---|---|
| getMostSignificantBits() | UUID 的高 64 位(前 8 字节) |
| getLeastSignificantBits() | UUID 的低 64 位(后 8 字节) |
| ByteBuffer.putLong | 按大端序写入 8 字节 |
| 第五行:对 16 字节做 Base64 |
return Base64.encodeToString(byteBufferWrap.array(), 11);
- byteBufferWrap.array() = 取出那 16 个字节。
- 第二个参数 11 = decode flags:

用Python实现:
bash
def generate_etsy_device() -> str:
"""
对应 Java 的 UUID.randomUUID() + ByteBuffer(16字节) + Base64(flags=11)
flags=11 = URL_SAFE + NO_WRAP + NO_PADDING
"""
# 生成 16 字节随机数(等价 UUID 的高低位各 8 字节)
raw = uuid.uuid4().bytes
# URL-safe base64(对应 URL_SAFE),再去掉换行与 = 填充(NO_WRAP + NO_PADDING)
result = base64.urlsafe_b64encode(raw).decode('ascii').rstrip('=')
return result
如果想让手机在生成一个新的应该怎么做呢?


这样缓存文件没有了,再打开就会生成一个新的缓存。
3. Python请求
这里可以简化一下x-etsy-signature的signature的原始加密字段target,这个字段就是请求参数。
bash
import hmac
import hashlib
import base64
import random
import time
import uuid
import requests
X_API_KEY = ''
def generate_etsy_device() -> str:
"""
对应 Java 的 UUID.randomUUID() + ByteBuffer(16字节) + Base64(flags=11)
flags=11 = URL_SAFE + NO_WRAP + NO_PADDING
"""
# 生成 16 字节随机数(等价 UUID 的高低位各 8 字节)
raw = uuid.uuid4().bytes
# URL-safe base64(对应 URL_SAFE),再去掉换行与 = 填充(NO_WRAP + NO_PADDING)
result = base64.urlsafe_b64encode(raw).decode('ascii').rstrip('=')
return result
def get_signature(data: str) -> str:
# 使用示例
key = ""
# hmac.new(密钥字节, 数据字节, sha256).digest() → 32字节
digest = hmac.new(
key.encode('utf-8'), # 密钥
data.encode('utf-8'), # 数据
hashlib.sha256
).digest()
return base64.b64encode(digest).decode() # base64 输出
def page_home(search_kw):
timestamp = int(time.time())
app_foreground_time = str(int(time.time()) * 1000)
app_initial_start_time = str(int(time.time()) * 1000 - random.randint(2000, 10000))
headers = {
'Host': 'openapi.etsy.com',
'x-app-in-background': 'false',
'x-detected-locale': 'CNY|en-US|CN',
'user-agent': 'Dalvik/2.1.0 (Linux; U; Android 10; Pixel 4 Build/QD1A.190821.011) Mobile/1 EtsyInc/6.47.0 Android/1',
# 'x-etsy-device': '',
'oauth_version': '2.0',
'x-api-key': X_API_KEY,
# 'x-etsy-signature': 'key-id=, version=1, timestamp=, signature=',
'x-etsy-signed-body': 'true',
}
x_etsy_device = generate_etsy_device()
# x_etsy_device = ""
headers['x-etsy-device'] = x_etsy_device
params = {
'keywords': f'{search_kw}',
'currency': 'CNY',
'buyer_features[app_foreground_time]': f'{app_foreground_time}',
'buyer_features[app_initial_start_time]': f'{app_initial_start_time}',
'buyer_features[device_model]': 'Google Pixel 4',
'buyer_features[network_type]': 'Wifi',
'with_static_filters': 'true',
'include_additional_listing_images': 'true',
'ship_to': 'CN',
'instant_download': 'null',
'category_prolist': '1',
'sort_on': 'score',
'sort_order': 'down',
'include_featured_categories': 'false',
'with_deep_facets': '1',
}
params_list = []
for k,v in params.items():
params_list.append(f'{k}={v}')
params_str = '&'.join(params_list)
signature_params = f"digest:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=\nhost:openapi.etsy.com\nkey-id:{X_API_KEY}\ntarget:get /etsyapps/v3/bespoke/member/search-with-ads/mmx?{params_str}\ntimestamp:{timestamp}\nversion:1\nx-etsy-device:{x_etsy_device}"
signature = get_signature(signature_params)
headers['x-etsy-signature'] = f"key-id=, version=1, timestamp={timestamp}, signature={signature}"
response = requests.get(
'https://openapi.etsy.com/etsyapps/v3/bespoke/member/search-with-ads/mmx',
params=params,
headers=headers,
)
print(response.text)
def main():
page_home('xiaomi 18 pro')
if __name__ == '__main__':
main()

