Perl CGI 被黑客入侵?但我做的一切都是正确的。

21
我刚刚注意到我的一个网站目录中有一些奇怪的PHP文件。它们原来是垃圾邮件发送者放置的漏洞文件。
这些文件自2006年以来就一直存在,大约在我使用我的CGI脚本运行高调的捐款活动时。而且这些文件被放置在脚本的可写目录中,所以我怀疑我的脚本可能已经被攻击了。
但是我正在使用Perl的“污点检查”、“严格模式”等功能,并且我从未将查询数据传递给shell(它从不调用shell!)或者使用查询数据生成OPEN的文件路径……我只打开我在脚本中直接指定的文件。我确实将查询数据作为文件内容传递到写入的文件中,但据我所知,这并不危险。
我仔细观察了这些脚本,没有发现任何问题,并研究了所有标准的Perl CGI漏洞。当然,他们可能以某种方式获得了我的托管帐户密码,但这些脚本被放置在我的CGI脚本数据目录中,这让我怀疑脚本。(此外,他们以某种方式获取我的密码是一个更可怕的解释。)另外,在那个时候,我的日志显示有很多“警告,IPN来自非PayPal地址”的消息,这些IP来自俄罗斯。所以看起来有人至少试图攻击这些脚本。
涉及两个脚本,我在下面粘贴它们。有人看到可能被利用以编写意外文件的问题吗?
以下是第一个脚本(用于接收PayPal IPN并跟踪捐款,还跟踪哪个网站生成了最多的捐款):
#!/usr/bin/perl -wT


# Created by Jason Rohrer, December 2005
# Copied basic structure and PayPal protocol code from DonationTracker v0.1


# Script settings



# Basic settings

# email address this script is tracking payments for
my $receiverEmail = "receiver\@yahoo.com"; 

# This script must have write permissions to BOTH of its DataDirectories.
# It must be able to create files in these directories.
# On most web servers, this means the directory must be world-writable.
# (  chmod a+w donationData  )
# These paths are relative to the location of the script.
my $pubDataDirectory =  "../goliath";
my $privDataDirectory = "../../cgi-data/donationNet";

# If this $privDataDirectory setting is changed, you must also change it below
# where the error LOG is opened

# end of Basic settings





# Advanced settings
# Ignore these unless you know what you are doing.



# where the log of incoming donations is stored
my $donationLogFile =   "$privDataDirectory/donationLog.txt";


# location of public data generated by this script
my $overallSumFile =    "$pubDataDirectory/overallSum.html";
my $overallCountFile =  "$pubDataDirectory/donationCount.html";
my $topSiteListFile =       "$pubDataDirectory/topSiteList.html";

# private data tracking which donation total coming from each site
my $siteTrackingFile =  "$privDataDirectory/siteTracking.txt";

# Where non-fatal errors and other information is logged
my $logFile =           "$privDataDirectory/log.txt";



# IP of notify.paypal.com
# used as cheap security to make sure IPN is only coming from PayPal
my $paypalNotifyIP =    "216.113.188.202";



# setup a local error log
use CGI::Carp qw( carpout );
BEGIN {

    # location of the error log
    my $errorLogLocation = "../../cgi-data/donationNet/errors.log";

    use CGI::Carp qw( carpout );
    open( LOG, ">>$errorLogLocation" ) or
        die( "Unable to open $errorLogLocation: $!\n" );
    carpout( LOG );
}

# end of Advanced settings


# end of script settings








use strict;
use CGI;                # Object-Oriented CGI library



# setup stuff, make sure our needed files are initialized
if( not doesFileExist( $overallSumFile ) ) {
    writeFile( $overallSumFile, "0" );
}
if( not doesFileExist( $overallCountFile ) ) {
    writeFile( $overallCountFile, "0" );
}
if( not doesFileExist( $topSiteListFile ) ) {
    writeFile( $topSiteListFile, "" );
}
if( not doesFileExist( $siteTrackingFile ) ) {
    writeFile( $siteTrackingFile, "" );
}


