深入探讨PHP调用外部程序时的一些细节

前言

总觉得一个域名仅仅用来放博客有点浪费,毕竟60RMB/Year,VPS的闲置带宽和计算资源也应该好好利用一下。既然可以绑定多个三级域名,那么完全可以建立一个复杂点的个人网站,不同域名下提供不同的服务。现在VPS上面已经挂着Aria2和Transmission,计划再写个信息采集爬虫玩玩,以后AI对抗什么的也完全可以放在VPS上跑。这么规划一番,一个颇具个性的小站便有模有样了。千里之行,始于足下,先把网站主页做好再说!

于是上W3layouts找主页模版,各种看花眼,但偏偏苦于个人艺术细菌不足,无力改造。最后难得找到一个走简洁风格的,却又显得有点死板简陋,没什么动态的内容。想想还是要自己亲自动手改造,于是便开始了这一番折腾。

问题的引出

首先,模版里面有个Recent Posts,于是就强迫症地想让里面的内容可以自动更新。问题是我用的Pelican明明是静态博客生成系统啊!没办法,只好亲自动手改造了,具体细节见附录。总而言之,初战告捷!

接下来,又看到模版里的Contact Me,再次虎躯一震:这个东西给力啊!明明有VPS,自然可以把这东西改造得更强大一些,让访问者提交留言之后自动发送到邮箱怎么样?是个不错的主意。

想想其实很简单,在Server端写个PHP接收POST参数,然后直接调用mutt发送邮件就可以了。顺手查了下PHP的基本语法,果然是宇宙最好的语言,几行就把原型撸出来了。满怀兴奋地点击运行,卧槽?!发送失败?!你妹的怎么可能发送失败?!再怎么仔细检查,也搞不明白到底是哪里用错了。而且连日志都没得看,因为这个版本的mutt在编译的时候特么的把debug功能关掉了。正在无比纠结的时候,突然间灵光一闪意识到,Apache执行外部程序,用的肯定是apache这个用户的权限吧……问题是这个用户有执行mutt的权限吗?

于是尝试执行一个简单点的命令,查看mutt的版本信息并返回到网页,发现一切正常,这说明apache这个用户肯定是有权限调用mutt的,但为什么一发送邮件就不行了呢?此时终于想到要祭出su -c大法,我以apache的身份执行命令,看看你丫为什么不行!原来mutt在运行的时候默认要把日志信息写到用户HOME目录下的sent文件中,但apache明明是一个nologin的用户,既然都不能登录哪来的家目录,自然也就没法写这个日志文件,导致发送失败。

于是下面的问题就简单了,如何在Apache中,以其他正常用户的权限,来执行一个程序呢?

权限提升

吐槽模式关闭,接下来进入严肃话题。在*nix系统下面,有一个巧妙且正规的提权方式:SetUID。大意就是说,对于设置了SetUID属性的一个可执行文件,执行者可以自动得到该文件所有者的权限。换句话说,假如rm被设置了这个属性(这个文件属于root),那么任何调用它的程序都将以root权限执行,也就是可以用最高权限随意删除系统文件!

但是回到正题,在我们的例子中,SetUID的特性并不是用来做破坏的。如果我们写一个程序专门用来根据命令行参数调用另外一个程序,并将其所有者设置为一个普通用户(假设为test),然后再为其加上SetUID属性,那么当我们在PHP中以apache的身份调用这个程序来执行新命令时,所执行的命令,应该默认就会以这个test用户的权限来运行了,对吗?

事实上,当我们真的实现一个这样的程序,所有者为test并设置SetUID属性,以apache的身份调用它来执行whoami命令时,会悲催地发现,返回的还是"apache"。也就是说,apache用户并未能利用一个属于test且带有SetUID属性的程序,将自己的调用伪装成是test用户在执行命令。这又是为什么呢?

为了解释这一点,我们引入real UIDeffective UID的概念。顾名思义,前者指真实UID,后者则指当前的有效UID。事实上,当我们执行一个带有SetUID属性的程序时,改变的只是进程的effective UID,从而使得当前进程具有另一个用户的权限;但进程的real UID还是能够使它原形毕露——这个进程只不过是一个UID为real UID的用户调用了带有SetUID属性的程序,所以才具有现在这个effective UID。这样的设计,是为了保证一方面程序在执行过程中能够得到新用户的权限(利用effective UID),另一方面又不会丢失原本应有的权限(利用real UID)。

下面引用一段StackOverflow上面对三种UID的详细解释:

Real UID

This is the UID of the user/process that created THIS process. It can be changed only if the running process has EUID=0.

Effective UID

This UID is used to evaluate privileges of the process to perform a particular action. EUID can be changed either to RUID, or SUID if EUID!=0. If EUID=0, it can be changed to anything.

