开源地址：https://github.com/lightless233/sign-me-2 0x00 上一篇文章主要分享了越权测试插件的思路，经过一段时间的使用，确实节省了不少时间，也遇到了新的问题。 很多网站现在发起请求时，会附加一个 sign 签名值，以保证参数值在传输的过程中不被篡改。这同样也给我们修改参数进行测试带来了一些麻烦。所以如果能在 burp 中修改完数据包后自动对参数进行签名，可以节省不少算签的时间（当然如果连签名算法都不知道，也没办法手动算签）。 0x01 基于以上的情况，简单糊了一个新的 burp 插件 SignMe，专门用于计算各类签名，并将计算好的签名替换回原始数据包。总体来说这个插件并没有什么特别的东西，毕竟只是一个简单的参数替换。 不过在这里动了一点小心思，并没有把某个特定的签名算法写死到插件里，二是额外塞了一个 JS 引擎进去，可以让用户通过编写 JS 代码自行实现签名算法。 由于 JS 中还需要和 Java 互相操作，最终选用了 graalvm 的方案。之所以使用了 JS 代码来编写签名算法，是因为在日常的测试过程中，大部分网站的签名算法都是由 JS 实现的，很多情况下我们甚至可以直接将网站的 JS 复制过来，稍微改一改就能复用了。如果选择其他语言，还需要经历一个翻译算法的过程，如果做过类似的工作就会发现翻译语言这个过程中会遇到很多的坑：比如不同语言加密库的实现不同、字符编码问题等等。所以干脆直接选择 JS 来作为签名算法的计算语言，既省时又省力。 在 sign-me 中，已经提前封装好了大量常用的方法，包括：打印日志、参数排序、计算常见哈希、获取时间戳等等，希望用户只是像拼积木一样就可以拼出一个签名算法，将更多的时间放到对目标的测试过程上，而不是将精力浪费在计算签名这种事情上。 当计算完成后，通过返回一个指定的数组，表示需要修改哪些参数，如下图所示： 在 burp 中，可以通过修改 sign-me 和 auth-test 的插件顺序，就可以实现自动对重放包进行签名的功能，从而进行半自动的越权测试了。 sign-me 的代码暂时也没有开源的计划，主要是因为写的太烂了（这是我写的第一个burp插件，所以写的很烂），计划在近期对代码进行一次重构，之后会放出来供大家一起使用。
0x00 最近一段时间，做了一些自动化/半自动化的测试小工具，主要还是想从重复的工作中解放出来，把时间投入到更有意义的事情中。 开发语言也从之前的 Python 逐步切换到了 Rust，虽然 Python 是一门很棒的语言，可以让我快速的实现各种想法的原型，但是 Python 在工程化的时候，总会出现一些不尽人意的地方。恰巧之前也学过一些 Rust，便逐步将主力开发语言切换到了 Rust。关于这部分的内容，后续可以再单独讲讲。 在最近的一些测试工作中，很大一部分目标都是各类 CMS、WMS 系统，经过前期的一些人工测试，发现这类业务逻辑相对简单且以 CURD 为主的系统，很难出现一些高危的 Web 漏洞（特指常见高危Web漏洞，例如：命令执行、SSRF、SQL注入等）。主要的风险还是以水平越权和垂直越权为主。 很多人可能根据经验会有疑问，为什么很少甚至不会出现 SQL注入漏洞？ 在大型甲方企业的开发流程中，开发这类系统时，一般都不是从零开始写第一行代码的，而是使用该团队积累下来的各种二方包。对于常见的业务操作，例如 CURD、OSS上传等等，都有较为成熟的框架和解决方案。一旦出现过一次漏洞，通过对二方包的修复，很容易就实现了对所有其他系统的修复，以及在新系统中的预防。 顺便一提，对于其他类型的漏洞，例如 SSRF 和 命令注入等，在常规的 CURD 类的 CMS、WMS 系统中，根本不会出现相关的业务场景，所以更不会出现相关的漏洞了。 越权检测一直是扫描器领域长盛不衰的话题，目前在越权检测这个方向下面，还没有看到非常成熟的方案，也没有通用的银弹。 虽然没有银弹，但是一定有人已经造过轮子了，先去搜索了一些 Burp 上比较知名的越权测试插件，使用了一圈下来感觉都很不爽。有些界面丑陋且已经年久失修无法使用，有些逻辑复杂操作反人类，终于还是决定写一个自己用起来顺手的小插件。 0x01 在正式开始造轮子之前，我们先回顾一下手动测试越权的流程。 假设我们使用两个相同权限的账号 A 和 B，分别使用不同的浏览器登录。Chrome 登录账号 A，Edge 登录账号 B，给 Chrome 浏览器添加代理，把流量转发到 Burp。 在 Burp 中发现一个接口：http://api.example.com/user/get?id=10001 ，分析后发现是查看当前用户信息的接口。此时如果想测试越权，我们需要找到账号 B 中，此接口对应的参数，并将其复制过来到 Burp 中。假设 B 账号得到的参数为 id=51234 ，在 Burp 中修改参数后发送，并人工检查 Response 中是否含有 B 账号的数据。 在这个过程中需要来回切换多个窗口，复制粘贴不同账号的数据，最后还需要人工对比 Response 数据的内容，需要操作很多个步骤，并且非常的麻烦。另外这只是进行了两个同权账号的测试，如果系统的角色非常复杂，有管理员、普通会员、子账号的情况下，需要手工处理的工作就更多了。 测着测着，很自然的就出现了第二个想法：如果不替换参数，直接替换身份凭证是否可行？ 答案自然是可行的，我们不用再去复制 B 账号每个接口的参数，只需要登录一次 B 账号并获取到 B 账号的身份凭证信息（Cookie、X-ACCESS-TOKEN等），在每次测试 A 账号的时候，顺手把 A 账号的身份凭证修改为 B 账号，就相当于以 B 账号的身份使用 A 账号的参数发起请求，直接查看返回是否一致就可以了。我们不需要来回的切换窗口，完全在 Burp 一个窗口中操作就可以了，当 Response 过大不方便肉眼看的时候，甚至可以使用的 Burp 的 diff 功能检查两个包是否有相同数据。 当然这个方案也有弊端： 如果参数中、头中含有 CSRF Token 或类似的标记，此方法无法通过服务端的相关校验； 依然无法避免角色过多带来的重复操作问题，同一个接口，每一个角色都要替换数据发一次包； 在少部分业务场景下，如果参数本身是无规则字符串，例如 UUID、MD5 等情况且根本没有接口可以获取到其他账号的资源 ID，按照业务设计规则，可能不涉及越权问题； 这一点实际上是有分歧的，理论上来说，我使用其他账号的资源 ID 获取到了资源内容，本身就属于越权，只能最终与业务沟通并根据实际情况再做是否修复的决定。 到目前为止，我们有了一些进展，简化了替换参数的工作量。但是我仍然希望将这个过程自动化，在我每次测试其他漏洞的时候，Burp 自动化帮我替换身份凭证，并比对返回值是否相似。 0x02 我们首先要解决的问题，就是账号的替换的问题，对同一个接口的请求，要以何种策略进行重放，是这个插件的核心逻辑。 回顾一下正常的测试流程，为了测试水平越权和垂直越权，我们至少需要准备3个账号，如果我们只想测试水平越权，或只想测试垂直越权，那么准备两个账号就可以了： A 类账号：基准账号； B 类账号：同权账号，与 A 类账号同权限，用于测试水平越权； C 类账号：低权、无权账号，比 A 类账号权限低，用于测试垂直越权； 有了账号，下一步需要确认的就是重放顺序。对于查询数据的请求而言，重放顺序并不那么重要，无论哪个账号先执行请求，都不会影响返回结果。而对于创建资源、修改资源、删除资源之类的请求，顺序就非常敏感了。比如针对一个删除操作的越权测试，如果我们先使用 A 类基准账号测试，那么无论是否有漏洞，C 类账号的操作一定是失败的，因为资源已经被删除了，也就没办法使用这个资源 ID 进行越权测试了。如果使用 B 类同权账号进行测试，假设存在水平越权漏洞，那么资源已经被 B 类账号删除，C 类账号操作也会失败，也无法判断是否存在垂直越权了。 根据以上推论，我们可以规定出请求重放的顺序：C 类账号 > B 类账号 > A 类账号，在每次请求完成后，把响应结果记录下来，当所有的重放完成后，对响应内容进行相似度判断以检查是否存在越权漏洞。 现在看起来似乎没有什么问题了，但是我们还忽略了前面提到的一点，如果业务系统有更多的角色或者业务身份呢？比如超级管理员，小管理员，后台管理员，前台管理员，普通会员，游客等等。 首先我们先看看能否简化为ABC三类账号，比如有些系统虽然有很多角色，但是是通过为某个账号单独赋权得到的。举个例子，某个系统的账号必须归属于其中的一个角色，而每个角色类型可以单独设置权限点。虽然看起来这个系统非常复杂，有非常多的角色，但是实际上我们测试时，只需要一个开启所有权限点的角色，和一个没有任何权限点的角色。两个开启全部权限点的角色的账号分别作为 A 类账号和 B 类账号，没有权限点的角色可以作为 C 类账号，问题迎刃而解。 但是实际测试中发现，并不是所有的系统都可以这样处理，有些系统无论如何也无法简化为 ABC 三类账号，那又要怎么办？ 此时我们只需要对插件的账号系统做一些小小的修改即可，添加一个 D 类账号，代表自定义角色。那么问题又来了，D 类账号的重放顺序应该如何定义呢？很明显当你遇到这么复杂的情况时，事先并不能确定应当如何重放，也就无法在插件中写死重放顺序，添加一个优先级功能根据实际情况判断是最好的做法了。 那么现在的账号体系就变成了下表这样，优先级越高请求顺序越靠前： A类账号，优先级-1，基准账号，所有其他账号的 Response 均和这个做对比，优先级最低，一定最后一个重放请求； B类账号，优先级50，同权账号，用于测试水平越权，优先级默认为 50，夹在低权和基准之间； C类账号，优先级101，低权账号，用于测试垂直越权，一定第一个重放请求； D类账号，优先级1~100，自定义角色，请求优先级可以根据实际情况自定义； 最后，再添加一个配置管理系统，给不同的站配置不同的账号，再加一点点界面，基本上就完成了。 0x03 每一个配置相当于一次测试项目，在同一时间，只能开启一个配置。 在每一份配置中，可以单独配置目标 scope、黑名单、重放设置等，防止触发重放各种不必要的请求； 同时账号也是在每一份配置中设置的，在这里填入需要替换的身份凭证信息，可以是 HTTP Header，也可以是某个参数。 当正确配置完成账号信息后，此时在浏览器中访问目标网站，AuthTest 将会使用配置好的账号自动重放这些请求。所有的请求都会在数据中心中展示。 在这里，可以对某些请求标记为漏洞，也可以将某些请求加入黑名单（比如图中出现的baidu统计请求），加入黑名单后，后续遇到该 URL 时将不再重放。 点击某一行即可看到详细的请求信息，相似度为每一个包与基准账号的 Response 做对比，使用了编辑距离算法。 经过一些实际测试，确实解放了工作中很大一部分的劳动力，减少了很多的重复性工作，漏洞挖掘效率也得到了显著提升。 不过由于该工具还在完善阶段，并且很多功能是针对个人使用习惯高度定制化的，暂时没有开源的打算，后续也会继续分享一些最近一段时间开发的其他小工具，以及一些安全建设理念。等到小工具打磨成熟了，会适时的放出来与大家一起使用。
How To Designing A Faster Than Faster GitHub Monitoring System
0x00 为什么需要这样的系统 近些年来，特别是2018年，各种不同的Github监控工具层出不穷，究其原因，还是每家企业生怕安全意识不足的员工将内部代码、账号信息上传至Github中，导致一些严重的安全问题，很有可能我们费劲千辛万苦构筑的边界防线，就被一个开发人员轻松的打破了。 GEYE GITHUB 传送门 目前市面上有许多种Github监控工具以及系统，有命令行的，有带界面的，有直接爬Github搜索页面的，也有借用API来搜索的，经过一些调研，发现各有特色，但是对于“开箱即用”等要求上，可能略有些距离。 本人在上家公司时，也曾负责过Github监控系统的开发与运营，经过很长时间的摸索，也总结了一些经验以及思路，并实现出了本文的主角GEYE监控系统（Github Eye）。在之前公司，Github监控系统应该说是经历了多次迭代，从最早的单文件脚本，逐步升级成了一个可视化的精确Github信息捕获系统，也多次先于白帽子发现了Github上的敏感信息，以及很多没有被外部发现的敏感信息。最快可在敏感信息被上传15分钟内捕获发现。 0x01 简谈设计思路 1. 整体设计思路 整体思路其实非常简单，就像把大象放到冰箱里一样，只有三步： 从Github获取我们感兴趣的信息 人工/自动筛选、过滤 入库并通知相关人员/系统 但是在每一步中，我在实现的时候，均会遇到一些比较坑的问题，也选取了不同的方案做了一些实验，最终得到了一个比较符合我预期的最佳方案，下面来简单介绍一下。 2. 获取信息 监控的第一步当然是要获取信息，获取信息的方式有很多，目前开源的监控系统里，有直接使用爬虫爬取搜索页面的，也有调用API的，当然我这里推荐的做法是调用官方的API来获取原始数据，说实话我不是很理解一些项目中通过直接爬取搜索页面获取数据的方式，也没有想到这样做有什么优势，甚至印象中好像还有某个项目为了避开Github的爬虫来源IP检测，还用Nginx搭建了一个反向代理集群来解决。所以我认为直接爬取搜索页面不是一个十分优雅的做法。 那么我们就剩下一条路可以选了，那就是利用Github提供的APIv3来获取原始数据。因为目前APIv4还不支持搜索代码，如果未来支持了，GEYE也许会切到APIv4上。 我们先来看一下搜索代码的API文档：https://developer.github.com/v3/search/#search-code 怎么用这里就不说了，相信大家都会用，我们只说一说需要注意的问题。首先这个q参数就是我们想查询的关键字，比较好理解，但是我们除了要添加关键字之外，还要额外添加一些参数，那就是fork，这个参数是用来指定“是否搜索fork”仓库的，如果这个值为true，那么这个接口会返回来自fork的仓库的数据，如果某个很著名的仓库（例如fastjson）中命中了你的关键字，那么你可能会拿到无数的重复信息，这不是我们希望看到的；我们希望的是尽可能多的获取不同仓库的信息，而不是同样的内容返回一万遍。 另外一点，我们其实希望尽可能快的获取到“新鲜”的泄露信息，所以我们还要修改这个接口的默认排序方式。默认情况下，Github会为我们返回最匹配的结果，无论这个代码是什么时候被上传的。所以我们需要指定sort参数。 结合起来，我们最后的请求参数可能是这样的：/code/search?q=keyword+fork:false&sort=indexed 说完关键字之后，应该就可以开始写爬虫调用API了，但是这里还有一点，Github为了防止我们滥用API，会有频率限制。正常情况下，每个Personal Access Token的频率是每分钟可以请求30次API接口，如果调用过快，会触发滥用警告，就是会限制你的token 1分钟。如果是通过OAuth2授权的App级别的token，请求上限会高很多。 基于这样的限制，我们没必要去优化我们的爬虫让他爬的很快，因为同样时间下，调用的频率是被限制好的。我们只需要利用普通的多线程爬虫获取数据即可。 3. 筛选信息 当我们将数据取回后，就需要决定哪些数据需要被人工确认，或是直接确认，哪些数据需要被丢弃。当然一股脑的将所有代码全都交给人工来判断也不是不可以，只要你的精力足够。 我们当然希望系统能够自动帮我们过滤掉一些无用的数据才好，我们人工只关心最有可能是泄露的部分，那么这就要考验我们的筛选模块了。 一开始，我是以API接口返回的代码段作为“基础值”，来进行过滤的，但是后来发现，接口返回的代码段里，其实并不会完全包含你的关键字。举个例子，比如你搜索的是corp.lightless.me，但是返回的代码段中，可能更多的是包含了corp、lightless、me三个分开的单词的结果，这也为我们过滤信息造成了一定的困扰。 再举一个例子，我们搜索的内容是lightless.me+password，那么返回的代码段中，很有可能只包含lightless.me的结果，而关于password部分的代码段没有被返回回来，这样情况过滤的时候，就有可能漏掉了。 为了避免这些情况，我的解决方法可能有些蠢，就是利用返回的信息，拼出raw.githubusercontent.com对应的代码文件，将其拉取回来，作为代码部分过滤的基底，这样的情况下可能相对来说会更准确一些。 需要过滤的内容不只是代码内容，还有username、repoName、filename、path等，也需要作为自动过滤的考量。至于为什么，大家可以自己尝试探索一下。 4. 存储信息 对于存储信息来讲，其实没有什么好说的了，关键问题在于如何去重。Github天生的sha值就是一个很好的去重参考值。我们考虑下面这样一个场景： 如果某个文件，第一次被处理的时候，出现了敏感信息，并且入库保存； 当文件更新了新的敏感信息后，这时候如果以文件名、仓库名等信息去重，就会认为已经和库内的信息重复了，无法正常入库，导致我们错过了重要的信息。 而每个文件都具有一个sha值，当文件变动的时候，这个值也会跟着变动，所以我们不用去费劲心思设计去重的指标，利用这个sha值就好了，这也是API接口会返回给我们的数据之一。 0x02 规则系统设计 规则系统应当算是这个系统的核心以及亮点，设计了一套自认为比较完善的搜索规则加过滤规则系统。整体来看，规则系统分为三大部分，分别是：搜索规则，过滤规则，全局过滤规则。 1. 搜索规则 搜索规则比较容易理解，其实就是搜索的关键字，用于初筛数据。每个搜索规则会含有多个过滤规则，用于第二层过滤数据。搜索规则的话，一般是搜索某个企业的内网域名，这样搜到敏感信息的命中率会比较高一些，比如corp.example.com，inner.example.com，example.net，example.org等等，每家公司都有自己的内网域名定义方式，如果是白帽子，可能就需要细心收集了。 当然这里也是有一些trick的，根据Github的搜索文档，会发现有全字匹配的搜索方式，即用双引号把关键字包起来："corp.example.com"，这样搜到的内容中会大部分含有完整的域名，而不是分开的三个单词，在实际使用的时候，是使用全字搜索还是非全字搜索，这都要针对不同的情况进行选择了。 2. 过滤规则 这就是第二层的具体过滤规则了，用于从初筛回来的数据中提取敏感信息、过滤垃圾信息等等。我在这里设计的时候，支持两种方式，字符串匹配以及正则，对于一些简单的情况，就可以直接利用字符串code.contains("keyword")的方式来匹配，当需要进行复杂的过滤的情况，就可以使用正则来匹配，比如(token|password|passwd|access[_]*key|secret[_]*key)["']*\s*(\=|\:)这种情况。 设计字符串匹配的原因还有一点，就是因为Python本身的正则匹配存在一些坑点。有时候会搜到一些.json文件，而这个文件只有一行，大小一般在几十KB上下，这时候Python的正则匹配会非常的慢，甚至导致DoS问题，进而整个过滤线程都卡死了，影响到整个系统的正常运行。 所以这里我在设计的时候，除了使用Python内置的正则，还调用了grep命令来解决这个问题，这样虽然会额外消耗一些新开进程的资源，但是整体来说会相对稳定一些。 3. 全局过滤规则 全局过滤规则，顾名思义，就是作用于所有搜索规则的过滤规则，为什么要这样做呢？想必做过Github信息泄露监控的同学会遇到过这种问题，搜索到的内容经常是一些垃圾信息，但是非常具有通用性，比如example.github.io的仓库名，这些一般都是静态博客，里面的一些html中经常误报。甚至是其他的html文件也会经常误报。对于这种情况，如果为每一条搜索规则都添加过滤规则的话，会十分重复。如果有上百条搜索规则的话，加起来怕是要累死。所以我设计了一个全局过滤规则，这样就可以在真正的进行过滤前，过滤掉一些无用的数据。 0x03 重点监控规则 很多时候，我们除了需要定时搜索敏感信息外，可能还想定时监控某个仓库、某个用户的行为，是不是上传了某个敏感文件，FastJSON是不是又有了安全更新，如果可以拿到这些信息，那么在对于我们研究一些组件的安全性、还是定点监控某个仓库、用户的行为，都有很大的帮助。 为此，特意为GEYE添加了重点监控功能，是基于Github的Events机制来完成的。 举几个可能会用到的场景：比如我需要监控FastJSON仓库的每一次push行为，来及时的获取更新内容，看看是不是有安全更新，如果有安全相关的更新，那么可以马上diff代码，查找相关可能出现漏洞的地方，并且及时的在公司内部安排新版本修复计划。另一方面，也可以分析出最新的PoC，添加到扫描器中扫一波。 还有些时候，发现了某用户或某仓库，出现了疑似敏感信息，例如内部数据库的账号密码，但是无法完全确定。或者是需要监控公司开源的仓库项目是否会出现敏感信息的情况。这个时候可以利用重点监控功能监控该仓库或该用户，一旦该用户提交了新的代码，可以马上发现，并进一步进行排查。可能有同学会问：就算他提交了敏感信息，普通的通过API搜索不也会发现吗？答案是确实可以，但是这里涉及到了一个时效性的问题。 纵观市面上的全部Github监控，细心的你一定可以发现，无论是多么优秀的工具（包括GEYE），都无法做到实时的发现，即便是无搜索间隔和无限的Token，永远都存在一个10~15分钟的延迟。也就是说，当你push了代码到Github上，都需要10~15分钟后，你这份代码才可以被通过API搜索到。个人猜测这个延迟是Github本身对新提交的代码进行索引的时间，只有被Github索引过的代码，才能够通过API搜索到。而对于events来说，则不存在这个限制，当用户的push操作完成后，就会产生一个PushEvent，不存在很明显的延迟（除非你监控的是public event，这个有5分钟的延迟）。所以重点监控功能，既可以弥补一些时间上的问题，也可以做到对单个用户、仓库的精确打击，还可以和公司业务结合，精准监控开源项目。 目前仅仅实现了针对PushEvent和ReleaseEvent事件的监控，后续计划支持更多的事件类型。 0x04 未来发展计划 GEYE作为一款真正意义上的“企业级”监控系统还差的比较远，当然也是在不停的完善之中。对于这个系统的未来发展，有一些不成熟的想法，可以先写出来，立个flag，看看后面会不会实现起来。 在开始之前本来以为是个小项目，但是越写越大了，这个坑怕是填不好了。GEYE GITHUB 传送门 Server/Agent拆分，可快速横向扩展Agent 重点监控的内容支持规则过滤，只保留命中规则或未命中规则的事件 更多暂时未想到的功能 目前已经完成了第一个release版本，由于是利用业余时间更新的，所以迭代可能会非常慢，一天也只能修一个bug、加一个小feature而已。最后贴一张目前的效果：
0x00 pyinstaller 今天wilson师傅说要把他的fenghuang-scan打包成可执行文件，据说用了一个叫做pyinstaller的东西，很是好奇。以前只是听说过这种东西，于是我也搞了一个试试看，不过我主要是想看看能不能将打包好的源码还原出来。 我们先来看看这个东西是怎么工作的吧，文档中有对这一部分进行介绍。https://pyinstaller.readthedocs.io/en/stable/operating-mode.html PyInstaller reads a Python script written by you. It analyzes your code to discover every other module and library your script needs in order to execute. Then it collects copies of all those files – including the active Python interpreter! – and puts them with your script in a single folder, or optionally in a single executable file. 按照这个说法，pyinstaller会分析你的python源码，然后把需要用到的库以及当前的python interpreter复制一份到一个单独的文件夹中，也可以生成一个单独的可执行文件。看起来很厉害的样子。那么这单独一个文件是怎么工作的呢，文档里也有讲到：https://pyinstaller.readthedocs.io/en/stable/operating-mode.html#how-the-one-file-program-works 这里我简单讲一下，更详细的内容可以参考文档。打包成一个文件后，这个文件的核心其实是一个bootloader。执行的时候，会在临时文件夹下面创建一个_MEIXXXXX的文件夹，然后bootloader会把python脚本中用到的一些文件解压扔进去，大部分是so文件。接下来就是执行python脚本了，当程序结束之后会删除这个文件夹。 打包后的文件中不包含任何的python源代码，而是将pyc打包进去了，当然打包进去的pyc进行了一些特殊的处理，后面会讲到。 0x01 打包一个文件试试看 我这里写了个看自己IP的小程序，代码很简单： #!/usr/bin/env python # coding: utf-8 # file: myip.py from __future__ import print_function import time import requests from api import API def main(): url = API.url r = requests.get(url) print(r.content) if __name__ == "__main__": # time.sleep(120) main() #!/usr/bin/env python # coding: utf-8 # file: api.py class API(object): def __init__(self): super(API).__init__() url = "http://myip.ipip.net" 这里为了测试打包，故意写的稍微复杂了一些，拆成了两个文件。下面就试试合并成一个可执行文件： # lightless @ VM-UBUNTU in ~/program/pyinstaller [22:18:27] $ pyinstaller -F myip.py 15 INFO: PyInstaller: 3.2.1 15 INFO: Python: 2.7.12 16 INFO: Platform: Linux-4.4.0-77-generic-x86_64-with-Ubuntu-16.04-xenial 16 INFO: wrote /home/lightless/program/pyinstaller/myip.spec 19 INFO: UPX is not available. 20 INFO: Extending PYTHONPATH with paths ['/home/lightless/program/pyinstaller', '/home/lightless/program/pyinstaller'] 20 INFO: checking Analysis 24 INFO: Building because /home/lightless/program/pyinstaller/myip.py changed 24 INFO: Initializing module dependency graph... 25 INFO: Initializing module graph hooks... 57 INFO: running Analysis out00-Analysis.toc 74 INFO: Caching module hooks... 76 INFO: Analyzing /home/lightless/program/pyinstaller/myip.py 2739 INFO: Loading module hooks... 2740 INFO: Loading module hook "hook-httplib.py"... 2740 INFO: Loading module hook "hook-requests.py"... 2742 INFO: Loading module hook "hook-encodings.py"... 3012 INFO: Looking for ctypes DLLs 3058 INFO: Analyzing run-time hooks ... 3067 INFO: Looking for dynamic libraries 3272 INFO: Looking for eggs 3273 INFO: Python library not in binary depedencies. Doing additional searching... 3296 INFO: Using Python library /usr/lib/x86_64-linux-gnu/libpython2.7.so.1.0 3299 INFO: Warnings written to /home/lightless/program/pyinstaller/build/myip/warnmyip.txt 3357 INFO: checking PYZ 3359 INFO: Building because toc changed 3360 INFO: Building PYZ (ZlibArchive) /home/lightless/program/pyinstaller/build/myip/out00-PYZ.pyz 3777 INFO: Building PYZ (ZlibArchive) /home/lightless/program/pyinstaller/build/myip/out00-PYZ.pyz completed successfully. 3835 INFO: checking PKG 3836 INFO: Building because /home/lightless/program/pyinstaller/build/myip/out00-PYZ.pyz changed 3836 INFO: Building PKG (CArchive) out00-PKG.pkg 5693 INFO: Building PKG (CArchive) out00-PKG.pkg completed successfully. 5699 INFO: Bootloader /usr/local/lib/python2.7/dist-packages/PyInstaller/bootloader/Linux-64bit/run 5699 INFO: checking EXE 5699 INFO: Rebuilding out00-EXE.toc because pkg is more recent 5699 INFO: Building EXE from out00-EXE.toc 5700 INFO: Appending archive to ELF section in EXE /home/lightless/program/pyinstaller/dist/myip 5728 INFO: Building EXE from out00-EXE.toc completed successfully. # lightless @ VM-UBUNTU in ~/program/pyinstaller [22:36:53] $ ./dist/myip 当前 IP：1.1.1.1 来自于：中国 浙江 杭州 联通 0x02 提取python源码 对bootloader的逆向部分以后有机会再讲，这个并不是本次讨论的重点，我们着重来看看如何从打包好的文件中提取python源码。 找了一些资料发现，pyinstaller实际上是自己维护了一个叫做PYZ格式的数据，并附加到了可执行文件的末尾，这段数据以PYZ开头。官方也似乎提供了读取这段数据的工具，具体可以看这个文件：https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/utils/cliutils/archive_viewer.py 把这个文件拿过来试试看： # lightless @ VM-UBUNTU in ~/program/pyinstaller/dist [22:43:37] C:2 $ python archive_viewer.py myip Traceback (most recent call last): File "archive_viewer.py", line 266, in <module> run() File "archive_viewer.py", line 258, in run PyInstaller.log.__process_options(parser, args) File "/usr/local/lib/python2.7/dist-packages/PyInstaller/log.py", line 49, in __process_options logger.setLevel(level) NameError: global name 'logger' is not defined 会发现报了迷之错误，不知道其他人怎么处理的，反正我是把log.py那个文件自己patch了一下。 一共有四个可以用的命令，分别是： U: go Up one level O <name>: open embedded archive name X <name>: extract name Q: quit 列表中有两个部分需要重点关注，一个是(3626573, 1576197, 1576197, 0, 'z', u'out00-PYZ.pyz')，一个是 (13072, 301, 497, 1, 's', u'myip') 其中out00-PYZ.pyz中都是我们引用到的各种库，可以使用O命令进行查看。 现在我们把pyc文件提取出来： ? x myip to filename? myip.pyc ? 0x03 还原python源码 好了，从pyc还原python源码的方式数不胜数，我这里使用easypythondecompiler来搞。直接还原，发现冷酷无情的报错了。 主要是因为：Invalid pyc/pyo file - Magic value mismatch!这个问题。我们知道，每个pyc文件都有一个magic head，这个就是前面说到的对pyc的处理，pyinstaller会把pyc的magic部分干掉，我们需要自己去补上。我这里测试的使用的python2，所以一共补8个字节，后面的4个字节是时间戳，前面的4个字节是python编译的版本。当然这个文件是我编译的，我可以知道这四个字节应该是：\x03\xf3\x0d\x0a，可是如果是反编译其他人打包好的文件，就只能查表去猜了。 这里似乎有个trick可用，我们把这个可执行文件中的系统库的pyc拿出来看看，会发现前四个字节居然存在。 那么我们直接把这4个字节补过来，补好以后是这个样子的。 接着就可以拿到源码了。 同理我们再对out00-PYZ.pyz中的api部分进行同样的操作，就可以拿到api.py的源码了。 0xFF 参考文献 pyinstaller document How to decompile files from PyInstaller PYZ file
CVE-2017-17522 Python webbrowser.py 命令执行漏洞分析
今日惊闻Python出现了CVE，问题出在Lib/webbrowser.py模块，看描述还十分严重。 Python容易产生远程命令执行漏洞。攻击者可以利用此问题，在运行受影响应用程序的用户上下文中执行任意命令。失败的尝试可能导致拒绝服务条件。 根据CVE的描述，问题出在环境变量上。 Lib/webbrowser.py in Python through 3.6.3 does not validate strings before launching the program specified by the BROWSER environment variable, which might allow remote attackers to conduct argument-injection attacks via a crafted URL. 既然是BROWSER这个环境变量有问题，那我们就看下这个文件中哪里引用到了BROWSER这个环境变量，我这里测试的环境是Python3.6.3。在620行左右，发现了相关的代码。 可以看到BROWSER环境变量经过一系列的处理，会传入register(name, klass, instance, update_tryorder)函数。我们继续跟入GenericBrowser(cmdline)。 跟入之后，发现cmdline被直接传入了subprocess.Popen()函数，问题应当就出在这里，经过简单的单步调试后，可以发现这个cmdline其实就是BROWSER和url拼接的列表，大概就是这个样子：cmdline: [BROWSER, url]，既然如此，PoC就非常容易构造了。 import webbrowser if __name__ == '__main__': webbrowser.open(".") 在运行之前先设定环境变量export BROWSER=ls，运行后即可发现命令执行了。 分析到这里，漏洞基本上已经分析完了，我不禁多了一些思考。首先，如果我们想利用这个漏洞，那么需要控制环境变量以及一个url参数，然后还要这个服务开放在公网上才能被远程利用，从这一点上来看，这个漏洞怕是有些标题党了。 回过头来再从开发者的角度去思考一下，为啥要这样写代码，我觉得初衷可能是方便用户使用自定义的浏览器来打开URL（对这个webbrowser功能不熟悉的同学可以先看看官方文档的使用方法），所以势必要让用户来提供浏览器的名称，而开发者又无法预知用户会使用什么奇葩的浏览器，所以并没有办法进行有效的过滤。 最后一处用户提供的URL部分，开发者也并没有判断这个URL是否合法，或者说最起码看起来是个正常URL的样子，传入一个.也是可以正常运行的，至少说来这个地方应该是不严谨的吧，挺好奇官方会如何修复这个问题的，也没准最后修都不修了呢23333
前面一篇关于Python协程的文章中，提到了python中协程的一些历史以及一些简单的用法，这些用法放在实际开发中可能不太够，我在后面的编写中就踩到了不少的坑，这里就回应上篇文章中末尾说的，再写一篇文章来梳理一下这些问题。时隔很久，终于还是磨磨唧唧的把第二篇写出来了。本来还准备有第三篇重生篇的，但是以防拖稿，合并到一起来写吧。 0x00 又是生成器？ 我们已经知道了，生成器可以作为协程来使用，但是很多细节问题尚未明确，我们先从最简单的一个协程开始。（后面会出现"生成器"和"协程"两个词混用的问题，可以都理解为"作为协程使用的生成器"） def easy_coroutine(): print("Start!") x = yield print("End! x: {}".format(x)) 这就是最简单的协程了，如果想使用这个协程，看起来还是有点麻烦的： # lightless @ LL-DESKTOP in C:\Users\lightless\Desktop [20:40:27] $ python -i .\ez_coroutine.py >>> c = easy_coroutine() >>> c <generator object easy_coroutine at 0x0000016AD411E570> >>> next(c) Start! >>> c.send(666) End! x: 666 Traceback (most recent call last): File "<stdin>", line 1, in <module> StopIteration >>> 创建协程的方式与创建生成器一样，通过调用函数的方法获取到一个生成器对象。紧接着调用next()方法来启动生成器，这一步也称为prime，有些文章会把这个东西翻译成"预激"，即让协程开始执行到第一个yield表达式的位置。（为了方便下文都称为预激了） 下面又调用了c.send(666)，调用者方法后，x = yield表达式就会得到666这个值，然后协程恢复运行，直到协程结束或者遇到另外的yield表达式为止。当然这里最后会抛出一个StopIteration异常，因为代码执行完毕了，这就是生成器的标准行为，不用在意。 在这个执行过程中，会发现我们的easy_coroutine()经历的多个状态，并非总是一直处于RUNNING的状态。事实上，协程的状态有四种：GEN_CREATED，GEN_RUNNING，GEN_SUSPENDED，GEN_CLOSED。这四种状态分别是等待开始执行，正在执行，停在了yield表达式位置，执行结束。如果你想获取某个协程的当前状态，可以通过inspect.getgeneratorstate函数获取。 额外说明一点，send()方法的参数会传递给协程，作为yield表达式的值，所以只有当协程处于GEN_SUSPENDED的状态时，才可以调用send()方法传递值。如果协程还没有prime，那么可以通过调用send(None)来预激协程。啥，你问为什么不能通过给尚未prime的协程调用send(233)来预激协程，如果你试过的话就会收到一个TypeError。 每次预激协程的时候都需要调用一遍next()函数，很麻烦，而且很容易忘掉，一旦忘记预激协程，后果也是十分严重的，因为没有被预激的协程没有任何作用。有没有简单的方法让Python自动预激协程呢？有的，函数装饰器就可以做到这一点，于是我们可以自己封装一套来让其自动预激！ def prime_coroutine(func): @functools.wraps(func) def prime(*args, **kwargs): t = func(*args, **kwargs) next(t) return t return prime 代码十分简单，如果看不懂的话，建议先去学习一下Python的装饰器部分。可能各位很容易想到之前提到过的一种装饰器，在Python3.4以后，标准库中新增了asyncio.coroutine装饰器，用于修饰协程函数，但是非常遗憾，这个装饰器并不会预激协程，而通过yield from语法调用一个协程时，会自动预激。所以可以通过asyncio.coroutine和yield from配合使用，实际上我们也是这样使用的。 这里有人会问，如果在写成中抛出了异常怎么办，是不是和普通函数的异常一样？当协程中出现异常时，而协程本身又没有捕获处理，那么这个异常会"冒泡"至协程的调用方，即next()或send()函数的调用方。如果协程已经抛出了异常，再尝试去调用它，会得到一个StopIteration异常。于是有人发现了可以通过让协程抛出异常的方式终止一个协程的运行。确实没错，而且我们也确实可以通过generator.throw()和generator.close()显示的向协程发送异常，使协程停止。这里举个例子： def exception_handler_example(): print("Start!") while True: try: x = yield except TypeError: print("Receive type error!!") else: print("Receive value: {}".format(x)) print("End!") 看一下运行样例 # lightless @ LL-DESKTOP in C:\Users\lightless\Desktop [21:43:06] $ python -i .\ez_coroutine.py >>> c = exception_handler_example() >>> next(c) # 预激协程 Start! >>> c.send(666) Receive value: 666 >>> c.send(TypeError) Receive value: <class 'TypeError'> >>> c.send(777) Receive value: 777 >>> c.close() >>> c <generator object exception_handler_example at 0x000001499EECEA40> >>> from inspect import getgeneratorstate >>> getgeneratorstate(c) 'GEN_CLOSED' >>> 这个协程是一个死循环，不停的接受调用方的值并且打印出来，有三个地方需要留意一下。 最后的End字符串始终没有打印出来。因为只有没有被捕获的异常会终止这个死循环，然而矛盾的是如果出现了未处理的异常，那么协程就挂了，所以不会输出最后的End字符串。 当调用c.close()时，会使生成器的yield表达式部分抛出一个GeneratorExit异常。如果生成器本身没有处理这个异常，或者运行到了生成器的结尾（即抛出了StopIteration）的情况下，调用方不会出现任何错误。另一方面，如果生成器收到了GeneratorExit异常，那么生成器就无法生成值！ 当send(TypeError)时，并不会终止生成器的运行，这一点很容易理解。 当c.close()后，我们获取协程的状态，确实是已经处于执行结束的状态了。 如果调用c.throw()方法，则会在yield表达式那里抛出一个指定的异常，如果生成器捕获并且处理这个异常，那么生成器会继续向下执行到下一个yield表达式的位置。如果生成器没有捕获这个异常，那么异常就会向上"冒泡"，传递给生成器的调用方。 0x01 yield进化！ 前面提到过，Python后来添加了yield from语法，之所以要引入这种东西，有两种原因，"把异常传入嵌套协程问题"和"让协程更好的处理返回值问题"。 可能有人已经发现了，我们定义的协程并不会return值，只会通过yield生产值。我们下面写个例子，这个协程不会"产出"值，而是"返回"值。 def sum(): total = 0 nums = [] while True: x = yield if not x: break total += x nums.append(x) return total, nums 运行一下看看。 # lightless @ LL-DESKTOP in C:\Users\lightless\Desktop [21:58:28] $ python -i .\ez_coroutine.py >>> s = co_sum() >>> next(s) >>> s.send(666) >>> s.send(233) >>> s.send(1) >>> s.send(None) Traceback (most recent call last): File "<stdin>", line 1, in <module> StopIteration: (900, [666, 233, 1]) >>> 我们通过发送None来终止这个协程的运行，虽然抛出了StopIteration异常，但是异常对象的value部分存有return语句的返回值。我们稍微修改一下调用方法，让这段代码看起来不那么奇怪： # lightless @ LL-DESKTOP in C:\Users\lightless\Desktop [22:00:59] $ python -i .\ez_coroutine.py >>> s = co_sum() >>> next(s) >>> s.send(1) >>> s.send(2) >>> s.send(3) >>> try: ... s.send(None) ... except StopIteration as e: ... result = e.value ... >>> result (6, [1, 2, 3]) >>> 虽然看起来不是很奇怪了，但是从异常对象中获取协程的返回值显得有点难过。为啥这么做呢？我们稍微考虑一下，生成器执行结束后的常规行为是什么？抛出StopIteration异常！那么要想办法把值从协程内部传出来，似乎唯一的方式就是放在异常对象的值中了。更难过的是，这是PEP 380定义的方式。前面提到过，yield from语法结构要解决的问题之一就是"让协程更好的处理返回值问题"，所以"yield from"的一个行为就是会在内部自动捕获StopIterator异常，并且将异常对象的value属性设置为yield from表达式的值。 如果你以为yield from的作用就这些的话，那可是大错特错了，他比yield的功能更加强大，作用也多很多。它的主要功能类似于一个"通道"或者一个"委托器"，用于将协程的调用方和嵌套协程最内层的子生成器连接起来，让二者可以愉快的发送和生产值。PEP 380中添加了一些新的术语，我们沿用一下。 委托生成器（delegating generator）：包含yield from <iterable>结构的生成器函数。 子生成器（subgenerator）：从yield from表达式中iteratable部分获取的生成器。（有时候就叫iterator，23333） 我们来看一个使用了yield from的例子，看起来有点复杂，而且很难受，因为是为了使用yield from刻意编出来的代码。 final_result = {} def co_sum(): total = 0 nums = [] while True: x = yield print("co_sum receive: ", x) if not x: break total += x nums.append(x) return total, nums def middle(key): idx = 0 while True: print("middle idx: ", idx) final_result[key] = yield from co_sum() print("sub-generator co_sum() done.") idx += 1 def main(): data_sets = { "set_1": [233, 666, 123], "set_2": [1, 2, 4, 5], "set_3": [999, 999, 999], } for key, data_set in data_sets.items(): print("start key:", key) m = middle(key) next(m) # 预激middle协程 for value in data_set: m.send(value) # 给协程传递每一组的值 m.send(None) print("final_result:", final_result) if __name__ == '__main__': main() 这段代码作用是，计算三个数值集每一个集合的总和，运行结果如下，我们依次来看每个函数的作用。 # lightless @ LL-DESKTOP in C:\Users\lightless\Desktop [22:51:54] $ python yield_from.py start key: set_1 middle idx: 0 co_sum receive: 233 co_sum receive: 666 co_sum receive: 123 co_sum receive: None sub-generator co_sum() done. middle idx: 1 start key: set_2 middle idx: 0 co_sum receive: 1 co_sum receive: 2 co_sum receive: 4 co_sum receive: 5 co_sum receive: None sub-generator co_sum() done. middle idx: 1 start key: set_3 middle idx: 0 co_sum receive: 999 co_sum receive: 999 co_sum receive: 999 co_sum receive: None sub-generator co_sum() done. middle idx: 1 final_result: {'set_1': (1022, [233, 666, 123]), 'set_2': (12, [1, 2, 4, 5]), 'set_3': (2997, [999, 999, 999])} main()函数通过循环分别读取每一个集合的key和一个存有多个数字的list。然后生成一个middle()协程并预激，将列表中的每一个数字通过send方法发过去。发送完毕后调用send(None)结束协程的运行。 middle(key)函数其实是一个委托生成器，因为他含有一个yield from <iterator>结构。这个循环每次遍历的时候，会生成一个新的co_sum()实例，每个实例都是一个生成器，当然是作为协程使用的生成器对象。委托生成器会在yield from的时候暂停执行，将控制权交给co_sum()，并等待co_sum()执行结束。 co_sum()函数是一个子生成器，与上一个例子中的代码一样。 现在再回过头来看这段代码，就比较容易理解了，这里只有一个子生成器和一个委托生成器，如果我们将子生成器换成一个委托生成器，再通过yield from调用其他的子生成器，就可以将任意多个生成器链接起来，只要最终的子生成器通过yield表达式结束即可。 0x02 何为yield from 本节内容较为枯燥无味，如果不感兴趣，可以跳过本节 是时候来深入理解一下yield from这个结构了，根据PEP 380来看，这东西相当难理解，而且及其复杂。下面我们就来试着解释一下RESULT = yield from EXPR。我们先把问题简化一下，做出以下几个假设。 假设yield from出现在委托生成器中，并且由调用方调用，同时也驱动着子生成器。 假设不支持.throw()和.close()方法。 假设生成器不会抛出异常，而是执行到代码最后，直到抛出StopIteration异常为止。 在这些假设下，RESULT = yield from EXPR可以简化成下面这样，在读这段代码之前，我们先说明一下这些下划线开头的变量的作用： _i：子生成器，同时也是一个迭代器 _y：子生成器生产的值 _r：yield from表达式最终的值 _s：调用方通过send()发送的值 _e：异常对象 这段代码的大部分含义已经在注释中说明了，可以结合注释来理解这段代码的含义。 _i = iter(EXPR) # EXPR是一个可迭代对象，_i其实是子生成器； try: _y = next(_i) # 预激子生成器，把产出的第一个值存在_y中； except StopIteration as _e: _r = _e.value # 如果抛出了`StopIteration`异常，那么就将异常对象的`value`属性保存到_r，这是最简单的情况的返回值； else: while 1: # 尝试执行这个循环，委托生成器会阻塞； _s = yield _y # 生产子生成器的值，等待调用方`send()`值，发送过来的值将保存在_s中； try: _y = _i.send(_s) # 转发_s，并且尝试向下执行； except StopIteration as _e: _r = _e.value # 如果子生成器抛出异常，那么就获取异常对象的`value`属性保存到_r，退出循环，恢复委托生成器的运行； break RESULT = _r # _r就是整个yield from表达式返回的值。 如果你已经理解了，那么我们继续。现实情况下，yield from要做的事情会复杂一些，毕竟我们做出了一系列的假设。将假设去掉没有什么难度，但是有几个问题非常难受： 子生成器可能只是一个迭代器，并不是一个作为协程的生成器，所以它不支持.throw()和.close()方法； 如果子生成器支持.throw()和.close()方法，但是在子生成器内部，这两个方法都会抛出异常； 调用方让子生成器自己抛出异常，没有原因，就是任性； 当调用方使用next()或者.send(None)时，都要在子生成器上调用next()函数，当调用方使用.send()发送非None值时，才调用子生成器的.send()方法； 这些问题都是yield from结构需要考虑的事情，下面我们来看一下完成的简化之前的代码，相关的注解也写到了注释中： _i = iter(EXPR) # EXPR是一个可迭代对象，_i其实是子生成器； try: _y = next(_i) # 预激子生成器，把产出的第一个值存在_y中； except StopIteration as _e: _r = _e.value # 如果抛出了`StopIteration`异常，那么就将异常对象的`value`属性保存到_r，这是最简单的情况的返回值； else: while 1: # 尝试执行这个循环，委托生成器会阻塞； try: _s = yield _y # 生产子生成器的值，等待调用方`send()`值，发送过来的值将保存在_s中； except GeneratorExit as _e: try: # 这部分的作用是关闭子生成器和委托生成器，因为子生成器可能是个可迭代对象，所以要处理没有close()方法的情况； _m = _i.close except AttributeError: pass else: _m() raise _e except BaseException as _e: # 这部分是处理通过.throw()方法传入的异常，如果子生成器是个迭代器，没有throw()方法的情况下，委托生成器会抛出一个异常 _x = sys.exc_info() try: _m = _i.throw except AttributeError: raise _e else: # 子生成器有throw()的情况，那么就调用throw()方法，并且传入调用发提供的异常。这时，子生成器可能会处理异常并继续执行，叶可能会抛出`StopIteration`异常结束执行，也有可能没有处理并且向上"冒泡"，抛出异常； try: _y = _m(*_x) except StopIteration as _e: _r = _e.value break else: # 这部分是当子生成器在执行时没有出现异常的情况 try: if _s is None: # 如果调用方发送的是None，那么就在子生成器上调用next()函数； _y = next(_i) else: # 调用发发送的不是None，调用子生成器的.send()方法； _y = _i.send(_s) except StopIteration as _e: # 如果出现了StopIteration异常，那么就获取异常对象中value属性的值，并保存在返回值中，中断循环，让委托生成器继续运行 _r = _e.value break RESULT = _r # _r就是整个yield from表达式返回的值。 看完代码，我们总结一下关键点： 子生成器生产的值，都是直接传给调用方的；调用方通过.send()发送的值都是直接传递给子生成器的；如果发送的是None，会调用子生成器的__next__()方法，如果不是None，会调用子生成器的.send()方法； 子生成器退出的时候，最后的return EXPR，会触发一个StopIteration(EXPR)异常； yield from表达式的值，是子生成器终止时，传递给StopIteration异常的第一个参数； 如果调用的时候出现StopIteration异常，委托生成器会恢复运行，同时其他的异常会向上"冒泡"； 传入委托生成器的异常里，除了GeneratorExit之外，其他的所有异常全部传递给子生成器的.throw()方法；如果调用.throw()的时候出现了StopIteration异常，那么就恢复委托生成器的运行，其他的异常全部向上"冒泡"； 如果在委托生成器上调用.close()或传入GeneratorExit异常，会调用子生成器的.close()方法，没有的话就不调用。如果在调用.close()的时候抛出了异常，那么就向上"冒泡"，否则的话委托生成器会抛出GeneratorExit异常。 到这里，整个yield from的执行原理已经搞清楚了，可能在现实中用不太上，但是对于理解Python协程的工作方式还是有一定帮助的。 0x03 在并发和异步之前 我们都知道，CPython解释器这个东西，本身是非线程安全的，存在一个GIL，一次只允许一个线程执行Python代码。而且我们无法通过Python代码来控制GIL，即便有办法，也不是提倡的做法。GIL的存在给我们在编写多线程程序的时候带来了一定程度的困扰。然而实际上，对于I/O密集型任务而言，GIL在某种程度上还会有一些正面作用。 在Python标准库中，所有的I/O阻塞型函数都会释放GIL来允许其他线程运行，除此之外，time.sleep()函数也会释放GIL，这让Python的多线程在I/O密集型任务中还是能发挥一些作用的。例如Python线程在等待网络I/O时，会释放GIL给其他的线程。而对于计算密集型的任务来说，规避GIL的最简单的方法就是使用多进程。 既然在I/O密集的情况下，多线程并没有什么明显的问题，那么为什么还是要推荐使用协程来处理呢？要知道，每种OS中线程的消耗是不一样的，Python使用的线程消耗的内存需要以MB来计算。如果在并发数量很大（连接数量很大）的情况下，例如几万个连接，这样的内存消耗量是非常可怕的。为了解决这种内存消耗大的情况，可以使用回调机制来实现异步调用。调用后立即返回，当某事件发生时，调用我们指定的回调函数，看起来简单粗暴有效，同时带来的副作用就是令人极其难过的"callback hell"。 于是协程式的异步编程就出现了，并且能很好的解决这些问题，极大的提高效率，并且Python原生的支持也并不弱，第三方库的支持也有很多，例如大名鼎鼎gevent和它的猴子补丁。下面我们从一个实际的爬虫问题入手，分别以多线程、协程角度思考，弄明白异步编程的思维方法与传统编程有什么不同。 0x04 一切从爬虫开始 我们从一个简单的爬虫开始，这个爬虫很简单，访问指定的URL，并且获取内容并计算长度，这里我们给定5个URL。第一版的代码十分简单，顺序获取每个URL的内容，当第一个请求完成、计算完长度后，再开始第二个请求。 # filename: spider_normal.py import time import requests targets = [ "https://lightless.me/archives/python-coroutine-from-start-to-boom.html", "https://github.com/aio-libs", "https://www.python.org/dev/peps/pep-0380/", "https://www.baidu.com/", "https://www.zhihu.com/", ] def spider(): results = {} for url in targets: r = requests.get(url) length = len(r.content) results[url] = length return results def show_results(results): for url, length in results.items(): print("Length: {:^7d} URL: {}".format(length, url)) def main(): start_time = time.time() results = spider() print("Use time: {:.2f}s".format(time.time() - start_time)) show_results(results) if __name__ == '__main__': main() 我们多运行几次看看结果。 大约需要花费14-16秒不等，这段代码并没有什么好看的，我们把关注点放在后面的代码上。现在我们使用多线程来改写这端代码。 # filename: spider_thread.py import time import threading import requests from spider_normal import targets, show_results final_results = {} def spider(url): r = requests.get(url) length = len(r.content) final_results[url] = length def main(): ts = [] start_time = time.time() for url in targets: t = threading.Thread(target=spider, args=(url, )) ts.append(t) t.start() for t in ts: t.join() print("Use time: {:.2f}s".format(time.time() - start_time)) show_results(final_results) if __name__ == '__main__': main() 再来看一下结果，很明显，我们的耗时已经降低到10s左右了： 从这两段代码中，已经可以看出并发对于处理任务的好处了，但是使用原生的threading模块还是略显麻烦，Python已经给我们内置了一个处理并发任务的库concurrent，我们借用这个库修改一下我们的代码，之所以修改成这个库的原因还有一个，那就是引出我们后面会谈到的Future。 import time from concurrent import futures import requests from spider_normal import targets, show_results final_results = {} def spider(url): r = requests.get(url) length = len(r.content) final_results[url] = length return True def main(): start_time = time.time() with futures.ThreadPoolExecutor(10) as executor: res = executor.map(spider, targets) print("Use time: {:.2f}s".format(time.time() - start_time)) show_results(final_results) if __name__ == '__main__': main() 执行一下，会发现耗时与上一个版本一样，稳定在10s左右。 可以看到我们调用了concurrent库中的futures，那么到底什么是futures？简单的讲，这个对象代表一种异步的操作，可以表示为一个需要延时进行的操作，当然这个操作的状态可能已经完成，也有可能尚未完成，如果你写JS的话，可以理解为是类似Promise的对象。在Python中，标准库中其实有两个Future类，一个是concurrent.futures.Future，另外一个是asyncio.Future，这两个类很类似，不完全相同，这些实现差异以及API的差异我们先按下暂且不谈，有兴趣的同学可以参考下相关的文档。Future是我们后面讨论的asyncio异步编程的基础，因此这里多说两句。 Future代表的是一个未来的某一个时刻一定会执行的操作（可能已经执行完成了，但是无论如何他一定有一个确切的运行时间），一般情况下用户无需手动从零开始创建一个Future，而是应当借助框架中的API生成。比如调用concurrent.futures.Executor.submit()时，框架会为"异步操作"进行一个排期，来决定何时运行这个操作，这时候就会生成一个Future对象。 现在，我们来看看如何使用asyncio进行异步编程，与多线程编程不同的是，多个协程总是运行在同一个线程中的，一旦其中的一个协程发生阻塞行为，那么整个线程都被阻塞，进而所有的协程都无法继续运行。asyncio.Future和asyncio.Task都可以看做是一个异步操作，后者是前者的子类，BaseEventLoop.create_task()会接收一个协程作为参数，并且对这个任务的运行时间进行排期，返回一个asyncio.Task类的实例，这个对象也是对于协程的一层包装。如果想获取asyncio.Future的执行结果，应当使用yield from来获取，这样控制权会被自动交还给EventLoop，我们无需处理"等待Future或Task运行完成"这个操作。于是就有了一个很愉悦的编程方式，如果一个函数A是协程、或返回Task或Future的实例的函数，就可以通过result = yield from A()来获取返回值。下面我们就使用asyncio和aiohttp来改写我们的爬虫。 import asyncio import time import aiohttp from spider_normal import targets, show_results final_results = {} async def get_content(url): async with aiohttp.ClientSession() as session: async with session.get(url) as resp: content = await resp.read() return len(content) async def spider(url): length = await get_content(url) final_results[url] = length return True def main(): loop = asyncio.get_event_loop() cor = [spider(url) for url in targets] start_time = time.time() result = loop.run_until_complete(asyncio.gather(*cor)) print("Use time: {:.2f}s".format(time.time() - start_time)) show_results(final_results) print("loop result: ", result) if __name__ == '__main__': main() 结果非常惊人，鹅妹子嘤！ 这里可能有同学会问为什么没看到yield from以及@asyncio.coroutine，那是因为在Python3.5以后，增加了async def和awiat语法，等效于@asyncio.coroutine和yield from，详情可以参考上一篇文章。在main()函数中，我们先获取一个可用的事件循环，紧接着将生成好的协程任务添加到这个循环中，并且等待执行完成。在每个spider()中，执行到await的时候，会交出控制权（如果不明白请向前看一下委托生成器的部分），并且切到其他的协程继续运行，等到get_content()执行完成返回后，那么会恢复spider()协程的执行。get_content()函数中只是通过async with调用aiohttp库的最基本方法获取页面内容，并且返回了长度，仅此而已。 在修改为协程版本后，爬虫性能有了巨大的提升，从最初了15s，到10s，再到现在的2s左右，简直是质的飞跃。这只是一个简单的爬虫程序，相比多线程，性能提高了近5倍，如果是其他更加复杂的大型程序，也许性能提升会更多。asyncio这套异步编程框架，通过简单的事件循环以及协程机制，在需要等待的情况下主动交出控制权，切换到其他协程进行运行。到这里就会有人问，为什么要将requests替换为aiohttp，能不能用requests？答案是不能，还是我们前面提到过的，在协程中，一切操作都要避免阻塞，禁止所有的阻塞型调用，因为所有的协程都是运行在同一个线程中的！requests库是阻塞型的调用，当在等待I/O时，并不能将控制权转交给其他协程，甚至还会将当前线程阻塞，其他的协程也无法运行。如果你在异步编程的时候需要用到一些其他的异步组件，可以到https://github.com/aio-libs/这里找找，也许就有你需要的异步库。 关于asyncio的异步编程资料目前来说还不算很多，官方文档应该算是相当不错的参考文献了，其中非常推荐的两部分是：Develop with asyncio和Tasks and coroutines，各位同学有兴趣的话可以自行阅读。asyncio这个异步框架中包含了非常多的内容，甚至还有TCP Server/Client的相关内容，如果想要掌握asyncio这个异步编程框架，还需要多加练习。顺带一提，asyncio非常容易与其他的框架整合，例如tornado已经有实现了asyncio.AbstractEventLoop的接口的类AsyncIOMainLoop，还有人将asyncio集成到QT的事件循环中了，可以说是非常的灵活了。 最后推荐一下《流畅的Python》，这本书中关于协程的部分介绍的非常详细，本文中相当一部分内容都参考自这里，强烈推荐。
0x00 前言 很久以前就听说Python的async/await很厉害，但是直到现在都没有用过，一直都在用多线程模型来解决各种问题。最近看到隔壁的Go又很火，所以决定花时间研究下Python协程相关的内容，终于在翻阅了一裤衩的资料之后有了一些理解。 0x01 起：一切从生成器开始 以往在Python开发中，如果需要进行并发编程，通常是使用多线程/多进程模型实现的。由于GIL的存在，多线程对于计算密集型的任务并不十分友好，而对于IO密集型任务，可以在等待IO的时候进行线程调度，让出GIL，实现『假并发』。 当然对于IO密集型的任务另外一种选择就是协程，协程其实是运行在单个线程中的，避免了多线程模型中的线程上下文切换，减少了很大的开销。为了理解协程、async/await、asyncio，我们要从最古老的生成器开始。 回顾Python的历史，生成器这个概念第一次被提出的时候是在PEP 255中被提出的，当时的Python版本为Python2.2。我们都知道range()函数，现在考虑一下我们来编写一个自己的range()函数，最直接最容易想到的方法也许是这样： def my_range(max_number): sequence = [] index = 0 while index < max_number: sequence.append(index) index += 1 return sequence 当你想创建一个很小的序列的时候，例如创建从0到100这样的列表，似乎没什么问题。但是如果想创建一个从0到999999999这么大的列表的话，就必须要创建一个完整的，长度是999999999的列表，这个行为非常占用内存。于是就有了生成器，用生成器来改写这个函数的话，会是下面这个样子： def lazy_range(max_number): index = 0 while index < max_number: yield index index += 1 当函数执行遇到yield的时候，会暂停执行。这样只需在内存中维护可以存储一个整数的内存空间就可以了。如果对生成器/迭代器不理解的话，可以参考Stack Overflow上的这篇高票回答：传送门 0x02 承：协程诞生 到这里可能还和协程没什么关系，但是实际上这已经是Python协程的雏形了，我们来看看维基上对于协程的定义： Coroutines are computer program components that generalize subroutines for non-preemptive multitasking, by allowing multiple entry points for suspending and resuming execution at certain locations. 从某些角度来理解，协程其实就是一个可以暂停执行的函数，并且可以恢复继续执行。那么yield已经可以暂停执行了，如果在暂停后有办法把一些value发回到暂停执行的函数中，那么Python就有了『协程』。于是在PEP 342中，添加了“把东西发回已经暂停的生成器中”的方法，这个方法就是send()，并且在Python2.5中得到了实现。利用这个特性我们继续改写range()函数： def smart_range(max_number): index = 0 while index < max_number: jump = yield index if jump is None: jump = 1 index += jump 就这样，整个生成器的部分似乎已经进入了stable的状态，但是在Python3.3中，这个情况发生了改变。在PEP 380中，为Python3.3添加了yield from，这个东西可以让你从迭代器中返回任何值（这里用的是迭代器，因为生成器也是一种迭代器），也可以让你重构生成器，我们来看这个例子： def lazy_range(max_number): index = 0 def gratuitous_refactor(): while index < max_number: yield index index += 1 yield from gratuitous_refactor() 这个特性也可以让生成器进行串联，使数据在多个生成器中进行传递。历史发展到这里，协程的出现似乎已经就差一步了，或者这里说是异步编程更恰当。在Python3.4中加入了asyncio库，使Python获得了事件循环的特性（关于事件循环的内容这里不再赘述）。asyncio + 生成器已经达到了异步编程的条件，在Python3.4中，我们就可以这样实现一个异步的模型： import asyncio @asyncio.coroutine def counttdown(number, n): while n > 0: print("T-minus", n, "({})".format(number)) yield from asyncio.sleep(1) n -= 1 loop = asyncio.get_event_loop() tasks = [ asyncio.ensure_future(counttdown("A", 2)), asyncio.ensure_future(counttdown("B", 5)), ] loop.run_until_complete(asyncio.wait(tasks)) loop.close() 这里的asyncio.coroutine装饰器是用来标记这个函数是一个协程的，因为asyncio要求所有要用作协程的生成器必须由asyncio.coroutine装饰。在这段代码中，时间循环会启动两个countdown()协程，他们会一直执行，直到遇到了yield from asyncio.sleep()，会暂停执行，并且将一个asyncio.Future对象返回给事件循环。事件循环会监控这个asyncio.Future对象，一旦其执行完成后，将会把这个Future的执行结果返回给刚刚因为这个Future暂停的协程，并且继续执行原协程。 从这个具体的例子出发，抽象点来看，实际上这个过程变成了： 你可以对任何asyncio.Future的对象进行yield from，将这个Future对象交给事件循环； 暂停执行的协程将等待这个Future的完成； 一旦Future获取到事件循环，并执行完所有的代码； 事件循环感知到Future执行完毕，原暂停的协程会通过send()方法获取Future对象的返回值并且继续执行； 0x03 转：从yield from到await 终于到了最激动人心的地方，在Python3.5中，添加了types.coroutine装饰器以及async def和await。我们先来看一下Python3.4和Python3.5中如何定义一个协程函数： # python34 @asyncio.coroutine def py34_function(): yield from work() # python35 async def py35_function(): await work() 看起来Python3.5中定义协程更为简单了，但是实际上生成器和协程之间的差别变的更加明显了。这里先要指出两个个注意点： await只能用于async def的函数中； yield from 不能用于async def的函数中； 除此之外，yield from和await可以接受的对象是略有区别的，await接受的对象必须是一个awaitable对象。什么是awaitable对象呢，就是一个实现了__await()__方法的对象，而且这个方法必须返回一个不是协程的迭代器。满足这两个条件，才算是一个awaitable对象，当然协程本身也是awaitable对象，因为collections.abc.Coroutine继承了collections.abc.Awaitable。换句话说，await后面可接受的对象有两种，分别是：协程和awaitable对象，当然协程也是awaitable对象。 在Python3.6中，这种特性继续被发扬光大，现在可以在同一个函数体内使用yield和await，而且除此之外，也可以在列表推导等地方使用async for或await语法。 result = [i async for i in aiter() if i % 2] result = [await func() for fun in funcs if await condition()] async def test(x, y): for i in range(y): yield i await asyncio.sleep(x) 0x04 合：尾声 到这里整个协程的历史已经是回顾完了，对于Python中的协程也有了一些理解，但是如何在实际中使用协程可能还有一些疑惑以及理解不够深刻的地方，准备继续研究几天，在下一篇文章讲一下实际场景中协程的运用和项目中遇到的问题。最后附上一些不错的文献：D How the heck does async/await work in Python 3.5? Python协程：从yield/send到async/await Python黑魔法 --- 异步IO（ asyncio） 协程 Keynote at PyCon Brasil 2015 (Screencast)
什么是Ansible Ansible是一种集成IT系统的配置管理，应用部署，执行特定任务的开源平台。主要基于Paramiko和PyYAML两个模块。 安装Ansible 这里是在CentOS7上安装使用的，首先要开启epel源： # yum install -y epel-release 配置公私钥证书登录 # ssh-kegen -t rsa -C "lightless@foxmail.com" # ssh-copy-id -i /root/.ssh/id_rsa.pub root@1.1.1.1 # ssh-copy-id -i /root/.ssh/id_rsa.pub root@2.2.2.2 定义主机组 默认的配置文件在/etc/ansible/hosts 有点像ini的格式： # This is the default ansible 'hosts' file. # # It should live in /etc/ansible/hosts # # - Comments begin with the '#' character # - Blank lines are ignored # - Groups of hosts are delimited by [header] elements # - You can enter hostnames or ip addresses # - A hostname/ip can be a member of multiple groups # Ex 1: Ungrouped hosts, specify before any group headers. ## green.example.com ## blue.example.com ## 192.168.100.1 ## 192.168.100.10 # Ex 2: A collection of hosts belonging to the 'webservers' group ## [webservers] ## alpha.example.org ## beta.example.org ## 192.168.1.100 ## 192.168.1.110 # If you have multiple hosts following a pattern you can specify # them like this: ## www[001:006].example.com # Ex 3: A collection of database servers in the 'dbservers' group ## [dbservers] ## ## db01.intranet.mydomain.net ## db02.intranet.mydomain.net ## 10.25.1.56 ## 10.25.1.57 # Here's another example of host ranges, this time there are no # leading 0s: ## db-[99:101]-node.example.com 默认的配置文件中提供了一些例子，这里说一些特殊的 别名 jumpserver ansible_ssh_port=22 ansible_ssh_host=1.1.1.1 这个是为主机定义一个别名，名称叫jumpserver，后面定义了host和port，除此之外还有一些保留的变量名： ansible_ssh_host ansible_ssh_port ansible_ssh_user ansible_ssh_pass ansible_connection, 连接类型，可以是local、ssh、或者是paramiko ansible_ssh_privite_key_file ansible_*_interpreter， 指定采用非python的其他脚本语言，例如ruby、perl等 主机变量 主机可以指定变量，以便提供给playbooks使用 host1 http_port=80 maxRequestsPerChild=100 host2 http_port=8080 maxRequestsPerChild=200 定义组变量 组变量就是变量的作用域会覆盖组所有成员 [atlanta] host1 host2 [atlanta:vars] ntp_server=ntp.atlanta.example.com proxy=proxy.atlanta.example.com 此外还有嵌套组，但是只在ansible-playbook中生效。 分离主机与数据 /etc/ansible/group_vars/+组名 存放指定组名定义的变量 /etc/ansible/host_vars/+主机名 存放指定主机名定义的变量 Ansible模块与API 通过ansible <host/group name> -m <module name> -a "param"进行调用模块。 ansible vultr -m command -a "uptime" 等价于 ansible vultr -a "uptime" 查看模块帮助信息 ansible-doc ping playbooks中执行命令方法 - name: reboot the server command: /sbin/reboot -t now
Powershell默认是不允许执行PS1脚本的，这就需要我们自己使用Set-ExecutionPolicy来修改默认策略。 使用Get-ExecutionPolicy可以获取当前的策略: PS C:\Users\lightless> Get-ExecutionPolicy Unrestricted 如果想看更详细的，针对每个scope设置的策略，可以使用如下命令： PS C:\Users\lightless> Get-ExecutionPolicy -List | Format-Table -AutoSize Scope ExecutionPolicy ----- --------------- MachinePolicy Undefined UserPolicy Undefined Process Undefined CurrentUser Unrestricted LocalMachine Undefined 一共有6种策略，分别是：Undefined, Bypass, Unrestricted, Remotesigned, Allsigned, Restricted. 默认情况下是Restricted策略，更详细的可以参考：https://technet.microsoft.com/library/hh847748.aspx 这里简单的说下六种策略的区别： Restricted. 不读取任何配置文件、不运行任何脚本，这个是默认策略。 AllSigned. 所有的脚本和配置文件必须有受信任的的发布者的签名，就算是自己写的脚本也同样如此，否则无法执行。 RemoteSigned. 和上面的类似，但是针对的是从网上下载下来的脚本，这些脚本同样也需要可信的签名。 Unrestricted. 可以运行脚本或者读取配置文件，如果执行的是从网上下载的脚本，那么会有一个申请权限的提示。 Bypass. 不阻止任何脚本或配置文件，也不会显示警告或者提示。 Undefined. 把当前scope的所有策略全部都删除，但是不会删除Group Policy scope中的策略。如果你想删除某个设置好的策略，用这个就行了。 上面说到了scope，这个可以理解为作用域，针对不同的作用域设置不同的策略。 主要有三种scope： process 设置的策略只影响当前的powershell进程。 CurrentUser: 设置的策略只影响当前用户。 LocalMachine: 设置的策略影响这台计算机上的所有用户。 那么设置方法就是使用Set-ExecutionPolicy命令，具体格式如下： Set-ExecutionPolicy [-ExecutionPolicy] <ExecutionPolicy> [[-Scope] <ExecutionPolicyScope> ] [-Force] [-Confirm] [-WhatIf] [ <CommonParameters>] -Force的作用是禁止所有的提示输出，当你更改执行策略的时候，默认情况下是会显示警告的，这个选项可以让他不显示警告。 -Confirm会在你执行这个cmdlet之前再确认一次，以免打错命令。 -WhatIf会显示执行完这条cmdlet之后会发生什么事情，但是并不执行当前的cmdlet。 这个策略其实也是可以绕过的，详细的可以参考这篇文章： https://blog.netspi.com/15-ways-to-bypass-the-powershell-execution-policy/ 参考资料： https://technet.microsoft.com/en-us/library/hh849812.aspx https://technet.microsoft.com/zh-cn/library/ee176961.aspx https://technet.microsoft.com/library/hh847748.aspx
今天干活的时候把代码里的数据库连接全部改成了长连接，然后就出现了奇怪的问题。 (2013, 'Lost connection to MySQL server during query') 应该是数据库连接的空闲时间过长，导致了连接丢失。没办法只能自己封装一遍MySQLdb，在每次执行前ping一下，并且重新连接。 #!/usr/bin/env python2 # coding: utf-8 #file: ConnectDB.py import socket import MySQLdb __author__ = "lightless" __email__ = "root@lightless.me" class DB(object): """ 对MySQLdb重新封装一遍 query方法检测超时问题 Fuck MySQLdb!!! """ def __init__(self, host, port, database, username, password, charset="utf8", autocommit=True): super(DB, self).__init__() self.db = None self.cursor = None self.result = None self.execute_result = None self.host = host self.database = database self.port = port self.username = username self.password = password self.charset = charset self.autocommit = autocommit def __del__(self): self.db.close() del self.db del self.cursor def __connect(self): try: self.db = MySQLdb.connect( host=self.host, port=int(self.port), user=self.username, passwd=self.password, db=self.database, charset=self.charset, autocommit=self.autocommit ) self.cursor = self.db.cursor() except MySQLdb.MySQLError as e: print "Error Message:" print e def connect(self): self.__connect() def __query(self, sql, commit=False): self.db.ping(True) self.execute_result = self.cursor.execute(sql) if commit: self.db.commit() self.result = self.cursor.fetchall() return self.result def query(self, sql, commit=False): try: if self.cursor is None: self.__connect() return self.__query(sql, commit) except MySQLdb.OperationalError: # Mysql Connection Lost self.__connect() try: return self.__query(sql, commit) except MySQLdb.MySQLError as e: print "Error Message:" print e 这样问题就解决了。