Day 3611 中间人攻击式饭否消息过滤代理

这个东西的最初设计目标是:在不对某些人取消关注(加黑名单也会取消关注)的情况下,通过中间人攻击(不是唬人,其实本质就是篡改返回结果)为不支持消息过滤的饭否客户端(实际上,没有任何一款饭否客户端和官方界面有这个功能,据本人所知的唯一途径是Chrome插件Fanatic,但也只能对饭否网页版起作用)提供相应功能。

由于没有一款客户端支持自定义API地址,所以采取的方式是:在本地用hosts将API所在主机(api.fanfou.com)重定向,然后在目标服务器接受解析,等于欺骗一下客户端,让它以为连接的是官方的API服务器。

此外,既然是中间人攻击,那么其实想对返回结果做什么都可以,本来除了过滤特定人之外还想加入更多功能,可是写完就懒了。

代码写得很面条很烂,请多包涵。

使用方法(以Apache 2.4.4 + PHP 5.3为例):

1、将文件内容另存为UTF-8无BOM格式,文件名为index.php(或任何目标服务器支持的默认文档名)

2、确认PHP开启cURL扩展,Apache开启了Rewrite扩展,修改httpd-vhosts.conf(或Apache虚拟主机配置文件所在处):

<VirtualHost *:80>
    ServerName api.fanfou.com
    DocumentRoot "文件所在路径"
</VirtualHost>
<Directory "文件所在路径">
    Order Allow,Deny
    Allow from All
    RewriteEngine On
    RewriteCond %{REQUEST_FILENAME} !-f
    #-f的含意是将请求的路径当作文件来看待,由于-f会检测文件是否真实存在,所以!之
    RewriteRule ^(.*)$ index.php?reqf=$1 [QSA]
    #QSA == Query strings appended,即将原始Query String内容原样转发
</Directory>

3、配置主机,将api.fanfou.com绑定到刚刚配置的Apache服务器上。

4、在要使用该代理的客户端上,配置hosts文件,将api.fanfou.com指向文件所在的主机。

2016-02-03更新:

在调试自己的饭否Lib时,发现任何返回的HTTP状态码都是200,检查一下发现是代理没有转发饭否API服务器返回的状态码,已在关闭cURL句柄前进行处理。

2016-01-13更新:

1.当发送图片信息(此时Content-Type为multipart/form-data),且消息内容开头为@时,status参数会被cURL认为是文件指向,造成出现can’t open file错误,导致消息发送失败。本来以为因为改动POST内容,会导致OAuth签名失效,解决这个问题会很麻烦,以致做好了要反编译客户端、拿OAuth Consumer信息的准备,结果第一次试探即告成功,解决方法竟然是在status数据的开头加一个空格就可以了,而且签名似乎仍然有效,并不会影响消息的成功发送,真是神奇。

2.解决之前版本错将Debug开关写成局部变量$debug,导致无论如何都不会输出日志的问题,现已修改为常量DEBUG_MODE。

