前言

不多说,舔狗,帮抢,写脚本,懂得都懂。

本篇文章仅为记录学习过程,不提供源码。

准备工作

抓包获取接口

通过Fiddler工具抓包小程序获得整个抢购流程所需要的接口:

  • 获取医院列表接口https://cloud.cn2030.com/sc/wx/HandlerSubscribe.ashx?act=CustomerList
  • 查询医院疫苗列表接口 https://cloud.cn2030.com/sc/wx/HandlerSubscribe.ashx?act=CustomerProduct
  • 查询疫苗可用日期接口 https://cloud.cn2030.com/sc/wx/HandlerSubscribe.ashx?act=GetCustSubscribeDateAll
  • 查询疫苗所选日期可用时间接口https://cloud.cn2030.com/sc/wx/HandlerSubscribe.ashx?act=GetCustSubscribeDateDetail
  • 抢购提交前验证接口 https://cloud.cn2030.com/sc/wx/HandlerSubscribe.ashx?act=GetCaptcha
  • 保存订单接口https://cloud.cn2030.com/sc/api/User/OrderPost

接口地址和接口格式都拿到了,但是我们发现,在每个请求中都带有zftsl参数,并且在时间接口中的返回结果是加密的。

初步看可以知道,zftsl大概是一个和md5加密相关的东西,接口加密的方法大概是AES加密,那我们就需要获取加密方式、偏移量和秘钥等数据才能够继续往下操作。因此我们选择反编译小程序。

反编译小程序获取加密方式

之前有发过一期反编译小程序的教程,本期只讲思路不提供工具。

获取知苗易约程序包

我们知道,小程序的本质是一个h5前端,通过开发者工具打包后在微信的浏览器内核展示,因此我们首先需要获取到小程序的包。

打开模拟器,登录微信,打开小程序。

打开文件管理器,进入/data/data/com.tencent.mm/MicroMsg/a077f02aa58ebb02952e923f4c901697/appbrand/pkg目录,找到我们的包。
9168bd0033c7f04e7fd0c31a3e7a628e.png

反编译包获得压缩后的源码

下载wxappUnpacker反编译工具,npm install导入所需要的包:

npm install esprima
npm install css-tree
npm install cssbeautify
npm install vm2
npm install uglify-es
npm install js-beautify

运行wuWxapkg.js脚本反编译程序:node wuWxapkg.js ~/Documents/1.wxapkg获得源码。

阅读源码寻找算法和秘钥

将源码导入微信开发者工具。关闭不校验合法域名选项,让程序成功运行,便于调试。

d3221ca2fc5d2818e392005ae6792416.png

我们发现程序需要获取位置信息,那我们首先在百度坐标拾取系统中获取位置的经纬度,然后在小程序的Sensor选项中设置经纬度,并在app.json中定义相关权限。

25e58b8399f609f70f74876edebb8c5e.png

设置好相关配置后,不出意外,我们的程序便可以成功开始运行。

c53ad71968df1272aecbfba40fbc01af.png

获取zftsl算法

全局搜索zftsl关键字,我们发现其就是一个以时间戳为种子的MD5字符串,那这个字段的目的应该是为了方式抓包软件重复发送相同数据包。那我们就不多写,直接上Java实现:

DigestUtils.md5Hex(("zfsw_" + (Calendar.getInstance().getTimeInMillis() / 10)).getBytes(StandardCharsets.UTF_8))

获取ASE加密算法方式及秘钥

根据代码,的确证实了我们的猜想,这是一个ASE加密算法,其中:

  • 加密模式是CBC
  • 填充方式是PKcs7
  • iv偏移量是:1234567890000000
    413080d14a5ec1a8a09546785f9889cb.png
    a97100afef185013ab46a2373e925c6d.png

但是秘钥我们没有找到直接的字符串,但是经过对变量的不断溯源(过程不展示了,太枯燥)我们知道秘钥和后端返回的Cookies有关联,那么应该是一个ASE动态加密算法,从Cookies中获取的秘钥,我们打开抓包软件,抓取程序的Cookies:

