Skip to content

Latest commit

 

History

History
505 lines (425 loc) · 21.2 KB

2018-11-29-点评登录自动化.md

File metadata and controls

505 lines (425 loc) · 21.2 KB
layout title categories tags
post
点评登录自动化
Tech
python
dianping

0x00 写在前面

这篇文章是尝试用Python模拟点评账号自动化登录的记录。仅仅保证当前能正确执行,任何的变化都可能造成这篇文章提及的方法失效。

0x01 锁定目标

目前点评的登录方式主要有扫码登录、手机短信登录、手机号密码登录以及第三方登录。很显然,只有手机号密码登录这一条途径最容易实现自动化登录。

打开登录页面https://account.dianping.com/login?redir=http%3A%2F%2Fwww.dianping.com%2F,用Charles抓包工具来查看登录过程使用到了哪些API。(点评启用了https,所以你得设置一下你的PC和Charles来支持抓取https的包)。 很快就能发现一条重要的请求https://account.dianping.com/account/ajax/passwordLogin,看起来是登录请求无误了。再来看一眼它的参数:

countrycode: 		86
username: 		xxxxxx
keepLogin:		on
encryptPassword:	***************...
_token:			***************...

前三个参数非常明显了,重点明显在后两个参数,如果模拟出这两个参数,那么我们就能造出同样的请求模拟登录了。

在这条请求之前紧接着一条https://account.dianping.com/account/ajax/checkRisk,Response是这样的:

{
	"code": 200,
	"msg": {
		"publicKey": "*********",
		"riskLevel": "0",
		"uuid": "df6ad118-35fe-4526-be03-27bccsc615a1"
	},
	"riskChannel": 201
}

暂时看不出有什么特别的,但是publicKey这个参数需要特别注意。有了key就说明肯定有加密。

一眼望去,这两个参数都是经过Base64编码的。不管三七二十一,先解码看一看。但是很遗憾,解码完后并没有什么有用的信息,一串乱码,应该是经过了加密操作。 既然反着不能来,那我们就正着来,看一看密码密文和token到底是怎么生成的。

0x02 搞定encryptPassword

打开Chrome的Debug模式,找一找这个页面加载了哪些js文件,马上一个目标又出现了https://www.dpfile.com/mod/app-easy-login-frame/0.1.47/app-easy-login-frame.js。生产环境的js文件都是经过压缩的,找一个在线格式化js的工具格式化一下看着比较舒服。

格式化后的js文件大概有1000行左右,搜索passwordLogin关键字,很快就找到了登录的代码。

e.login = function(e, o) {
    var t = {
        countrycode: a("#countrycode-account .code").html().replace(/\s|\+/g, ""),
        username: e.username,
        keepLogin: e.keepLogin
    };
    if (this.captcha.publicKey) {
        var n = new c;
        n.setPublicKey(this.captcha.publicKey),
        t.encryptPassword = n.encrypt(s.stringify([e.password, this.captcha.uuid]))
    } else t.password = e.password,
    t.uuid = this.captcha.uuid;
    var i = (location.host.indexOf("ppe") > -1 ? "": "https://" + location.host) + "/account/ajax/passwordLogin";
    if (window.Rohr_Opt) try {
        var l = [];
        for (var d in t) l.push(d + "=" + t[d]);
        var u = "?" + l.join("&"),
        m = Rohr_Opt.reload(i + u);
        t._token = m
    } catch(f) {
        console.log("security error info:  " + f)
    }
    v.exec(i, t,
    function(e) {
        if (200 === e.code) o(!0);
        else if (101 === e.code) {
            if (e.msg) {
                var t = '<form id="bind-form" class="bind-form">                    <div class="form-item form-title">完善安全信息</div>                    <div class="form-item form-alert"><div class="alert-content">为了您的账号安全,请完善如下信息</div></div>                    <div class="form-item form-input">                        <div class="textbox-border textbox-wide">                            <input id="mobile-number-textbox" class="textbox" type="text" placeholder="手机号" />                        </div>                    </div>                    <div id="captcha-container" class="form-item form-input captcha-container">                        <div class="textbox-border textbox-narrow">                            <input id="captcha-textbox" class="textbox" type="text" placeholder="请输入验证码" />                        </div>                        <div class="plain-button-wrapper">                            <img alt="验证码图片" class="captcha" width="85" />                        </div>                        <div class="clearfix"></div>                    </div>                    <div class="form-item form-input">                        <div class="textbox-border textbox-narrow">                            <input id="number-textbox" class="textbox" type="text" placeholder="请输入动态码"/>                        </div>                        <div class="plain-button-wrapper">                            <button id="send-number-button" class="plain-button" type="button">发送动态码</button>                        </div>                        <div class="clearfix"></div>                    </div>                    <div class="form-item form-input">                        <button id="bind-button" class="main-button" type="submit">绑定</button>                    </div>                </form>';
                a(".body-wrapper").html(t),
                r(e.msg)
            }
        } else o(!1, e.msg && e.msg.err || "用户名或密码错误")
    })
},

