layout | title | categories | tags | ||
---|---|---|---|---|---|
post |
点评登录自动化 |
Tech |
|
这篇文章是尝试用Python
模拟点评账号自动化登录的记录。仅仅保证当前能正确执行,任何的变化都可能造成这篇文章提及的方法失效。
目前点评的登录方式主要有扫码登录、手机短信登录、手机号密码登录以及第三方登录。很显然,只有手机号密码登录这一条途径最容易实现自动化登录。
打开登录页面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到底是怎么生成的。
打开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
字段是通过加密password
和uuid
获得的。
s.stringify()
是一个将数据结构转成json
字符串的方法,这里最后得到的字符串是"[password,uuid]"
注意中间没有空格。
n.encrypt()
调用的是JSEncrypt
的encrypt()
方法,核心是RSA
非对称加密算法。
在Python
中,我们可以借助pycrypto
来实现相同的加解密算法。
需要注意的是,JSEncrypt
使用的publicKey
并不直接是setPublicKey
的key
,它在前面和后面分别填充了一段字符。
最终使用的publicKey
如下:
finalPublicKey = "-----BEGIN PUBLIC KEY-----\n" + [your_public_key] + "\n-----END PUBLIC KEY-----"
另一点需要主要的是,JSEncrypt
中RSA
使用的填充方法是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
这个参数。正好与我们需要解决的第二个问题相同。
我原本以为搞定了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
是一个第三方库pako
,deflate
将字符串进行了一次压缩。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
字段都可以是确定的值,不用特别处理。
ts
和cts
是时间戳。
mT
是用户鼠标的移动路径记录,kT
是键盘输入的记录,aT
应该是停留在<a>
标签的记录。这几个参数都和用户行为有关,kT
可以通过随机键盘输入来生成。aT
不变问题应该不大,mT
暂时还没有想到好的方法来伪造。
sign
就是iI("riskChannel=201&user=[username]")
的结果。
当然也可以选择先不修改这些字段,只要保证ts
和cts
字段是最新的就可以了。
有了这些信息,就能开始编写我们的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。
有了前两节的基础,实现起来也不会很难了。需要注意的是在构造请求的时候,不能忽视请求头的内容,我们要伪装的像正常的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
对象进行post
和get
操作时就拥有了用户登录信息。
贴上最后实现的代码,一个简易版的自动登录完成。
#!/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
像上面所说的一样,这只是一个简易版的登录脚本。登录过程中还会遇到很多其他的情况,比如验证码,登录限制等等。这些情况都没有处理,也不是那么容易处理。另外使用不当可能会造成账号短暂被锁,限制密码登录甚至账号被ban等后果,请注意。 点评的反爬虫机制还是很严格的,请小心使用。 关于验证码的部分,现在的ML这么火热,验证码识别已经是可以解决的问题了,有时间的话再来试一试验证码的识别。