Saved UID

If you run an executable with the set-UID bit set, then the resulting running process will start off with a real UID of the real user running it, and an effective and saved UID of the owner of the executable file. If the process then calls setuid() or seteuid() to change their effective UID, they can still get back their original privileges again thanks to the saved UID. If the set-UID bit is not set, SUID will be the RUID.

接下来我们分析,为什么上述例子的表现与我们所预期的不同。首先我们知道,各类编程语言中常见的system()调用或者类似调用,本质上都是系统底层fork()->exec()过程的封装而已。fork()调用复制出一个子进程,随后在子进程中执行exec()调用,用新程序覆盖当前进程。其次我们又知道,exec()调用覆盖当前进程时,如果程序没有SetUID属性,那么新进程的UID/EUID都等于原本进程的UID。

因此,在apache进程调用上述设置了SetUID的程序时,当前进程的EUID变为test,而UID仍为apache。随后当前进程中调用system()首先复制出一个子进程,且父子进程的UID、EUID均不变;接下来子进程调用exec()覆盖自身,由于所执行的程序没有SetUID属性,此时新进程的UID、EUID均为原调用者进程的UID,也就是apache用户。由于whoami命令显示的是EUID,因此输出的是"apache"而不是"test"

这样我们就能明白如何才能真正获得一个UID和EUID均为test的新进程:只要及时交换UID和EUID,使得调用system()的时候原本进程的UID已经是test,此时新进程的UID、EUID就一起变为test了。示例代码见附录。

system()调用与login shell

对bash之类的shell程序而言,login shell是指导入了配置文件、环境变量等的shell环境,我们在使用su - 用户名切换用户时,中间的那个减号就表示切换用户后启用一个login shell很多程序如果想要正确运行,必须依赖shell中的一系列环境变量,也就是必须运行在login shell中。先前我在用Python的subprocess库的时候就已经踩过这个大坑了。

因此,不管是在C语言中,还是在Python、PHP等脚本语言中,进行类system()调用时为了稳妥起见,最好保证将命令执行在login shell中。先前我想到的解决方案是利用su - 用户名 -c 具体命令切换到一个普通用户来运行,但这要求我们必须用root权限来进行system()调用,否则会因为需要输入密码而失败。现在想出的一个更为简便的方法是执行:bash --login -c 具体命令来产生一个login shell,这个做法对权限没有要求,不会产生安全隐患。具体实现同样见附录中C代码。

命令行参数传递过程分析

这里我们简要分析一下前端POST过来的评论数据是怎么一层一层传递下来,最后调用mutt的。首先,PHP代码在执行的时候从HTTP的环境变量中读取POST数据,随后立刻把数据保存到临时文件中。接下来,构造命令从临时文件中读取数据,并发送到指定邮箱。但是注意这里的这个命令只是字符串,并未被执行。

我们把这个命令字符串作为命令行参数传递给/usr/local/bin/RunAsUser这个程序,它就是用来进行权限转换的程序,以一个普通用户的身份来执行传递给它的参数。但是在这个程序中,命令字符串还要进一步被包装,保证命令是运行在login shell当中,正如上文所述。最后,封装好的命令被送往system()调用,才真正开始调用mutt发送邮件。

附录

PHP发邮件示例代码

<?php
$text = $_POST['text'];
$name = $_POST['name'];
if (!empty($text)) {
    $temp = tempnam("/tmp","PHP_TEMP_");
    $fid = fopen($temp, "w");
    fwrite($fid, $text);
    fclose($fid);
    $cmd = "/usr/local/bin/RunAsUser /usr/bin/mutt -s \'New Blog Comment by ".$name."\' -e \' set content_type=\'text/html\' \' -e \' set realname=\'".$name."\' \' 852301601@qq.com < ".$temp;
    system($cmd, $status);
    unlink($temp);
    if ( $status == 0 ) echo "您的留言已经成功发送";
    else echo "系统错误,您的留言发送失败,请手动发送邮件。";
} else {
    echo "Please use POST method.";
}
?>

权限变更示例代码

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>

#define MAX 2048

char *fmt = "bash --login -c \"%s\"";
char cmd[MAX + 128] = "";
char buf[MAX];

int main( int argc, char *argv[] )
{
    int i, sum = 0, res = 0;
    uid_t uid , euid;
    uid = getuid();
    euid= geteuid();
    //下面这一步是核心所在!
    if(setreuid(euid,uid)) perror("setreuid");
    for ( i = 1; i < argc; i++ ) sum += strlen(argv[i]);
    if ( sum + argc > MAX ) {
        puts("Command too long!");
        return -1;
    }
    buf[0] = 0;
    for ( i = 1; i < argc; i++ ) {
        if ( i != 1 ) strcat(buf, " ");
        strcat(buf, argv[i]);
    }
    sprintf(cmd, fmt, buf);
    res = system(cmd);
    //puts(cmd);
    return res;
}