# allow group to write to our data files
umask( oct( "02" ) );



# create object to extract the CGI query elements

my $cgiQuery = CGI->new();




# always at least send an HTTP OK header
print $cgiQuery->header( -type=>'text/html', -expires=>'now',
                         -Cache_control=>'no-cache' );

my $remoteAddress = $cgiQuery->remote_host();



my $action = $cgiQuery->param( "action" ) || '';

# first, check if our count/sum is being queried by another script
if( $action eq "checkResults" ) {
    my $sum = readTrimmedFileValue( $overallSumFile );
    my $count = readTrimmedFileValue( $overallCountFile );

    print "$count \$$sum";
}
elsif( $remoteAddress eq $paypalNotifyIP ) {

    my $donorName;


    # $customField contains URL of site that received donation
    my $customField = $cgiQuery->param( "custom" ) || '';

    # untaint and find whitespace-free string (assume it's a URL)
    ( my $siteURL ) = ( $customField =~ /(\S+)/ );

    my $amount = $cgiQuery->param( "mc_gross" ) || '';

    my $currency = $cgiQuery->param( "mc_currency" ) || '';

    my $fee = $cgiQuery->param( "mc_fee" ) || '0';

    my $date = $cgiQuery->param( "payment_date" ) || '';

    my $transactionID = $cgiQuery->param( "txn_id" ) || '';


    # these are for our private log only, for tech support, etc.
    # this information should not be stored in a web-accessible
    # directory
    my $payerFirstName = $cgiQuery->param( "first_name" ) || '';
    my $payerLastName = $cgiQuery->param( "last_name" ) || '';
    my $payerEmail = $cgiQuery->param( "payer_email" ) || '';


    # only track US Dollars 
    # (can't add apples to oranges to get a final sum)
    if( $currency eq "USD" ) {

    my $status = $cgiQuery->param( "payment_status" ) || '';

    my $completed = $status eq "Completed";
    my $pending = $status eq "Pending";
    my $refunded = $status eq "Refunded";

    if( $completed or $pending or $refunded ) {

        # write all relevant payment info into our private log
        addToFile( $donationLogFile,
               "$transactionID  $date\n" . 
               "From: $payerFirstName $payerLastName " .
               "($payerEmail)\n" .
               "Amount: \$$amount\n" .
               "Fee: \$$fee\n" .
               "Status: $status\n\n" );                    

        my $netDonation;

        if( $refunded ) {
        # subtract from total sum

        my $oldSum = 
            readTrimmedFileValue( $overallSumFile );

        # both the refund amount and the
        # fee on the refund are now reported as negative
        # this changed as of February 13, 2004
        $netDonation = $amount - $fee;
        my $newSum = $oldSum + $netDonation;

        # format to show 2 decimal places
        my $newSumString = sprintf( "%.2f", $newSum );

        writeFile( $overallSumFile, $newSumString );


        my $oldCount = readTrimmedFileValue( $overallCountFile );
        my $newCount = $oldCount - 1;
        writeFile( $overallCountFile, $newCount );

        }

        # This check no longer needed as of February 13, 2004
        # since now only one IPN is sent for a refund.
        #  
        # ignore negative completed transactions, since
        # they are reported for each refund (in addition to 
        # the payment with Status: Refunded)
        if( $completed and $amount > 0 ) {
        # fee has not been subtracted yet
        # (fee is not reported for Pending transactions)

        my $oldSum = 
            readTrimmedFileValue( $overallSumFile );
                $netDonation = $amount - $fee;
        my $newSum = $oldSum + $netDonation;

        # format to show 2 decimal places
        my $newSumString = sprintf( "%.2f", $newSum );

        writeFile( $overallSumFile, $newSumString );

        my $oldCount = readTrimmedFileValue( 
                             $overallCountFile );
        my $newCount = $oldCount + 1;
        writeFile( $overallCountFile, $newCount );
        }

        if( $siteURL =~ /http:\/\/\S+/ ) {
        # a valid URL

        # track the total donations of this site
        my $siteTrackingText = readFileValue( $siteTrackingFile );
        my @siteDataList = split( /\n/, $siteTrackingText );
        my $newSiteData = "";
        my $exists = 0;
        foreach my $siteData ( @siteDataList ) {
            ( my $url, my $siteSum ) = split( /\s+/, $siteData );
            if( $url eq $siteURL ) {
            $exists = 1;
            $siteSum += $netDonation;
            }
            $newSiteData = $newSiteData . "$url $siteSum\n";
        }

        if( not $exists ) {
            $newSiteData = $newSiteData . "$siteURL $netDonation";
        }

        trimWhitespace( $newSiteData );

        writeFile( $siteTrackingFile, $newSiteData );

        # now generate the top site list

        # our comparison routine, descending order
        sub highestTotal {
            ( my $url_a, my $total_a ) = split( /\s+/, $a );
            ( my $url_b, my $total_b ) = split( /\s+/, $b );
            return $total_b <=> $total_a;
        }

        my @newSiteDataList = split( /\n/, $newSiteData );

        my @sortedList = sort highestTotal @newSiteDataList;

        my $listHTML = "<TABLE BORDER=0>\n";
        foreach my $siteData ( @sortedList ) {
            ( my $url, my $siteSum ) = split( /\s+/, $siteData );

            # format to show 2 decimal places
            my $siteSumString = sprintf( "%.2f", $siteSum );

            $listHTML = $listHTML .
            "<TR><TD><A HREF=\"$url\">$url</A></TD>".
            "<TD ALIGN=RIGHT>\$$siteSumString</TD></TR>\n";
        }

        $listHTML = $listHTML . "</TABLE>";

        writeFile( $topSiteListFile, $listHTML );

        }


    }
    else {
        addToFile( $logFile, "Payment status unexpected\n" );
        addToFile( $logFile, "status = $status\n" );
    }
    }
    else {
    addToFile( $logFile, "Currency not USD\n" );
    addToFile( $logFile, "currency = $currency\n" );
    }
}
else {
    # else not from paypal, so it might be a user accessing the script
    # URL directly for some reason


    my $customField = $cgiQuery->param( "custom" ) || '';
    my $date = $cgiQuery->param( "payment_date" ) || '';
    my $transactionID = $cgiQuery->param( "txn_id" ) || '';
    my $amount = $cgiQuery->param( "mc_gross" ) || '';

    my $payerFirstName = $cgiQuery->param( "first_name" ) || '';
    my $payerLastName = $cgiQuery->param( "last_name" ) || '';
    my $payerEmail = $cgiQuery->param( "payer_email" ) || '';


    my $fee = $cgiQuery->param( "mc_fee" ) || '0';
    my $status = $cgiQuery->param( "payment_status" ) || '';

    # log it
    addToFile( $donationLogFile,
           "WARNING:  got IPN from unexpected IP address\n" .
           "IP address:  $remoteAddress\n" .
           "$transactionID  $date\n" . 
           "From: $payerFirstName $payerLastName " .
           "($payerEmail)\n" .
           "Amount: \$$amount\n" .
           "Fee: \$$fee\n" .
           "Status: $status\n\n" );

    # print an error page
    print "Request blocked.";
}



