iRule Security 101 - #07 - FTP Proxy

We get questions all the time about custom application protocols and how one would go about writing an iRule to "understand" what's going on with that protocol.  In this article, I will look at the FTP protocol and show you how one could write the logic to understand that application flow and selectively turn on and off support for various commands within the protocol. Other articles in the series:


FTP

FTP, for those who don't know, stands for File Transfer Protocol.  FTP is designed to allow for the remote uploading and downloading of documents.  I'm not going to dig deep into the protocol in this document, but for those who want to explore further, it is defined in RFC959.  The basics of FTP are as follows. 

Requests are made with single line requests formatted as:

 

COMMAND COMMAND_ARGS CRLF

Some FTP commands include USER, PASS, & ACCT for authentication, CWD for changing directories, LIST for requesting the contents of a directory, and QUIT for terminating a session.

Responses to commands are made in two ways.  Over the main "control" connection, the server will process the request and then return a response in this format

CODE DESCRIPTION CRLF

Where code is the status code defined for the given request command.  These have some similarity to HTTP response codes (200 -> OK, 500 -> Error), but don't count on them being exactly the same for each situation.  For commands that do not requests content from the server (USER, PASS, CWD, etc), the control connection is all that is uses.  But, there are other commands that specifically request data from the server.  RETR (downloading a file), STOR (uploading a file), and LIST (for requesting a current directory listing) are examples of these types of commands.  For these commands, the status is still returned in the control channel, but the data is passed back in a separate "data" channel that is configured by the client with either the PORT or PASV commands.

Writing the Proxy

We'll start of the iRule with a set of global variables that are used across all connections.  In this iRule will will only inspect on the following FTP commands: USER, PASV, RETR, STOR, RNFR, FNTO, PORT, RMD, MKD, LIST, PWD, CWD, and DELE.  This iRule can easily be expanded to include other commands in the FTP command set.  In the RULE_INIT event we will set some global variables to determine how we want the proxy to handle the specific commands.  A value of 1 for the "block" options will make the iRule deny those commands from reaching the backend FTP server.  Setting a value of 0 for the block flag, will allow the command to pass through.

when RULE_INIT {
  set DEBUG 1
  #------------------------------------------------------------------------
  # FTP Commands
  #------------------------------------------------------------------------
  set sec_block_anonymous_ftp 1
  set sec_block_passive_ftp 0
  set sec_block_retr_cmd 0
  set sec_block_stor_cmd 0
  set sec_block_rename_cmd 0
  set sec_block_port_cmd 0
  set sec_block_rmd_cmd 0
  set sec_block_mkd_cmd 0
  set sec_block_list_cmd 0
  set sec_block_pwd_cmd 0
  set sec_block_cwd_cmd 0
  set sec_block_dele_cmd 1
}

Since we will not be relying on a BIG-IP profile to handle the application parsing, we'll be using the low level TCP events to capture the requests and responses.  When a client establishes a connection, the CLIENT_ACCPETED event will occur, from within this event we'll have to trigger a collection of the TCP data so that we can inspect it in the CLIENT_DATA event.

when CLIENT_ACCEPTED {
  if { $::DEBUG } { log local0. "client accepted" }
  TCP::collect
  TCP::release
}

In the CLIENT_DATA event, we will look at the request with the TCP::payload command.  We will then feed that value into a switch statement with options for each of the commands.  For commands that are found that we want to disallow, we will issue an FTP error response code with description string, empty out the payload, and return from the iRule - thus breaking the connection.  For all other cases, we allow the TCP engine to continue on with it's processing and then enter into data collect mode again.

