Copyright Notice

This text is copyright by CMP Media, LLC, and is used with their permission. Further distribution or use is not permitted.

This text has appeared in an edited form in WebTechniques magazine. However, the version you are reading here is as the author originally submitted the article for publication, not after their editors applied their creativity.

Please read all the information in the table of contents before using this article.
Download this listing!

Web Techniques Column 68 (Dec 2001)

[suggested title: Keeping robots from stuffing your forms]

In my column last month, I talked about having a survey form for customer feedback. These types of forms often have ratings systems or multiple choice values, which are then summarized into ``average score'' or ``most frequent complaint''. In a sense, you're taking a poll, with the ``winner'' affecting the outcome of some future behavior.

This is similar to a online ``voting'' poll, where people select their favorite ``best-dressed'' person, or perhaps their favorite baseball player for an all-star team from some web form. Of course, the forms are meant to be used just once per person, but a clever Perl hacker can write ``ballot stuffing'' programs with a few lines of code. In fact, I've shown such code in past columns (November 1999), and talked about some possible prevention solutions (March 2001), such as ensure that a given IP address or cookie value can vote only once per day or so.

But good scripters can work around even tricks like cookie bans, so what to do next. I was actually thinking about this problem the other day while I was responding to a classified ad on a large portal service (OK, yeah, it was a personals ad), and as a final step, they wanted me to type in the number I saw on the screen. I wondered why they wanted to test me in such a simple way (maybe this was to increase the average quality of the prospective dating material), but then I noticed that it was an image!

Aha, I said to myself. As a human, it's simple for me to see an image, extract the text content, and put it back into a form element, but that's got to be reasonably difficult for an automated form submission! And that got me scurrying off to see how to steal the idea for this month's column. (I'm sure this was implemented because the personals sections in the past have seemed to be constantly stuffed full of fake ads for real escort services and the like.)

After a couple of false starts, I came up with the program presented in [Listing one, below], as a demostration of the basics of this technique.

Lines 1 through 3 are my standard Perl program header, enabling warnings, compiler restrictions, and disabling the buffering of STDOUT. Line 5 brings in all the CGI shortcuts as functions rather than methods.

Lines 7 through 13 give our program a bit of memory, using the Cache::Cache subclass, Cache::FileCache. The Cache::Cache suite is found in the CPAN, being actively developed by DeWitt Clinton, as a replacement/refactoring for his File::Cache (and friends) modules I've used in previous columns.

Here, we're setting up a memory that remembers things for 10 minutes by default. And once an hour, the next lucky participant also gets to burn a bit of CPU to perform the purging housekeeping. Most entries will clean themselves up as needed, but if anyone leaves in the middle of trying to present a survey form, the resulting mess will stay around for up to an hour. The ``namespace'' is also defined, unique to this particular application, which I've arbitrarily chosen as antirobot.

Beginning in line 15, we handle the image generation logic. Since that won't make much sense until we see how the inline image is used, I'll skip that for the moment, and jump down to line 44, and come back later.

Lines 44 to 46 print the standard CGI (HTTP) header, the top of the HTML head, and an in-page first level header to label the page.

Lines 48 to 62 handle the response to the form. Again, since that won't make much sense until we've seen the form, I'll set that aside as well.

Lines 64 to 74 set up a $verify string and a $session value, and store them into the persistent cache. The verify string is eight random characters, designed to be fairly distinct even in courier font, which is why I throw out the 10 characters in the character class on line 65 (two digits, and 4 letters in both lower and upper case). The session ID is designed to be unguessable, so I lifted the same code from Apache::Session once again that I've used here in past columns, to generate a non-predictable 64-character hex string.

The strategy is simple. We provide a challenge (the $verify value) known only on the server, but keyed by the unique session ID (the $session value). This challenge is presented only as an image link, and a hidden field communicates the session ID from the form to the form response action. If the response to the challenge does not match the challenge, we have a mismatch, and must start over.

The form must contain at least two things then: an image link that contains the session ID, and a hidden field that contains that same session ID. The hidden field is handled as a ``sticky field'' set in line 73, and printed in line 85. The image link is generated in line 83, which refers back to this same script, but with a trailing ``path info'' of the session number followed by .png. This is detected back in line 15 on the subsequent invocation, but let's finish off the form first.

Lines 76 to 87 generate the form, including our one survey element: a request for some favorite ice-cream flavor. We also have to include our session hidden form, the link to the image, and the textfield for the user's response to reading the image to determine the string in $verify.

Let's see how that image is generated, starting back up in line 15. First, we notice that the script is invoked with some ``path info''. This is trailing data that follows the URI of the script, as if the script were a directory. For example, if the session ID was ``a1b2c3'', we'd get the URL:

        http://www.stonehenge.comm/cgi/antirobot/a1b2b3.png

invoked from the original script of

        http://www.stonehenge.comm/cgi/antirobot

This URL was constructed in line 83, and is automatically adjusted for the installed location of the script. Line 15 pulls out the /a1b2c3.png part into $info.

Lines 16 to 21 verify that this is a plausible URL for a session image. If not, a ``404 not found'' response is generated, which makes sense: you've asked for a ``file'' within a ``directory'' that doesn't even exist.

Next, lines 23 to 28 extract the secret $verify string for this session, computed in the previous invocation and saved in line 74 to the database. Again, if this doesn't exist, it's either a ``replay attack'' (a good session key is being reused to get another vote in place) or a ``forge attack'' (where a session ID is being randomly generated to see if it might be a valid credential). Because of the huge space of a 256-bit MD5 value, a brute force attack will not yield fruit, but in any case, we return the ``404 not found'' code here as well. (The warnings generated in line 25 would definitely be of some concern, however, and should be watched closely.)

If we've got a good session, and therefore the verify string for that session, the next thing is to make an image of it. Three popular tools for this are GD, Imager and the steroid-laden Image::Magick modules, all found in the CPAN. As this was a simple task, I picked GD, brought in at line 31. I'm using a fairly recent GD which writes PNG files: older versions generate the controversial GIF format which would also work as well here.

Line 33 selects the ``giant'' font built in to the GD package. Lines 34 and 35 create an image that is big enough to hold the string and a one-pixel border around the string (for an offseting border). Line 36 allocates the background color (always the first color), picking ``black'' here (reg, green, and blue values all 0). Line 37 when uncommented would make this background transparent, but I realized that the output would then be sensistive to the background color of the HTML page, so I commented that out at the last minute.

Lines 38 and 39 write the string, by creating an ``ink'' of ``white'' (red, green, and blue values all 255, their maximum), and then using that to place a string, offset 1 character in each direction to maintain the border. Finally, line 40 pushes the image out with the right CGI (HTTP) header as a PNG, and line 41 terminates this particular CGI invocation.

As I was discussing this program with my peers, a few suggested that using an automated tool to perform optical character recognition (OCR) on the image would be enough to extract the verify string programmatically. My thoughts are that if someone is going to that extreme, and if it was important enough to me, I'd start using low-contrast letters, or gradiants, or background grids. But even with just the simple image here, we've raised the bar to where most people won't bother trying to get around it (although a few would take it on for the challenge).

Once the form has been filled out, we've got the standard response structure beginning in line 48. First, if verify is returned, then it was a form response. If session is also included, then we fetch that session from the cache. If that's a hit, we remove the session from the cache, thus ensuring that a replay attack is not possible: only one form response can possibly use a given session/validation pair. Note that there's a very small window between checking for the session and removing that valid session, which could lead to multiple submits that are all validated. Again, a little more care in programming could eliminate that (using a read-modify-write-locked database, for example), but again, I think we've raised the bar enough to deter all but the most serious ballot-stuffers.

In line 54, if we've got a match between the challenge (in $validate) and the response (in $verify), then we've got a real human who has managed to correctly examine the image, figure out the original letters and digits, and then type those back in. In that case, we record the real human's vote in line 55 (code not shown: you could save it to XML as I showed in last month's column, for example), and offer our congratulations for participating in the democratic process. Otherwise, line 59 punts them back to the form again (as described earlier). Note that the sticky fields for the form values do indeed persist, so they'll not need to re-select the ice-cream color, but they will get another session ID and validation string.

And there you have it: a complete implementation of a robot-ballot-stuffing-proof survey form. Well, until someone else publishes how to programmatically turning an image into a text string, anyway. Until then, enjoy!

Listings

        =1=     #!/usr/bin/perl -w
        =2=     use strict;
        =3=     $|++;
        =4=     
        =5=     use CGI qw(:all);
        =6=     
        =7=     use Cache::FileCache;
        =8=     my $cache = Cache::FileCache->new
        =9=       ({namespace => 'antirobot',
        =10=        username => 'nobody',
        =11=        default_expires_in => '10 minutes',
        =12=        auto_purge_interval => '1 hour',
        =13=       });
        =14=    
        =15=    if (length (my $info = path_info())) { # I am the image
        =16=      my ($session) = $info =~ m{\A/([0-9a-f]+)\.png\z}i
        =17=        or do {
        =18=          warn("bad URL $info");
        =19=          print header(-status => '404 Not Found');
        =20=          exit 0;
        =21=        };
        =22=    
        =23=      defined(my $verify = $cache->get($session))
        =24=        or do {
        =25=          warn("Cannot find $session");
        =26=          print header(-status => '404 Not Found');
        =27=          exit 0;
        =28=        };
        =29=    
        =30=      ## make up an image from the verify string
        =31=      require GD;
        =32=    
        =33=      my $font = GD::gdGiantFont();
        =34=      my $image = GD::Image->new(2 + $font->width * length $verify,
        =35=                                 2 + $font->height);
        =36=      my $background = $image->colorAllocate(0,0,0);
        =37=      ## $image->transparent($background);
        =38=      my $ink = $image->colorAllocate(255,255,255);
        =39=      $image->string($font, 1, 1, $verify, $ink);
        =40=      print header('image/png'), $image->png;
        =41=      exit 0;
        =42=    }
        =43=    
        =44=    print header,
        =45=      start_html("Vote for your favorite!"),
        =46=      h1("Vote for your favorite ice cream flavor!");
        =47=    
        =48=    if (defined(my $verify = param('verify'))) {
        =49=      Delete('verify');
        =50=      if (defined (my $session = param('session'))) {
        =51=        Delete('session');
        =52=        if (defined (my $validate = $cache->get($session))) {
        =53=          $cache->remove($session); # one chance is all you get
        =54=          if ($validate eq $verify) { # success!
        =55=            ## would save param('flavor') here
        =56=            print h2("Thank you!"), p("Your vote has been counted."), end_html;
        =57=            exit 0;
        =58=          }
        =59=          print p("Sorry, please reenter the security string exactly as shown!");
        =60=        }
        =61=      }
        =62=    }
        =63=    
        =64=    my $verify = do {
        =65=      my @charset = grep !/[10joli]/i, 0..9, 'a'..'z', 'A'..'Z';
        =66=      join "", map { $charset[rand @charset] } 1..8;
        =67=    };
        =68=    
        =69=    my $session = do {
        =70=      require MD5;
        =71=      MD5->hexhash(MD5->hexhash(time.{}.rand().$$));
        =72=    };
        =73=    param('session', $session);
        =74=    $cache->set($session, $verify);
        =75=    
        =76=    print hr, startform;
        =77=    print p("Your favorite ice-cream?");
        =78=    print radio_group(-name => "flavor",
        =79=                      -values => [qw(None Other Chocolate Vanilla Strawberry)],
        =80=                      -default => "None",
        =81=                      -columns => 1);
        =82=    print p("For security purposes, please enter",
        =83=            img({src => url()."/$session.png"}).":",
        =84=            textfield(-name => "verify"));
        =85=    print hidden('session');
        =86=    print br, submit, endform, hr;
        =87=    print end_html;

Randal L. Schwartz is a renowned expert on the Perl programming language (the lifeblood of the Internet), having contributed to a dozen top-selling books on the subject, and over 200 magazine articles. Schwartz runs a Perl training and consulting company (Stonehenge Consulting Services, Inc of Portland, Oregon), and is a highly sought-after speaker for his masterful stage combination of technical skill, comedic timing, and crowd rapport. And he's a pretty good Karaoke singer, winning contests regularly.

Schwartz can be reached for comment at merlyn@stonehenge.com or +1 503 777-0095, and welcomes questions on Perl and other related topics.