<?php
const DEBUG_MODE = TRUE; //调试开关,设置为 true 以输出日志
//获取 Rewrite 传入的 Query string 参数 reqf ,包含了客户端原始请求的API路径
if (!$_GET['reqf'] || empty($_GET['reqf'])) die("No API path to request specified!");
//获取调用的API模式,用于处理返回时快速跳转相应处理模块
$api_path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$api_name = strtolower(pathinfo($api_path) ['filename']);
$api_ext = strtolower(pathinfo($api_path) ['extension']);
writelog("Requested API extension: " . $api_ext);
//获取客户端请求 Method
$method = strtoupper($_SERVER['REQUEST_METHOD']);
//构造远端 API 请求 URL
$api_url = "http://api.fanfou.com/" . $_GET['reqf'];
//整理要转发的 headers
$request_headers = array();
foreach (apache_request_headers() as $arhk => $arhv) {
    writelog($method . " Header [" . $arhk . "] => " . $arhv);
    $request_headers[] = $arhk . ': ' . $arhv;
}
//如果有 Query string 参数,构造一个去掉 reqf 项的 GET 参数数组
$clientQS = '';
if ($_GET && sizeof($_GET) > 1) {
    foreach ($_GET as $gk => $gv) {
        if (strtolower($gk) != 'reqf') $clientQS = $clientQS . '&' . $gk . '=' . $gv;
    }
    if (substr($clientQS, 0, 1) == '&') $clientQS = '?' . substr($clientQS, 1);
    $api_url = $api_url . $clientQS;
}
//对 URL 中的空格替换为+
//这一行的主要用途是修复蘑菇饭提交带图消息时,在Query String中又写了一遍已经在POST里写过的status参数,却又不将空格转化成+(西文加号),导致签名不符过不了OAuth验证
$api_url = str_replace(" ", "+", $api_url);
writelog($method . ' ' . $api_url);
//准备 cURL 对象
$c = curl_init();
curl_setopt($c, CURLOPT_URL, $api_url);
curl_setopt($c, CURLOPT_ENCODING, 'gzip,deflate');
curl_setopt($c, CURLOPT_TIMEOUT_MS, 3000);
curl_setopt($c, CURLOPT_RETURNTRANSFER, true);
//根据 Method 不同进行不同的 curl 操作
switch ($method) {
    case 'GET':
        //GET:追加header
        curl_setopt($c, CURLOPT_HTTPHEADER, $request_headers);
        break;

    case 'POST':
        //POST 将要提交的数据整理后追加,分情况处理
        curl_setopt($c, CURLOPT_POST, true);
        if (strpos(strtolower($_SERVER["CONTENT_TYPE"]) , "multipart/form-data") !== FALSE) {
            //按multipart/form-data处理
            writelog("Posting multipart/form-data content...");
            //针对cURL重写的请求对header进行修正
            for ($rhidx = 0; $rhidx < sizeof($request_headers); $rhidx++) {
                //删除原有的Content-Type参数(boundary会被重复添加,与原始值不一致,导致服务器无法分割数据)
                if (stristr($request_headers[$rhidx], "Content-Type") !== FALSE) $request_headers[$rhidx] = '';
                //删除原有的Content-Length参数(cURL对请求重写后与原始长度不一致,如果原始值比新值长,服务器就会一致等待未传输完成的数据,直到超时)
                if (stristr($request_headers[$rhidx], "Content-Length") !== FALSE) $request_headers[$rhidx] = '';
            }
            //Debug模式下输出整理后的Request Header
            if (DEBUG_MODE === TRUE) {
                foreach ($request_headers as $rh) {
                    writelog("Request header: " . $rh);
                }
            }
            curl_setopt($c, CURLOPT_HTTPHEADER, $request_headers);
            //重新组织POST数据,主要为了将上传的文件重新附加
            $postdata = array();
            foreach ($_POST as $pdk => $pdv) {
                writelog("POST data: [" . $pdk . "] => " . $pdv);
                $postdata[$pdk] = $pdv;
            }
            //如果提交的数据有文件数据(对饭否来说只有一种情况:photo),PHP会将其按 $_FILES(POST提交的文件)处理,需要重新编入准备提交的数据
            if ($_FILES['photo']) {
                writelog("File uploaded as " . $_FILES['photo']['tmp_name']);
                $postdata['photo'] = '@' . $_FILES['photo']['tmp_name'] . ';filename=' . $_FILES['photo']['name'] . ';type=' . $_FILES['photo']['type'];
				if(strpos($postdata['status'], '@') === 0) $postdata['status'] = " ".$postdata['status'];
            }
			writelog("POST data in a nutshell: ".var_export($postdata, TRUE));
            curl_setopt($c, CURLOPT_POSTFIELDS, $postdata);
        } else {
            //按application/x-www-form-urlencoded处理
            writelog("POST data: " . http_build_query($_POST));
            curl_setopt($c, CURLOPT_HTTPHEADER, $request_headers);
            curl_setopt($c, CURLOPT_POSTFIELDS, http_build_query($_POST));
        }
        break;

    default:
        //除此之外的方法都不支持
        die("Unsupported request method [" . $method . "]!");
    }
    //提交请求并获取返回
    $output = curl_exec($c);
    writelog($method . " executed.");
    if ($output === false) {
        writelog($method . " cURL error: " . curl_error($c));
    }
 
    //设置返回的HTTP Status Code
    http_response_code(curl_getinfo($c, CURLINFO_HTTP_CODE));
 
    //关闭 curl 句柄
    curl_close($c);
 
    //处理结果
    switch ($api_ext) {
        case 'xml':
            //使用 simpleXML 加载返回内容
            $xml = simplexml_load_string($output);
            //如果试图打开 XML 出错,原样返回,并在日志中记录
            if ($xml === FALSE) {
                writelog("Error parsing XML return...");
                break;
            }
            //由于暂时用不到 XML 过滤,什么也没有写,有需求可自行扩展
            break;

        case 'json':
            //只对特定的 API 进行处理
            if ($api_name == "home_timeline" || $api_name == "mentions" || $api_name == "public_timeline") {
                //将 JSON 返回解析为对象数组
                $json_array = json_decode($output);
                //如果试图打开 JSON 出错,原样返回,并在日志中记录
                if (json_last_error() !== JSON_ERROR_NONE) {
                    writelog("Error parsing JSON return...");
                    break;
                }
                //开始处理JSON
                //范例:过滤各种 Timeline 中,user 的 id 为 ifanfou 的消息
                foreach ($json_array as $json_ek => $json_ev) {
                    if ($json_ev->{'user'}->{'id'} === "ifanfou") unset($json_array[$json_ek]);
                }
                //以下代码解释:https://stackoverflow.com/questions/20372982/removing-array-index-reference-when-using-json-encode-in-php
                //解释:如果上面的foreach实际执行了过滤操作(顺便说一句 foreach 能做到在其循环中删除元素其实是不对的),
                //那么 $json_array 的数组索引就会变成不连续的,而不连续的索引会被视为 json 不支持的关联数组,
                //所以 json_encode() 会将索引视为一个外层嵌套元素,变成 [ "1": {"created_at": ...} ] 的形式,而客户端是不能解析这种结构的。
                //array_values() 的作用是提取数组中的所有值,并为其重新建立数字索引,当数字索引的值连续起来后,再进行 json_encode() 就可以得到需要的输出了。
                $json_array = array_values($json_array);
                //重新将数组编译为JSON字符串
                $output = json_encode($json_array);
            }
            break;

        default: //如果是饭否API只剩下一种可能:rss,不进行处理
            break;
        }
        //输出结果
        echo ($output);
		//本地方法:写入日志
		function writelog($s) {
			if (DEBUG_MODE === TRUE) {
				$s = (string)$s;
				$s = '[' . date('Y-m-d H:i:s') . '] ' . $s;
				$f = fopen("log_" . date('Ymd') . ".txt", "a");
				fwrite($f, "\r\n" . $s);
				fclose($f);
			}
		}
?>

注:所有评论将在审核通过后显示,请不要在评论内容的任何位置出现链接,否则您的评论将被自动移入回收站,且永远不会被复审。

All comments will be available after being manually reviewed, please do not include any links anywhere in your comment, otherwise your comment will be automatically deleted and are not eligible for review.

4 条评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注