##
# Reads file as a string.
#
# @param0 the name of the file.
#
# @return the file contents as a string.
#
# Example:
# my $value = readFileValue( "myFile.txt" );
##
sub readFileValue {
    my $fileName = $_[0];
    open( FILE, "$fileName" ) 
        or die( "Failed to open file $fileName: $!\n" );
    flock( FILE, 1 ) 
        or die( "Failed to lock file $fileName: $!\n" );

    my @lineList = <FILE>;

    my $value = join( "", @lineList );

    close FILE;

    return $value;
}



##
# Reads file as a string, trimming leading and trailing whitespace off.
#
# @param0 the name of the file.
#
# @return the trimmed file contents as a string.
#
# Example:
# my $value = readFileValue( "myFile.txt" );
##
sub readTrimmedFileValue {
    my $returnString = readFileValue( $_[0] );
    trimWhitespace( $returnString );

    return $returnString;
}



##
# Writes a string to a file.
#
# @param0 the name of the file.
# @param1 the string to print.
#
# Example:
# writeFile( "myFile.txt", "the new contents of this file" );
##
sub writeFile {
    my $fileName = $_[0];
    my $stringToPrint = $_[1];

    open( FILE, ">$fileName" ) 
        or die( "Failed to open file $fileName: $!\n" );
    flock( FILE, 2 ) 
        or die( "Failed to lock file $fileName: $!\n" );

    print FILE $stringToPrint;

    close FILE;
}



