APK应用市场爬虫的爬取与下载策略

最近在集中突击项目,队友在用机器学习算法做应用分析的时候需要大量的软件样本。其中恶意软件可以在公开的病毒中下载https://virusshare.com,从论文中可以看到基本上所有的做恶意软件分析的项目都是从这个库中下载的恶意软件样本,这个网站要联系作者提供邀请码才能注册,虽然作者回复的也蛮及时,闲麻烦的同学可以先用我的账号登陆看看,ID:Yiruma, Pass:cauccauc

算法分析中除了恶意软件样本,还需要很多正常的,保证安全的软件,然而网上并没有提供这样的下载连接,所以只能自己去应用市场上爬,因此也写了不少小爬虫。因为软件下载网站或多或少都会有防盗链的机制,这里分享出来我的设计思路。

一般的防盗链机制

我们在网站上下载一个apk应用往往需要这几个步骤:

  1. 打开应用市场主页
  2. 打开某一个应用分类
  3. 应用分类中展示了此分类下的软件列表,我们点进去一个进入某一个apk页面
  4. apk页面一般不会直接提供下载连接,而是在我们点击下载按钮的时候,使用js来异步发出请求,获取apk的真正下载连接,这些下载连接往往是动态变化,有时间限制的
  5. 浏览器根据下载连接下载apk

整个过程解决起来大致可以分为三块

第一步就是从网站上爬取所有的apk链接,这些apk链接一般都是不会变化的,所以设计起来就和普通的爬虫一样

第二部我们需要设计一个函数,输入apk链接,能够自动将apk的下载链接解析出来

最后一步就需要我们能够对下载链接实现批量下载

下面就开始分别介绍这三个步骤。这之前还有一点很重要的是,实际网站都会对过量访问做限制,如果短时间内访问量过大,就会封掉ip导致一段时间内403或者429,所以爬取中千万不要搞多线程,两次请求间千万要time.sleep()上一段时间,一旦被封ip,一般都要一个多小时后才能解禁

第一步: 从应用市场主页中爬取所有应用链接

为了下载的应用分布比较全面,我的爬取策略是首先从应用市场主页面拿到所有的分类页面链接,之后再从分类页面中下载详细的apk

这些链接往往都比较明确,如果bs4用的比较熟的话可以直接通过解析html页面然后从DOM上拿链接,不过我的做法比较简单,不过也很有效,直接通过正则表达式就可以获取,比如如下页面

左边是在网站主页上找到的分类链接,可以看到有Business,Education,Entertainment等诸多种类,他们的链接可以用如下正则提取

1
https://www.xxx.com/category/[a-zA-Z0-9]{1,30}/\d/

右边是点进去分类页面后所展示出的apk列表,可以看到这些apk的链接可以用如下正则提取

1
https://www.xxx.com/app/[a-zA-Z0-9\.]{1,100}/

大致通过如下代码可以拿到不少apk的链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#传入应用市场主页URL,拿到所有分类链接
def getCategoryList(url):
mainPage = s.get(url).content
regGetCategory = re.compile("/category/.*/\"")
categoryList = regGetCategory.findall(mainPage)
for i in range(len(categoryList)):
categoryList[i] = "https://www.apkmonk.com" + categoryList[i][:-3] + "{}"
return categoryList
#传入分类链接,获得所有apk链接
def getAppList(url):
subPage = s.get(url).content
regSubpage = re.compile("/app/.*/\"")
appList = regSubpage.findall(subPage)
appListRetu = []
for i in appList:
temp = "https://www.apkmonk.com" + i[:-1]
if temp not in appListRetu:
appListRetu.append(temp)
return appListRetu

简单跑了一下,获取每个分类下的10页,最后拿到了10000个左右的apk链接,将这些链接保存下来,可以一直重复使用

第二步: 传入一个apk链接,获得其下载链接

这里就是针对防盗链措施的解决方法,看下比较常见的apk防盗链措施:

我们首先访问之前拿到的一个apk链接

https://www.apkmonk.com/app/in.promettheus.fhrai/

访问之后,页面上显示DownloadAPK,链接指向

https://www.apkmonk.com/download-app/in.promettheus.fhrai/5_in.promettheus.fhrai_2018-01-03.apk/