实际上,这里对于password的操作是一个签名操作,它使用了服务器提供的公钥来对密码明文进行签名。而私钥只有服务器有,也就只有服务器才能验证密码的正确性。但是为了说明更加方便,后面对这一操作都模糊的称为加密

n.setPublicKey(this.captcha.publicKey)很明显,checkRisk请求返回的publicKey用于了password的加密。 t.encryptPassword = n.encrypt(s.stringify([e.password, this.captcha.uuid])),可以发现encryptPassword字段是通过加密passworduuid获得的。 s.stringify()是一个将数据结构转成json字符串的方法,这里最后得到的字符串是"[password,uuid]"注意中间没有空格。

n.encrypt()调用的是JSEncryptencrypt()方法,核心是RSA非对称加密算法。 在Python中,我们可以借助pycrypto来实现相同的加解密算法。

需要注意的是,JSEncrypt使用的publicKey并不直接是setPublicKeykey,它在前面和后面分别填充了一段字符。

最终使用的publicKey如下:

finalPublicKey = "-----BEGIN PUBLIC KEY-----\n" + [your_public_key] + "\n-----END PUBLIC KEY-----"

另一点需要主要的是,JSEncryptRSA使用的填充方法是PKCS1 v1.5,在Python中我们也需要指明同样的填充方法来保证可以被服务器解密。

附上加密算法的Python实现:

from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
import base64

public_key = """-----BEGIN PUBLIC KEY-----
[your public key]
-----END PUBLIC KEY-----"""

rsa = RSA.importKey(public_key)
cipher = PKCS1_v1_5.new(rsa)

def encrypt(msg):
    ciphertext = cipher.encrypt(msg.encode('utf8'))
    return base64.b64encode(ciphertext).decode('ascii')

ciphertext = encrypt('["password","uuid"]')
print(ciphertext)

你可能会发现,打印出的结果和抓包的结果并不一样,这是因为RSA算法每次运行得出的结果都是不一样的,但他们都能够被正确的解密。

至此,我们已经解决了一个问题,但是又引入了一个新的问题,要获取uuid,就必须模拟一个checkRisk的请求。查看这个请求的参数你会发现,它也需要_token这个参数。正好与我们需要解决的第二个问题相同。

0x03 搞定token

我原本以为搞定了encryptPassword已经完成了大部分的工作,可事实上在token上花费了更多的时间。

_token的计算方式在上面的js代码中也能发现。

var i = (location.host.indexOf("ppe") > -1 ? "": "https://" + location.host) + "/account/ajax

var l = [];
for (var d in t) l.push(d + "=" + t[d]);
var u = "?" + l.join("&"),
m = Rohr_Opt.reload(i + u);
t._token = m

i就是整个URL,u就是这个URL的参数,以urlencode的方式拼接在URL后面。 Rohr_Opt.reload()并不在这个js文件内,通过Chrome,我们可以发现这么一个jshttps://s0.meituan.net/mx/rohr/rohr.min.js

很不幸,它是经过混淆的,各种奇葩的变量命名和符号。不过没关系,混淆的我们也能解开。

找到Rohr_Opt.reload发现它的实现其实就是iP.reload