##
# Checks if a file exists in the filesystem.
#
# @param0 the name of the file.
#
# @return 1 if it exists, and 0 otherwise.
#
# Example:
# $exists = doesFileExist( "myFile.txt" );
##
sub doesFileExist {
    my $fileName = $_[0];
    if( -e $fileName ) {
        return 1;
    }
    else {
        return 0;
    }
}



##
# Trims any whitespace from the beginning and end of a string.
#
# @param0 the string to trim.
##
sub trimWhitespace {   

    # trim from front of string
    $_[0] =~ s/^\s+//;

    # trim from end of string
    $_[0] =~ s/\s+$//;
}



##
# Appends a string to a file.
#
# @param0 the name of the file.
# @param1 the string to append.
#
# Example:
# addToFile( "myFile.txt", "the new contents of this file" );
##
sub addToFile {
    my $fileName = $_[0];
    my $stringToPrint = $_[1];

    open( FILE, ">>$fileName" ) 
        or die( "Failed to open file $fileName: $!\n" );
    flock( FILE, 2 ) 
        or die( "Failed to lock file $fileName: $!\n" );

    print FILE $stringToPrint;

    close FILE;
}



##
# Makes a directory file.
#
# @param0 the name of the directory.
# @param1 the octal permission mask.
#
# Example:
# makeDirectory( "myDir", oct( "0777" ) );
##
sub makeDirectory {
    my $fileName = $_[0];
    my $permissionMask = $_[1];

    mkdir( $fileName, $permissionMask );
}

这里可能有些冗余(抱歉...为了完整性?),但下面是第二个脚本(用于生成网站HTML按钮,人们可以将其添加到自己的网站上):

#!/usr/bin/perl -wT


# Created by Jason Rohrer, December 2005


# Script settings



# Basic settings

my $templateFile = "buttonTemplate.html";

# end of Basic settings





# Advanced settings
# Ignore these unless you know what you are doing.

# setup a local error log
use CGI::Carp qw( carpout );
BEGIN {

    # location of the error log
    my $errorLogLocation = "../../cgi-data/donationNet/errors.log";

    use CGI::Carp qw( carpout );
    open( LOG, ">>$errorLogLocation" ) or
        die( "Unable to open $errorLogLocation: $!\n" );
    carpout( LOG );
}

# end of Advanced settings


# end of script settings








use strict;
use CGI;                # Object-Oriented CGI library


# create object to extract the CGI query elements

my $cgiQuery = CGI->new();




# always at least send an HTTP OK header
print $cgiQuery->header( -type=>'text/html', -expires=>'now',
                         -Cache_control=>'no-cache' );


my $siteURL = $cgiQuery->param( "site_url" ) || '';

print "Paste this HTML into your website:<BR>\n";

print "<FORM><TEXTAREA COLS=40 ROWS=10>\n";

my $buttonTemplate = readFileValue( $templateFile );

$buttonTemplate =~ s/SITE_URL/$siteURL/g;

# escape all tags
$buttonTemplate =~ s/&/&amp;/g;
$buttonTemplate =~ s/</&lt;/g;
$buttonTemplate =~ s/>/&gt;/g;


print $buttonTemplate;

print "\n</TEXTAREA></FORM>";




##
# Reads file as a string.
#
# @param0 the name of the file.
#
# @return the file contents as a string.
#
# Example:
# my $value = readFileValue( "myFile.txt" );
##
sub readFileValue {
    my $fileName = $_[0];
    open( FILE, "$fileName" ) 
        or die( "Failed to open file $fileName: $!\n" );
    flock( FILE, 1 ) 
        or die( "Failed to lock file $fileName: $!\n" );

    my @lineList = <FILE>;

    my $value = join( "", @lineList );

    close FILE;

    return $value;
}



##
# Reads file as a string, trimming leading and trailing whitespace off.
#
# @param0 the name of the file.
#
# @return the trimmed file contents as a string.
#
# Example:
# my $value = readFileValue( "myFile.txt" );
##
sub readTrimmedFileValue {
    my $returnString = readFileValue( $_[0] );
    trimWhitespace( $returnString );

    return $returnString;
}



##
# Writes a string to a file.
#
# @param0 the name of the file.
# @param1 the string to print.
#
# Example:
# writeFile( "myFile.txt", "the new contents of this file" );
##
sub writeFile {
    my $fileName = $_[0];
    my $stringToPrint = $_[1];

    open( FILE, ">$fileName" ) 
        or die( "Failed to open file $fileName: $!\n" );
    flock( FILE, 2 ) 
        or die( "Failed to lock file $fileName: $!\n" );

    print FILE $stringToPrint;

    close FILE;
}



##
# Checks if a file exists in the filesystem.
#
# @param0 the name of the file.
#
# @return 1 if it exists, and 0 otherwise.
#
# Example:
# $exists = doesFileExist( "myFile.txt" );
##
sub doesFileExist {
    my $fileName = $_[0];
    if( -e $fileName ) {
        return 1;
    }
    else {
        return 0;
    }
}



##
# Trims any whitespace from the beginning and end of a string.
#
# @param0 the string to trim.
##
sub trimWhitespace {   

    # trim from front of string
    $_[0] =~ s/^\s+//;

    # trim from end of string
    $_[0] =~ s/\s+$//;
}



##
# Appends a string to a file.
#
# @param0 the name of the file.
# @param1 the string to append.
#
# Example:
# addToFile( "myFile.txt", "the new contents of this file" );
##
sub addToFile {
    my $fileName = $_[0];
    my $stringToPrint = $_[1];

    open( FILE, ">>$fileName" ) 
        or die( "Failed to open file $fileName: $!\n" );
    flock( FILE, 2 ) 
        or die( "Failed to lock file $fileName: $!\n" );

    print FILE $stringToPrint;

    close FILE;
}



##
# Makes a directory file.
#
# @param0 the name of the directory.
# @param1 the octal permission mask.
#
# Example:
# makeDirectory( "myDir", oct( "0777" ) );
##
sub makeDirectory {
    my $fileName = $_[0];
    my $permissionMask = $_[1];

    mkdir( $fileName, $permissionMask );
}

