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

这个东西的最初设计目标是:在不对某些人取消关注(加黑名单也会取消关注)的情况下,通过中间人攻击(不是唬人,其实本质就是篡改返回结果)为不支持消息过滤的饭否客户端(实际上,没有任何一款饭否客户端和官方界面有这个功能,据本人所知的唯一途径是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);
			}
		}
?>

Comments

发表评论

电子邮件地址不会被公开。 必填项已用*标注

 剩余字数 ( Characters available )

注:请不要在评论中插入任何链接,否则将自动被识别为垃圾评论,博主将完全看不到。

Notice: please DO NOT add any links in your comment, otherwise it would be identified as SPAM automatically.

*