ASP.NET_SessionId=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2NDQyMTY4MTEuNDE0MTgwNSwiZXhwIjoxNjQ0MjIwNDExLjQxNDE4MDUsInN1YiI6IllOVy5WSVAiLCJqdGkiOiIyMDIyMDIwNzAyNTMzMSIsInZhbCI6Iis4TWRBUUlBQUFBUU5HTTFNbU00TURJM1pEZzBabVV5WlJ4dmNYSTFielZEZFRBNVQzcENiV3RZU0ZoMVdVeE5TSHBzWTNoRkFCeHZcclxuVlRJMldIUXdhbGd6VDAxeFZEbHljRXd4UlRCbWFTMWliSFpOQ3pJM0xqTTRMalV5TGpFMEFBQUFBQUFBQUE9PSJ9.ajMIjCAa6LYvL-V7Lc0pgq50i3j9MyqyP0-IwVg8uSs

前面的ASP.NET_SessionId=只是一个.net框架Session管理的一个key前缀,应该和秘钥没什么关系。那我们去掉前缀后发现,所返回的Cookies是一串JWT秘钥,我们先对该秘钥进行解密:

b1d3700e35ecbf1153cc1b053f009535.png

我们在负载中获得一个对象,其中val为加密部分。但是我们发现其加密方法应该是Base64,因此,我们对val进行再次base64解密:

8054e087533d9f31b5d3d3259ea373d4.png

发现其中4c52c8027d84fe2e正好是16位的,其格式也与秘钥十分相似。因此我们将其作为秘钥进行测试:

7ed1bb2de65dd26d8e87f9de48de828f.png

发现成功解密!

编写代码

编写工具类

AES解密工具

/**
 * @author xin.liu
 */
public class AesService {

    private Cipher decryptCipher;
    private Cipher encryptCipher;
    private static volatile AesService INSTANCE;