mutt的一些使用细节

运行时如果需要覆盖某些设定值,可以直接使用-e 'set xxx=\'xxx\''的形式,注意字符转义,或者单引号双引号交替使用也行。如果有多条语句需要执行,就使用多个-e,而不能把命令写在一起。

默认情况下mutt无法正确处理中文编码,需要在配置文件/etc/Muttrc.local中增加如下几行:

set charset="utf-8"
set send_charset="gb2312"
set send_charset="utf-8"

一般可以用echo "邮件正文" | mutt -s 标题 xxx@xxx的方式来直接发送邮件,但是很容易出错。如果正文中包含了汉字、换行等,就会发送失败。因此,在使用PHP发送邮件时,不能直接构造一个单行的发送语句,而是应该先保存邮件正文内容到一个临时文件,然后从临时文件中读取邮件内容并发送。

在Pelican博客中显示最近文章

由于博客是个人网站的重要部分,因此在网站首页上面展示一下最近更新的文章是个不错的选择。对于Wordpress一类的“重量级”博客,由于后台用到了数据库,因此在首页自定义想要显示的最近更新文章十分容易,访问数据库select一下即可。但是对于像Pelican这样静态生成的博客,想要在网站首页显示出最近文章就稍微有点麻烦了。

考虑Pelican的扩展性,比较方便改造的部分主要有两个:生成HTML时参照的模版以及预处理时调用的plugin。经过对比分析之后,我选择利用前者来解决这个问题。简而言之,我定义了一个模版,其生成的HTML的内容实际上是json数据。然后主页在加载时执行JavaScript,利用jQuery的GET方法载入这些结构化的json数据到HTML标签中,这样就能够完美显示出最近更新的文章了。

此时又有一个问题,由于一般博客地址使用三级域名,但网站首页是二级域名,如下图所示,homepage目录存放网站首页的数据,绑定在finaltheory.me域名上;而blog目录存放博客数据,绑定在blog.finaltheory.me域名上。

/var/www/html/
├── ai
├── aria2
├── blog
├── downloads
├── homepage
└── wordpress

Pelican在更新的时候只能把生成的HTML更新到三级域名绑定的blog目录中去,如果在主页想要访问就成了跨域。考虑到服务器是我自己个人用,平常不会随意变动目录结构,也就懒得再折腾别的方式,直接做个软链接ln -s ../blog/abstracts.html搞定。

Apache中域名与目录绑定及其优先级

使用虚拟主机的用户往往会忽略一个令人疑惑的问题:大家使用同一台主机提供的服务,各个域名都指向同一个IP地址,那么为什么通过不同的域名会访问到不同网站呢?实际上,像Apache这样的服务端程序会自动根据请求中的URL信息,来决定执行哪个目录下的脚本或者返回哪个目录下的数据。换句话说,对于服务器而言,可以把不同的域名绑定到不同的目录上面,从而做到同一台机器为多个网站提供服务。

对于CentOS 7系统的httpd,用户自定义配置一般存放在/etc/httpd/conf.d目录下,其中的每个.conf文件都会被include进主配置文件中。我们使用VirtualHost字段来定义目录与域名的对应关系,其基本语法如下:

<VirtualHost *:80>
ServerAdmin FinalTheory@hotmail.com
DocumentRoot /var/www/html/blog
ServerName blog.finaltheory.me
ErrorDocument 404 /404.html
</VirtualHost>

其中DocumentRoot是目录位置,而ServerName就是要绑定的域名了。这里的域名绑定支持二级域名和三级域名,如果为一个二级域名绑定了目录,那么访问该二级域名下所有未进行目录绑定的三级域名(假设其DNS解析已指向该服务器)时,默认都会跳转到该二级域名所绑定的目录。

举个例子,本博客二级域名为finaltheory.me,绑定在上图所示homepage目录下,若新增一个三级域名test.finaltheory.me,并设置好DNS解析,那么访问该域名时会默认返回homepage目录下的内容。此外,需重启Apache才能使得目录绑定生效!

由于Chrome等浏览器都带有缓存机制,会缓存一些静态内容,更新不够及时。因此当检查目录绑定是否生效或者是否正确时,在浏览器中刷新页面来判断是不靠谱的。一个简单的做法是在IPython中直接用无缓存的urlopen函数来查看返回的数据是否符合预期:

print urlopen('http://www.baidu.com').read()

comments powered by Disqus
Published:
2015-02-09
分类:
Tag: