21

I just noticed some strange PHP files in one of my web directories. They turned out to be spammer-placed exploit files.

They've been there since 2006, around the time that I was running a high-profile donation campaign using a CGI script of mine. And the files were placed in the script's writeable directory, so I suspect that my script might have been exploited somehow.

But I'm using Perl "taint checking", strict, etc, and I'm never passing query data to the shell (it never invokes the shell!) or using query data to generate a file path for OPEN... I only OPEN files that I specify directly in the script. I do pass query data INTO written files as file content, but as far as I'm aware, that's not dangerous.

I've stared at these scripts and cannot see anything, and I've studied all the standard Perl CGI holes. Of course, they could have gotten the password to my hosting account somehow, but the fact that these scripts were placed in my CGI script's data directory makes me suspect the script. (Also, them getting my password "somehow" is a much scarier explanation.) Also, around that time, my logs show lots of "Warning, IPN received from a non-PayPal address" messages, with those IPs coming from Russia. So it seems like someone was at least TRYING to hack these scripts.

Two scripts are involved, and I'm pasting them below. Anyone see anything that could be exploited to write unexpected files?

Here's the first script (for receiving PayPal IPN and tracking the donations, and also tracking which site is generating the most donations):

#!/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 );
}

And, there's some redundancy here (sorry about that... completeness?), but here's the second script (for generating website HTML buttons that people can add to their own site):

#!/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 );
}
Charles
  • 50,943
  • 13
  • 104
  • 142
Jason Rohrer
  • 503
  • 4
  • 14
  • 1
    Jason, is this the only dynamic content on that machine? – Nick ODell May 08 '11 at 16:25
  • If files in your upload directory are executable by all, and if they can be reached by the outside world, you've got a problem. But you didn't mention, ARE they executable? Or are they just files, which may indicate a failed attempt? Certainly the webserver shouldn't be looking in an upload directory for executable content. Also, who else has had access to the machine? Could it be someone inside? – DavidO May 08 '11 at 16:28
  • 8
    If the machine hasn't been kept up to date, then 5 years is a lot of time for a remote vulnerability to spring up in a web server, or a mail server, or a kernel, or anything else running on that machine with network access. – Quentin May 08 '11 at 16:28
  • Well, lots of other dynamic content has been added to the machine since then! Various PHP scripts, etc. But none of my PHP scripts write to files (they all used MySQL). Also, these files were posted right during the peak of this campaign. Why did it sit there for so long? Because it was quiet! It's not like my whole site got scrubbed or anything... just two little PHP files added to some subdir somewhere... who would notice that? Also, what kind of holes are available in ftp servers? Anyone have a good link for that? – Jason Rohrer May 08 '11 at 16:34
  • Oh, and no one else has access to the machine. Just one user (me). Well, it's a shared hosting account, but the accounts are well-isolated from each other (jailshell, etc). And this wasn't an upload directory... this was a dir that the CGI script would save its web-readable data files to (it would generate some HTML files that were web-accessible). Yes, one of the PHP scripts that got uploaded was flagged as executable. The only way to upload there, without an exploit, is to FTP or SCP in with my username and password. – Jason Rohrer May 08 '11 at 16:42
  • Could you post the virus-PHP files? We may be able to figure out their intent, and find out how much damage has been done. – Nick ODell May 08 '11 at 16:43
  • How is this line possible, with strict in effect? `my $receiverEmail = "receiver@yahoo.com";` Should give the compilation error: `Global symbol @yahoo requires explicit package name.` – TLP May 08 '11 at 16:43
  • 1
    @TLP: older perls didn't interpolate arrays, or (later) only interpolated them when such an array existed. @ only always interpolates as of 5.6.1. – ysth May 08 '11 at 16:55
  • 6
    Although auditing your own code is a good idea, I agree with @David Dorward, it could have been any number of vulnerabilities, especially on a shared host machine. One thing to check, who owns the hacked files? That will give you a hint as to their origin. If they're not owned by whomever your CGI programs runs as (possibly you or possibly the web server), then they likely did not come from your program. – Schwern May 08 '11 at 22:54
  • What is in the @yahoo array? perl -le 'my $receiverEmail = "receiver@yahoo.com"; print $receiverEmail' :-( – tadmc May 09 '11 at 02:08
  • @TLP: I actually had that escaped with the real recipient's email address in there.... but I didn't want to post her email to the world, so I changed it... and forgot to escape it in what I posted. Fixed my post. @Schwern: unfortunately, CGI runs as me, so I can't tell, and my webhost's logs don't go back far enough to show if this was done through a login. It's pretty much a mystery that will remain so. But I thought that someone might be able to spot something in this perl code (especially since the wayward PHP files were posted right after the CGI-based campaign launched) – Jason Rohrer May 09 '11 at 23:07
  • @Jason This is a long shot, but what about someone potentially POSTing form data with an to the CGI script? Depending on where Perl stored uploaded files, the CGI module may have saved the uploaded data to a local file behind the scenes. – GargantuChet Jun 04 '11 at 20:55

4 Answers4

2

I've seen something similar before. In our case, I'm pretty sure the hackers used a buffer overflow in a library that hadn't been updated. They were then able to use a PHP shell to write files the server.

It's quite likely the problem wasn't in your code. Updating your software more often would make attacks less likely but unfortunately it's impossible to be completely hack-proof. Chances are that they were scanning for a common vulnerability in an old version of software.

Adrian Mouat
  • 44,585
  • 16
  • 110
  • 102
0

Been a while since I played with perl's CGI module, but are you sure CGI::param escapes the values? From where I'm sitting, the values may contain backticks and thus will be expanded and executed.

Mel
  • 6,077
  • 1
  • 15
  • 12
  • 2
    I was looking at that unrestricted `open FILE, $filename`.. if you can manipulate `$filename` somehow, you can pretty much do what you want. `open FILE "| echo #!/usr/bin/perl > hack.cgi"` – TLP May 08 '11 at 17:41
  • Hmm... I don't think backticks INSIDE a user-submitted variable will do anything... nothing is expanded there when the variable is used (it's a string already). Is it? Every exploit that I'm aware of involves passing a user-submitted variable to an OPEN call or using a user-submitted variable inside your OWN backticks. TLP, you're right about the OPEN call being potentially problematic, but if you look at where those subroutines are called, you'll see that $filename never involves a user-submitted variable. I only OPEN a static set of 4 or 5 filenames that are hard-coded by me. – Jason Rohrer May 08 '11 at 19:35
  • 3
    A safety precaution related to 'open' would be to use the three argument version, assuming your version of Perl supports it. I think it showed up in 5.6.1, which is already more than a decade old. Example: open FILE, '>', $filename or die $!; This puts the open mode (>) in a different parameter from the filename, which prevents shell injections. See perldoc -f open for details. – DavidO May 09 '11 at 08:58
  • 1
    If exploit PHP has been on your server since 2006, you're not doing everything right. – ceejayoz Jun 08 '11 at 19:52
0

You could refactor your code to make all the filepath references into compile-time constants with the constant pragma:

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

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

Dealing with constants is a pain because they don't get interpolated by qq, and you've got to constantly endlessly be using (s)printf or a lot of concatenation operators. But it should make it a lot harder for ne'erdowells to alter any arguments that are being passed as filepaths.

roosto
  • 843
  • 8
  • 9
  • 1
    Because of those disadvantages [the `constant` module should be avoided](http://p3rl.org/Perl::Critic::Policy::ValuesAndExpressions::ProhibitConstantPragma). Recommend [Const::Fast](http://p3rl.org/Const::Fast) instead. – daxim Jun 01 '11 at 12:28
0

Your code seems quite safe to me. I would only slightly object to the use of relative paths for the files, that makes me a little uncomfortable, but it's hard to imagine some security risk in that. I'd bet the vulnerability was somewhere below (perl, apache ...)

leonbloy
  • 73,180
  • 20
  • 142
  • 190