    private AesService(String keyStr, String ivStr){
        try{
            byte[] keyBytes = keyStr.getBytes();
            int base = 16;
            if (keyBytes.length % base != 0) {
                int groups = keyBytes.length / base + 1;
                byte[] temp = new byte[groups * base];
                Arrays.fill(temp, (byte) 0);
                System.arraycopy(keyBytes, 0, temp, 0, keyBytes.length);
                keyBytes = temp;
            }
            Security.addProvider(new BouncyCastleProvider());
            Key key = new SecretKeySpec(keyBytes, "AES");
            encryptCipher = Cipher.getInstance("AES/CBC/PKCS7Padding", "BC");
            decryptCipher = Cipher.getInstance("AES/CBC/PKCS7Padding", "BC");
            encryptCipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(ivStr.getBytes()));
            decryptCipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(ivStr.getBytes()));
        }catch (Exception e){
            e.printStackTrace();
        }
        INSTANCE = null;
    }

    public static AesService getInstance(String keyStr, String ivStr) {
        if (INSTANCE == null){
            synchronized (AesService.class){
                if (INSTANCE == null){
                    INSTANCE = new AesService(keyStr, ivStr);
                }
            }
        }
        return INSTANCE;
    }

    /**
     * 解密方法
     * @param content 要解密的字符串
     */
    public String decrypt(String content){
        try{
            byte[] encryptedText = decryptCipher.doFinal(Hex.decode(content.getBytes()));
            return new String(encryptedText);
        }catch (Exception e){
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 加密方法
     * @param content  要加密的字符串
     */
    public String encrypt(String content) {
        try{
            byte[] encryptedText = encryptCipher.doFinal(content.getBytes());
            return new String(Hex.encode(encryptedText)).toUpperCase();
        }catch (Exception e){
            e.printStackTrace();
            return null;
        }
    }
}

Base64 解密工具

public class Base64Utils {

    private static final BASE64Decoder DECODER = new BASE64Decoder();

    public static String decode(String base64) {
        try{
            return new String(DECODER.decodeBuffer(base64), StandardCharsets.UTF_8);
        }catch (Exception e){
            return null;
        }
    }
}

Jwt解密工具

可以使用jwt的依赖,也可以直接对jwt令牌进行base64解密:

/**
 * @author xin.liu
 */
public class JwtUtils {

    /**
     * 解析JWT 负荷
     * @param token 待解析的jwt
     * @param key 需要获取的负荷 key
     * @return 解析结果
     */
    public static String getTokenPayload(String token, String key) {
        DecodedJWT jwt = JWT.decode(token);
        return jwt.getClaim(key).asString();
    }

}

请求工具类

这里我们可以使用HttpClient工具类进行,但是我发现使用工具类时创建隧道连接时会消耗大量的时间,尤其是在抢购的时候,因此最终我选择了使用原生http请求工具:

因为在抢购的过程中,需要进行验证,所以一个抢购会话需要维持Cookies,我们因此没有使用静态方法或者单例模式

/**
 * HTTP 请求服务
 * @author xin.liu
 */
public class HttpRequestService {

    private String cookies = null;

    public JSONObject doPost(String url, String body){
        try{
            return JSONObject.parseObject(proxyHttpRequest(url, "POST", body));
        }catch (Exception e){
            e.printStackTrace();
            return null;
        }
    }

    public JSONObject doGet(String url){
        try{
            return JSONObject.parseObject(doGetString(url));
        }catch (Exception e){
            e.printStackTrace();
            return null;
        }
    }

    public String doGetString(String url){
        return proxyHttpRequest(url, "GET", null);
    }

    private synchronized HttpURLConnection createConnection(String urlAddress, String method, String body) throws Exception {
        URL url = new URL(urlAddress);
        trustAllHttpsCertificates();
        HttpURLConnection httpConnection = (HttpURLConnection) url.openConnection();
        httpConnection.setConnectTimeout(KillKey.TIMEOUT);
        Map<String, String> headerParameters = UserConfig.getRequestHeaderMaps();
        if (headerParameters != null) {
            for (String key : headerParameters.keySet()) {
                httpConnection.setRequestProperty(key, headerParameters.get(key));
            }
        }
        if (StringUtils.isNotBlank(cookies)){
            httpConnection.setRequestProperty("Cookie", cookies);
        }
        httpConnection.setRequestProperty("zftsl", DigestUtils.md5Hex(("zfsw_" + (Calendar.getInstance().getTimeInMillis() / 10)).getBytes(StandardCharsets.UTF_8)));
        httpConnection.setRequestMethod(method);
        httpConnection.setDoOutput(true);
        httpConnection.setDoInput(true);
        if (!(body == null || "".equals(body.trim()))) {
            OutputStream writer = httpConnection.getOutputStream();
            try {
                writer.write(body.getBytes(KillKey.ENCODING));
            } finally {
                if (writer != null) {
                    writer.flush();
                    writer.close();
                }
            }
        }
        int responseCode = httpConnection.getResponseCode();
        String cookies = httpConnection.getHeaderField("Set-Cookie");
        if (StringUtils.isNotBlank(cookies)){
            this.cookies = cookies;
        }
        if (responseCode != KillKey.HTTP_STATUS_OK) {
            throw new Exception(responseCode + ":" + inputStream2String(httpConnection.getErrorStream(), KillKey.ENCODING));
        }
        return httpConnection;
    }

    public String proxyHttpRequest(String address, String method, String body) {
        String result = null;
        HttpURLConnection httpConnection = null;
        try {
            httpConnection = createConnection(address, method, body);
            String encoding = "UTF-8";
            if (httpConnection.getContentType() != null && httpConnection.getContentType().contains(KillKey.CHARSET)) {
                encoding = httpConnection.getContentType().substring(httpConnection.getContentType().indexOf(KillKey.CHARSET) + 8);
            }
            result = inputStream2String(httpConnection.getInputStream(), encoding);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (httpConnection != null) {
                httpConnection.disconnect();
            }
        }
        return result;
    }

    private String inputStream2String(InputStream input, String encoding) throws IOException {
        BufferedReader reader = new BufferedReader(new InputStreamReader(input, encoding));
        StringBuilder result = new StringBuilder();
        String temp;
        while ((temp = reader.readLine()) != null) {
            result.append(temp);
        }
        return result.toString();
    }

    private void trustAllHttpsCertificates() throws Exception {
        HttpsURLConnection.setDefaultHostnameVerifier((str, session) -> true);
        javax.net.ssl.TrustManager[] trustAllCerts = new javax.net.ssl.TrustManager[1];
        javax.net.ssl.TrustManager tm = new MiTm();
        trustAllCerts[0] = tm;
        javax.net.ssl.SSLContext sc = javax.net.ssl.SSLContext.getInstance("TLS");
        sc.init(null, trustAllCerts, null);
        HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
    }

    static class MiTm implements javax.net.ssl.TrustManager,javax.net.ssl.X509TrustManager {
        @Override
        public java.security.cert.X509Certificate[] getAcceptedIssuers() { return null; }
        @Override
        public void checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType) { }
        @Override
        public void checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType) { }
    }
}

完整源码因为侵权的问题暂时不提供。本篇文章仅为记录学习过程中遇到的问题,仅供学习交流。

如果有疑问,欢迎在文章下评论。

Q.E.D.