iP.reload = function(jv) {
    var jw;
    var jx = {};
    if (typeof jv === _$_543c[91]) {
        jx = iO.parse(jv.split(_$_543c[146])[1])
    } else {
        if (typeof jv === _$_543c[2]) {
            jx = jv
        }
    };
    iP.sign = iJ(jx);
    iP.cts = new Date().getTime();
    jw = iI(iP);
    if (Rohr_Opt.LogVal && typeof(window) !== _$_543c[0]) {
        window[Rohr_Opt.LogVal] = encodeURIComponent(jw)
    };
    return jw
};

首先来还原_543c, 找到_543c的定义

var _$_543c = ["\x75\x6E\x64\x65\x66\x69\x6E\x65\x64", "\x66\x75\x6E\x63\x74\x69\x6F\x6E", ...]

这不就是一个string的数组么,写一小段代码把它一个个打印出来就知道是啥了。

还原后的iP.reload

iP.reload = 
// jv = [URL]?countrycode=86&username=xxxx&keepLogin=true...
function(jv) {
    var jw;
    var jx = {};
    if (typeof jv === "string" {
        jx = querystring.parse(jv.split("?")[1])
        // jx is a dict
        // {
        //      countrycode: 86,
        //      username: [username],
        //		....
        // }
        //
    } 
    /*else {
        if (typeof jv === "object") {
            jx = jv
        }
    };*/
    iP.sign = iJ(jx);
    iP.cts = new Date().getTime();
    jw = iI(iP);
    if (Rohr_Opt.LogVal && typeof(window) !== "undefined") {
        window[Rohr_Opt.LogVal] = encodeURIComponent(jw)
    };
    return jw
};

由于我们的输入肯定是一个字符串,所以else的分支不用管它,querystring.parse又将URL和parameters给拆分出来了,实际有用的元素只是parameters。事实上,这个函数一直再做一些掩耳盗铃的事情。 jx现在是一个parameters的dict,它作为参数被传递给iJ()

var iJ = 
function(je) {
    var jd = [];
    var ck = Object.keys(je).sort();
    ck.forEach(function(jf, bx) {
        if (jf !== "token" && jf !== "_token") {
            jd.push(jf + "=" + je[jf])
        }
    });
    jd = jd.join("&");
    // jd is a string: countrycode=86&username=xxxx&keepLogin=true...
    return iI(jd)
};

可以看见,这里又把jx给合并成之前的urlencode之后的string了!只是过滤掉了token_token字段。这俩字段在这俩request里也没有,不必理会。

接着来看iI()

var iI = 
function(jc) {
    // json string
    jc = cD.deflate(JSON.stringify(jc));
    // jc = binaryString(json string)
    // iD = base64encode
    jc = iD(jc);
    return jc
};

cD是一个第三方库pakodeflate将字符串进行了一次压缩。iD就是base64encode函数,所以iI()的作用是将输入转成json字符串,经过压缩后在进行base64编码。

回过头看iP.reload函数,发现它的最后一步就是iI()运算后的结果,那么我们反着来一下,应该就能解出来iP的内容。

方便起见,这里就直接用js来实现(需要依赖pako库):

var pako = require('pako');
 
var token = [your token]

var binary = new Buffer(token, 'base64')
var output = pako.inflate(binary);
output = String.fromCharCode.apply(null, output);

console.log(output)

拿着token试一试,看看是不是解出来了?

有了iP的结构,构造_token就变得明了了,需要哪些信息,填上去就行,可是发现还是没有那么简单。:(

{
    "rId": "100049", 
    "ver": "1.0.6", 
    "ts": 1542944472842, 
    "cts": 1542944496446, 
    "brVD": [ // client width and height. fixed
        290, 
        375
    ], 
    "brR": [ // width and height related. fixed
        [
            2560, 
            1440
        ], 
        [
            2560, 
            1417
        ], 
        24, 
        24
    ], 
    "bI": [ // fixed
        "https://account.dianping.com/account/iframeLogin?callback=EasyLogin_frame_callback0&wide=false&protocol=https:&redir=http%3A%2F%2Fwww.dianping.com", 
        "https://account.dianping.com/login?redir=http:https://www.dianping.com"
    ], 
    "mT": [ // track user mouse position, at most 30 positions
        "164,243", 
        "165,243", 
        ...,
    ], 
    "kT": [         // track user keyboard press, at most 30 inputs
        "S,INPUT", 
        "I,INPUT", 
        "U,INPUT", 
        ...,
    ], 
    "aT": [
        "164,243,BUTTON", 
        "118,171,INPUT", 
        "198,107,INPUT", 
        "208,46,A", 
        "259,28,DIV"
    ], 
    "tT": [ ], 
    "aM": "", 
    "sign": "xxxxxxxxxx" // iI("riskChannel=201&user=[username]")    
}

这是iP的数据结构,rId,ver,brVD,brR,bI字段都可以是确定的值,不用特别处理。 tscts是时间戳。 mT是用户鼠标的移动路径记录,kT是键盘输入的记录,aT应该是停留在<a>标签的记录。这几个参数都和用户行为有关,kT可以通过随机键盘输入来生成。aT不变问题应该不大,mT暂时还没有想到好的方法来伪造。 sign就是iI("riskChannel=201&user=[username]")的结果。 当然也可以选择先不修改这些字段,只要保证tscts字段是最新的就可以了。

有了这些信息,就能开始编写我们的Python代码了。唯一需要考虑的是,我们不能直接在Python中使用pako库,得给他找一个替代者。Google了一番后发现pako的压缩算法其实就是Python中的zlib压缩。那么我们的实现代码大概就是:

def gen_token(input):
    zlibbed_str = zlib.compress(input)
    compressed_string = zlibbed_str
    return base64.b64encode( compressed_string )

dict = {"rId":"100049","ver":"1.0.6", ...}

dict["cts"] = int(time.time() * 1000)
dict["ts"] = dict["cts"] - 10234 # 减一个随机值,因为ts比cts要早

json_str = json.dumps(dict, separators=(',',':'))
json_byte = bytearray(str(json_str), 'UTF-8') 

data = {
    'riskChannel' : 201,
    'user' : [username],
    '_token' : gen_token(json_byte)
}

...

data就是我们需要发出的数据。

至此,我们已经弄明白了encrypPassword_token的生成方式,下一步,就是构造自己的Request。

0x04 实战

有了前两节的基础,实现起来也不会很难了。需要注意的是在构造请求的时候,不能忽视请求头的内容,我们要伪装的像正常的web行为。获取请求头也简单,抓包直接复制过来就是。但是你可能会发现请求头中已经有了Cookies字段。经过测试,没有这个字段也能正常的访问,只要有下面这些请求头就行了:

headers = {
	'Host' : 'account.dianping.com',
	'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36',
	'Accept': '*/*',
	'Referer': 'https://account.dianping.com/account/iframeLogin?callback=EasyLogin_frame_callback0&wide=false&protocol=https:&redir=http%3A%2F%2Fwww.dianping.com%2F',
	'Accept-Encoding': 'gzip, deflate, br',
	'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7',
	'Connection': 'keep-alive',
	'Origin':  'https://account.dianping.com',
	'X-Requested-With': 'XMLHttpRequest',
	'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
}

Done!现在我们已经拥有了所有必要信息,把他们组装在一起就能够实现我们的登录了! 我决定使用一个Account类来封装用户的信息,初始化接受用户名和密码,提供一个login()函数来实现登录功能。登录完成后生成一个Session对象,使用Session对象进行postget操作时就拥有了用户登录信息。

贴上最后实现的代码,一个简易版的自动登录完成。

#!/usr/bin/pythonX
# -*- coding: UTF-8 -*-

import requests
import time
import json
import zlib
import base64
import random

from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5

class DianPingAccount:
    _check_risk_url = 'https://account.dianping.com/account/ajax/checkRisk'
    _login_url = 'https://account.dianping.com/account/ajax/passwordLogin'

    def __init__(self, phone, password):
        super(DianPingAccount, self).__init__()
        self._phone = phone
        self._password = password

        self._headers = {
            'Host' : 'account.dianping.com',
            'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36',
            'Accept': '*/*',
            'Referer': 'https://account.dianping.com/account/iframeLogin?callback=EasyLogin_frame_callback0&wide=false&protocol=https:&redir=http%3A%2F%2Fwww.dianping.com%2F',
            'Accept-Encoding': 'gzip, deflate, br',
            'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7',
            'Connection': 'keep-alive',
            'Origin':  'https://account.dianping.com',
            'X-Requested-With': 'XMLHttpRequest',
            'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
        }
        self.session = None
        self._token_dict = None

    def _gen_token_dict(self):
        self._token_dict = {
            "rId" : "100049",
            "ver" : "1.0.6",
            "brVD" : [290,375],
            "brR" : [[1280,800],[1280,777],24,24],
            "bI" : ["https://account.dianping.com/account/iframeLogin?callback=EasyLogin_frame_callback0&wide=false&protocol=https:&redir=http%3A%2F%2Fwww.dianping.com%2F","https://account.dianping.com/login?redir=http%3A%2F%2Fwww.dianping.com%2F"],
            "mT" : ["87,235","87,235","86,235","86,235","85,235","85,235","85,235","85,235","85,235","84,236","84,237","84,238","83,239","83,240","83,241","82,243","81,244","80,246","76,249","71,253","66,256","58,261","48,266","44,266","41,266","40,266","39,266","39,265","38,264","35,262"],
            "kT" : ["8,INPUT","7,INPUT","6,INPUT","5,INPUT","4,INPUT","3,INPUT","\b,INPUT"],
            "aT" : ["87,235,BUTTON","93,162,INPUT","207,115,INPUT","226,42,A","262,6,DIV"],
            "tT" : [],
            "aM" : "",
            "sign" : "eJxTKsosznbOSMzLS82xNTIwVCstTi2yNTQ1NzAwMLewMDdWAgDCewnn"
        }

    def _gen_token(self):
        if not self._token_dict:
            self._gen_token_dict()

        dict = self._token_dict

        dict["cts"] = int(time.time() * 1000)
        dict["ts"] = dict["cts"] - random.randint(10000, 50000)

        json_str = json.dumps(dict, separators=(',',':'))
        input_byte = bytearray(str(json_str), 'UTF-8') 
        zlibbed_str = zlib.compress(input_byte)

        return base64.b64encode(zlibbed_str)

    def _get_check_risk_data(self):
        data = {
            'riskChannel' : 201,
            'user' : self._phone,
            '_token' : self._gen_token()
        }

        return data

    def _encrypt(self, input_str, public_key):
        public_key = '-----BEGIN PUBLIC KEY-----\n' + public_key + '\n-----END PUBLIC KEY-----'
        rsa = RSA.importKey(public_key)
        cipher = PKCS1_v1_5.new(rsa)
        ciphertext = cipher.encrypt(input_str.encode('utf8'))
        return base64.b64encode(ciphertext).decode('ascii')

    def _get_login_data(self, public_key, uuid):
        data_arr = [self._password, uuid]
        data_str = json.dumps(data_arr, separators=(',',':'))
        encrypted_passwd = self._encrypt(data_str, public_key)

        return {
            'countrycode': 86,
            'username': self._phone,
            'keepLogin': 'on',
            'encryptPassword': encrypted_passwd,
            '_token': self._gen_token()
        }


    def login(self):
        self.session = requests.Session()
        r = self.session.post(self._check_risk_url, 
            headers = self._headers, 
            data = self._get_check_risk_data(),
            verify = False
        )

        r_dict = json.loads(r.text)

        public_key = r_dict and r_dict['msg'] and r_dict['msg']['publicKey']
        if not public_key:
            print("Error: Cannot get public key!")
            return False

        uuid = r_dict and r_dict['msg'] and r_dict['msg']['uuid']
        if not uuid:
            print("Error: Cannot get uuid!")
            return False

        r = self.session.post(self._login_url,
            headers = self._headers,
            data = self._get_login_data(public_key, uuid),
            verify = False
        )

        if not r or not r.cookies:
            print("Error: Login failed!")
            return False

        print(r.cookies)
        return True

0x05 结语

像上面所说的一样,这只是一个简易版的登录脚本。登录过程中还会遇到很多其他的情况,比如验证码,登录限制等等。这些情况都没有处理,也不是那么容易处理。另外使用不当可能会造成账号短暂被锁,限制密码登录甚至账号被ban等后果,请注意。 点评的反爬虫机制还是很严格的,请小心使用。 关于验证码的部分,现在的ML这么火热,验证码识别已经是可以解决的问题了,有时间的话再来试一试验证码的识别。