到这里还不是最终的apk下载链接,因为尝试访问会发现返回的仍是一个html页面,其中的关键信息如下:

$('#download_sub_text').html('You could also download directly by <a onclick="ga(\'send\', \'event\', \'link\', \'click_here\', \'in.promettheus.fhrai\');" href="'+data.url+'">clicking here!</a>');

这是一段js代码,可以看出来我们的下载链接是通过js动态执行后才生成的,所以我们直接访问这个url是无法拿到下载链接的,这里介绍两种思路

方法一:类似于XSSbot的js代码执行

在xss中的bot可以触发我们所发送的js代码,这里关于bot怎么写,可以去翻下土师傅的blog,我采用的是Phantomjs来动态加载js代码

梳理下我们的思路:

  1. 调用phantomjs动态执行页面,之后将执行js后的页面重新保存
  2. python解析html文件,拿到真正的下载链接

我们的js可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var webPage = require('webpage');
var system = require('system');
var args = system.args[1];
var page = webPage.create();
var fs = require('fs')
//将参数作为打开的页面
page.open(args,function(){
//2000毫秒后,执行页面内容保存工作
setTimeout(function(){
fs.write('E:/fei.txt', page.content, 'w');
phantom.exit();
},2000);
});

整个python代码大致长这样

1
2
3
4
5
6
7
8
9
10
def down_url(url):
#获得apk链接
os.system("phantomjs test.js "+url)
#从文件中解析内容
content = open("E:/fei.txt", 'r').read()
apk_url = reg.findall(content)
if len(apk_url) == 0:
return False
return apk_url

方法二:使用python模拟js代码

这也是比较推荐的方法,有些获取下载链接的js其实并不复杂,通过对其分析,我们甚至可以找到更为简便的api