when CLIENT_DATA {
  if { $::DEBUG } { log local0. "----------------------------------------------------------" }
  if { $::DEBUG } { log local0. "payload [TCP::payload]" }
  set client_data [string trim [TCP::payload]]
  #---------------------------------------------------
  # Block or alert specific commands
  #---------------------------------------------------
  switch -glob $client_data {
    "USER anonymous*" -
    "USER ftp*" {
      if { $::DEBUG } { log local0. "LOG: Anonymous login detected" }
      if { $::sec_block_anonymous_ftp } {
        TCP::respond "530 Guest user not allowed\r\n";
        reject
      }
    }
    "PASV*" {
      if { $::DEBUG } { log local0. "LOG: passive request detected" }
      if { $::sec_block_passive_ftp  } {
        TCP::respond "502 Passive commands not allowed\r\n"
        TCP::payload replace 0 [string length $client_data] ""
        return
      }
    }

    "RETR*" {
      if { $::DEBUG } { log local0. "LOG: RETR request detected" }
      if { $::sec_block_retr_cmd  } {
        TCP::respond "550 RETR commands not allowed\r\n"
        TCP::payload replace 0 [string length $client_data] ""
        return
      }
    }
    "STOR*" {
      if { $::DEBUG } { log local0. "LOG: STOR request detected" }
      if { $::sec_block_stor_cmd  } {
        TCP::respond "550 STOR commands not allowed\r\n"
        TCP::payload replace 0 [string length $client_data] ""
        return
      }
    }
    "RNFR*" -
    "RNTO*" {
      if { $::DEBUG } { log local0. "LOG: RENAME request detected" }
      if { $::sec_block_rename_cmd  } {
        TCP::respond "550 RENAME commands not allowed\r\n"
        TCP::payload replace 0 [string length $client_data] ""
        return
      }
    }
    "PORT*" {
      if { $::DEBUG } { log local0. "LOG: PORT request detected" }
      if { $::sec_block_port_cmd  } {
        TCP::respond "550 PORT commands not allowed\r\n"
        TCP::payload replace 0 [string length $client_data] ""
        return
      }
    }
    "RMD*" {
      if { $::DEBUG } { log local0. "LOG: RMD request detected" }
      if { $::sec_block_rmd_cmd  } {
        TCP::respond "550 RMD commands not allowed\r\n"
        TCP::payload replace 0 [string length $client_data] ""
        return
      }
    }
    "MKD*" {
      if { $::DEBUG } { log local0. "LOG: MKD request detected" }
      if { $::sec_block_mkd_cmd } {
        TCP::respond "550 MKD commands not allowed\r\n"
        TCP::payload replace 0 [string length $client_data] ""
        return
      }
    }
    "LIST*" {
      if { $::DEBUG } { log local0. "LOG: LIST request detected" }
      if { $::sec_block_list_cmd } {
        TCP::respond "550 LIST commands not allowed\r\n"
        TCP::payload replace 0 [string length $client_data] ""
        return
      }
    }
    "PWD*" {
      if { $::DEBUG } { log local0. "LOG: PWD request detected" }
      if { $::sec_block_pwd_cmd } {
        TCP::respond "550 PWD commands not allowed\r\n"
        TCP::payload replace 0 [string length $client_data] ""
        return
      }
    }
    "CWD*" {
      if { $::DEBUG } { log local0. "LOG: CWD request detected" }
      if { $::sec_block_cwd_cmd } {
        TCP::respond "550 CWD commands not allowed\r\n"
        TCP::payload replace 0 [string length $client_data] ""
        return
      }
    }
    "DELE*" {
      if { $::DEBUG } { log local0. "LOG: DELE request detected" }
      if { $::sec_block_dele_cmd } {
        TCP::respond "550 DELE commands not allowed\r\n"
        TCP::payload replace 0 [string length $client_data] ""
        return
      }
    }
  }
  TCP::release
  TCP::collect
}

Once a connection has been made to the backend server, the SERVER_CONNECTED event will be raised.  In this event we will release the context and issue a collect to occur for the server data.  The server data will then be returned, and optionally logged, in the SERVER_DATA event.

when SERVER_CONNECTED {
  if { $::DEBUG } { log "server connected" }
  TCP::release
  TCP::collect
}
when SERVER_DATA {
  if { $::DEBUG } { log local0. "payload <[TCP::payload]>" }
  TCP::release
  TCP::collect
}

And finally when the client closes it's connection,. the CLIENT_CLOSED event will be fired and we will log the fact that the session is over.

when CLIENT_CLOSED {
  if { $::DEBUG } { log local0. "client closed" }
}

Conclusion

This article shows how one can use iRules to inspect, and optionally secure, an application based on command sets within that application.  Not all application protocols behave like FTP (TELNET for instance sends one character at a time and it's up to the proxy to consecutively request more data until the request is complete).  But this should give you the tools you need to start inspection on your TCP based application.

Get the Flash Player to see this player.
Published Nov 15, 2007
Version 1.0
  • Hi,

     

    Could you help me set up an iRule for FTP access, basically to enforce a directory path based on username and password?

     

    Should be something like this:

     

    when FTP_REQUEST { AUTH::username_credential ?

     

    Regards,

     

  • Corection: I got every debug logs from the other events except from CLIENT_DATA. Any idea why?
  • In section CLIENT_DATA and CLIENT_ACCEPTED I do not have any logs in my ltm log. Any idea why? I'm on a BigIP 11.6.0
  • On 9.4.5, it looks like replacing these lines in CLIENT_ACCEPTED:

     

    TCP::collect

     

    TCP::release

     

     

    with these lines:

     

    TCP::collect 0 0

     

     

    will solve the problem.
  • This rule doesn't work on my 9.4.5 HF2 box. Basically, because it does a TCP::collect and immediately a TCP::release in the CLIENT_ACCEPTED block, the CLIENT_DATA event is never triggered (it only logs data coming from the server). But if you don't do a TCP::release in CLIENT_ACCEPTED then the rule will hang because the client doesn't send data until it gets the banner from the server.