1
杰森,这是那台机器上唯一的动态内容吗? - Nick ODell
8
如果机器没有及时更新,那么五年时间对于运行具有网络访问权限的Web服务器、邮件服务器、内核或其他任何东西的远程漏洞来说是很长的一段时间。请注意,我已经采取了尽力使翻译准确和简明扼要的措施,但并不保证完全无误。 - Quentin
哦,而且没有其他人可以访问这台机器。只有一个用户(我)。嗯,这是一个共享托管账户,但是这些账户彼此之间都很好地隔离(使用了jailshell等)。而且这不是一个上传目录……这是一个CGI脚本将其可读的数据文件保存到的目录(它会生成一些HTML文件,这些文件可以通过Web访问)。是的,其中一个被上传的PHP脚本被标记为可执行文件。唯一的上传方式,除非存在漏洞,就是使用我的用户名和密码通过FTP或SCP进行上传。 - Jason Rohrer
1
@TLP:旧版的 Perl 不会插值数组,或者(后来)只有在存在这样的数组时才插值。从 5.6.1 开始,@ 只会始终插值。 - ysth
6
虽然审计自己的代码是个好主意,我同意 @David Dorward 的观点,但在共享主机上可能存在许多漏洞。有一件事需要检查,就是谁拥有被黑客攻击的文件?这将为你提供关于它们来源的提示。如果这些文件不是由你的 CGI 程序运行时所属的所有者(可能是你或可能是Web服务器)拥有,则它们很可能不是来自你的程序。 - Schwern
显示剩余7条评论
4个回答

2

我之前见过类似的情况。在我们的情况下,我很确定黑客利用了一个未更新的库中的缓冲区溢出漏洞。然后他们能够使用PHP shell来写入服务器文件。

很可能问题不在于你的代码。更频繁地更新软件可以使攻击变得不太可能,但不幸的是完全防止被黑客攻击是不可能的。很可能他们正在扫描旧版本软件中常见的漏洞。


0

你的代码看起来相当安全。我只是稍微反对使用相对路径来访问文件,这让我有点不舒服,但很难想象会有安全风险。我敢打赌漏洞可能存在于更底层(perl、apache等)。


0

我已经有一段时间没有使用过Perl的CGI模块了,但是你确定CGI::param转义了这些值吗?从我的角度来看,这些值可能包含反引号,因此会被扩展和执行。


2
我正在研究那个不受限制的 open FILE, $filename.. 如果你可以以某种方式操纵 $filename,你几乎可以做任何想做的事情。open FILE "| echo #!/usr/bin/perl > hack.cgi" - TLP
嗯...我认为在用户提交的变量内部使用反引号不会有任何作用...当变量被使用时,其中没有任何扩展(它已经是一个字符串)。是吗?我所知道的每个漏洞都涉及将用户提交的变量传递给OPEN调用或在自己的反引号中使用用户提交的变量。TLP,你关于OPEN调用可能存在问题是正确的,但如果你看一下那些子例程被调用的地方,你会发现$filename从未涉及用户提交的变量。我只打开了由我硬编码的一组静态的4或5个文件名。 - Jason Rohrer
3
与“open”相关的一项安全措施是使用三个参数版本,假设您使用的Perl版本支持它。我认为它出现在5.6.1中,这已经超过十年了。例如:open FILE,'>',$filename or die $!; 这将打开模式(>)放在与文件名不同的参数中,从而防止shell注入攻击。有关详细信息,请参见perldoc -f open。 - DavidO
1
如果自2006年以来,PHP漏洞一直存在于您的服务器上,那么您做得还不够好。 - ceejayoz

0

你可以重构你的代码,将所有的文件路径引用都变成编译时常量,使用constant pragma

use constant {
    DIR_PRIVATE_DATA  => "/paths/of/glory",
    FILE_DONATION_LOG => "donationLog.txt"
};

open( FILE, ">>".DIR_PRIVATE_DATA."/".FILE_DONATION_LOG );

处理常量很麻烦,因为它们不会被qq插值,你必须一直使用(s)printf或大量的连接运算符。 但是这应该使得恶意用户更难更改作为文件路径传递的任何参数。


1
因为这些缺点,应该避免使用constant模块(http://p3rl.org/Perl::Critic::Policy::ValuesAndExpressions::ProhibitConstantPragma)。建议改用[Const::Fast](http://p3rl.org/Const::Fast)。 - daxim

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接