1
2
3
4
$.get('/down_file/',{"pkg":"in.promettheus.fhrai","key":"5_in.promettheus.fhrai_2018-01-03.apk"}).done(function(data)
{
//xxx...
}

可以看到其实就是调用jq的get方法发送了一条get请求,请求是

https://www.xxx.com/down_file/?pkg=in.promettheus.fhrai&key=5_in.promettheus.fhrai_2018-01-03.apk

我们尝试过去访问一下,返回结果如下:

1
{"url": "http://apk.apkmonk.com/apks-3/in.promettheus.fhrai_2018-01-03.apk?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=IFVYHACUO60QSGWW9L9Z%2F20180103%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20180103T164103Z&X-Amz-Expires=2400&X-Amz-SignedHeaders=host&X-Amz-Signature=b187cf0e7b2b680b5dcd858243ba624cb0aa360021af9827628af21ee29162ed", "resp": "success"}

json格式数据,里面的url就是我们的apk下载链接,所以直接用python生成url然后访问就可以拿到apk的连接,Bingo!

这种分析网站的api方法是比较推荐的,毕竟phantomjs的方法执行起来太过麻烦,事实上在这个网站里我不仅发现了不止一个能够获取apk下载连接的api

OK,最后我们拿到的下载连接大致就是这个样子

http://apk.apkmonk.com/apks-3/in.promettheus.fhrai_2018-01-03.apk?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=IFVYHACUO60QSGWW9L9Z%2F20180103%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20180103T160517Z&X-Amz-Expires=2400&X-Amz-SignedHeaders=host&X-Amz-Signature=3e8625b5087cd37ab6f274c08957c26c50699589772040e00315669e32631422

第三步: 下载连接分析与下载方式

首先我们应该简单分析一下这些连接,就拿上面的连接作为例子

可以看到里面有关于时间的参数X-Amz-Date=20180103T160517Z,猜测是连接生成时间,还有一个名为Expires的参数X-Amz-Expires=2400,猜测是连接有效时间,2400猜测是2400s也就是40分钟,从参数X-Amz-Algorithm=AWS4-HMAC-SHA256中可以看到使用了HMAC算法,对应的签名是X-Amz-Signature=3e8625b5087cd37ab6f274c08957c26c50699589772040e00315669e32631422,没错正好是SHA256的64位,这样整个连接是不可伪造的了,我们不能手动修改时间参数保证链接一直有效,也不能猜测组合生成hash的参数,因为使用了HMAC。另一方面,连接还是有时间的所以拿到连接后还是要尽快下载才行,不能批量拿到所有的下载连接再批量下载了。简单验证一下我们的猜想,我测试了一个昨天的连接,以及一个修改了时间参数的连接,结果都是返回403Forbidden,说明我们的下载连接没有通过验证,不能下载。

之后便是下载方式的介绍了,这里还是介绍两种

Wget

在拿到下载连接后,直接python通过系统调用的方式调用wget工具完成下载,这种的好处是可以在拿到连接后直接下载,同时也很方便控制多线程同时下载,被禁ip的话解封后可以接着上次的接着下载,但问题也很明显,首先wget这个工具总感觉下载速度不快,另外python系统调用os.system(“wget xxx”)对返回结果不好观察,尤其是开多个线程同时下载的时候控制台输出的命令返回结果简直是群魔狂舞,所以推荐真的使用这种方法的话加上-q 或者-nv参数开启静默下载功能,这样就没有输出了。

另外一个可能用到的参数是-O,用于指定下载文件的位置以及文件名,还有个参数–spider,表示不下载任何东西,只是看下响应头来后的文件大小,不过感觉有时候并没有用。。。

IDM

IDM是今天才想到的下载方法,这是一个文件下载工具,下载速度极快,也有很强大的下载自定义功能。我们要做的就是每次获取一小部分下载连接,然后以html格式写到文件里,之后打开此html文件,右键使用IDM下载全部连接,就可以将所有的连接添加到下载队列。

那些error请不要在意,如上面所述这些下载连接都已经过时了,没有通过检测

关于网站防盗链的一些考虑

其实从上面来看,通过程序批量的获得一些apk还是比较容易实现的,通常的js执行然后获取下载连接的方案很容易解决,事实上整个操作中对我们造成最大的阻碍是云服务器厂商做的访问限制,网站的防护通常并不有效

也许网站可以设置为用户注册后才能下载apk,并基于用户身份进行apk下载的峰值限制,也许还可以在每个apk下载前都需要人工输入验证码,或者可以将自己的api隐藏的更为隐蔽一点,个人觉得,还是在每次下载前引入不得不人工操作的步骤才是最有效的方法,比如下面这样 2333

与本文无关,记录些写爬虫时的问题与学到的东西

http的head方法是不获取文件,只是请求响应头,可以用于获取文件大小,python中的requests包支持head方法

1
2
3
4
import requests
s = requests.session()
s.head(url)

相同的在curl中对应的参数是 curl -I , wget中是wget –spider


关于如何做到文件下载记录,考虑了很多种,apkList.txt除了apk连接,做成一个字典,一定要有的是后面0,1表示已经处理和没有处理,如果下载连接可以一直保存的话,可以考虑将下载连接也存到里面,之后将dict对象使用json转成字符串存储到apkList.txt中


json的简单使用方法再复习一下

1
2
3
4
import json
dict = json.loads(str)
str = json.dumps(dict)


在python3中,os.popen改成了子线程执行,程序不会阻塞等待命令执行完毕,需要单线程的话可以采用os.system,不过阅读popen的文档应该能找到更好的解决方案


跑的过程中需要持续写文件的话,可以使用fw.flush()函数,将缓存区中的字符写到文件中,防止中途报错而丢失之前的数据


当时做多线程拿下载连接的时候,考虑了这样的方式,每个线程处理完需要往dict中置apk的url为1表示下载完成,为了防止中途报错丢失数据,每次结束后都重新将dict写入文件。测试中发现中途报错终止的时候,子线程并不会直接停止,而是停止阻塞强行运行完毕,之后所有程序结束运行,记得之前在java线程池实现的时候线程池的确设计了这种机制,后来爬虫决定不用多线程了,此事也就作罢


线程池简单使用方法记录,包在python3中可用

1
2
3
4
5
6
7
8
9
from concurrent.futures import ThreadPoolExecutor
#参数指定线程池的大小
pool = ThreadPoolExecutor(5)
#submit往池子里扔函数,第一个是函数名,后面是参数
pool.submit(getApkHTML, 50)
pool.submit(getApkHTML, 100)
pool.submit(getApkHTML, 150)
pool.submit(getApkHTML, 200)
pool.submit(getApkHTML, 250)