Merge commit '8fff1d70fd' into topic/policy-scripts-new

* commit '8fff1d70fd':
  ftp.bro is done except for a few points
This commit is contained in:
Seth Hall 2011-03-16 17:02:53 -04:00
commit 3bba5af34f
4 changed files with 394 additions and 652 deletions

View file

@ -1,161 +0,0 @@
module FTP;
export {
type CmdArg: record {
cmd: string &default="<unknown>";
arg: string &default="";
seq: count &default=0;
};
type PendingCmds: table[count] of CmdArg;
const ftp_cmd_reply_code: set[string, count] = {
# According to RFC 959
["<init>", [120, 220, 421]],
["USER", [230, 530, 500, 501, 421, 331, 332]],
["PASS", [230, 202, 530, 500, 501, 503, 421, 332]],
["ACCT", [230, 202, 530, 500, 501, 503, 421]],
["CWD", [250, 500, 501, 502, 421, 530, 550]],
["CDUP", [200, 500, 501, 502, 421, 530, 550]],
["SMNT", [202, 250, 500, 501, 502, 421, 530, 550]],
["REIN", [120, 220, 421, 500, 502]],
["QUIT", [221, 500]],
["PORT", [200, 500, 501, 421, 530]],
["PASV", [227, 500, 501, 502, 421, 530]],
["MODE", [200, 500, 501, 504, 421, 530]],
["TYPE", [200, 500, 501, 504, 421, 530]],
["STRU", [200, 500, 501, 504, 421, 530]],
["ALLO", [200, 202, 500, 501, 504, 421, 530]],
["REST", [500, 501, 502, 421, 530, 350]],
["STOR", [125, 150, 110, 226, 250, 425, 426, 451, 551, 552, 532, 450, 452, 553, 500, 501, 421, 530]],
["STOU", [125, 150, 110, 226, 250, 425, 426, 451, 551, 552, 532, 450, 452, 553, 500, 501, 421, 530]],
["RETR", [125, 150, 110, 226, 250, 425, 426, 451, 450, 550, 500, 501, 421, 530]],
["LIST", [125, 150, 226, 250, 425, 426, 451, 450, 500, 501, 502, 421, 530]],
["NLST", [125, 150, 226, 250, 425, 426, 451, 450, 500, 501, 502, 421, 530]],
["APPE", [125, 150, 226, 250, 425, 426, 451, 551, 552, 532, 450, 550, 452, 553, 500, 501, 502, 421, 530]],
["RNFR", [450, 550, 500, 501, 502, 421, 530, 350]],
["RNTO", [250, 532, 553, 500, 501, 502, 503, 421, 530]],
["DELE", [250, 450, 550, 500, 501, 502, 421, 530]],
["RMD", [250, 500, 501, 502, 421, 530, 550]],
["MKD", [257, 500, 501, 502, 421, 530, 550]],
["PWD", [257, 500, 501, 502, 421, 550]],
["ABOR", [225, 226, 500, 501, 502, 421]],
["SYST", [215, 500, 501, 502, 421]],
["STAT", [211, 212, 213, 450, 500, 501, 502, 421, 530]],
["HELP", [211, 214, 500, 501, 502, 421]],
["SITE", [200, 202, 500, 501, 530]],
["NOOP", [200, 500, 421]],
# Extensions
#["SIZE", [213, 550]],
#["SITE", 214],
#["MDTM", 213],
#["EPSV", 500],
#["FEAT", 500],
#["OPTS", 500],
#["CDUP", 250],
#["CLNT", 200],
#["CLNT", 500],
#["EPRT", 500],
#["FEAT", 211],
#["HELP", 200],
#["LIST", 550],
#["LPRT", 500],
#["MACB", 500],
#["MDTM", 212],
#["MDTM", 500],
#["MDTM", 501],
#["MDTM", 550],
#["MLST", 500],
#["MLST", 550],
#["MODE", 502],
#["NLST", 550],
#["OPTS", 501],
#["REST", 200],
#["SITE", 502],
#["SIZE", 500],
#["STOR", 550],
#["SYST", 530],
["<init>", 0], # unexpected command-reply pair
["<missing>", 0], # unexpected command-reply pair
["QUIT", 0], # unexpected command-reply pair
} &redef;
}
function add_pending_cmd(pc: PendingCmds, cmd: string, arg: string): CmdArg
{
local ca = [$cmd = cmd, $arg = arg, $seq=|pc|+1];
pc[|pc|+1] = ca;
return ca;
}
function get_pending_cmd(pc: PendingCmds, reply_code: count, reply_msg: string): CmdArg
{
local best_match: CmdArg;
local best_seq = 0;
local best_score: int = -1;
#if ( |pc| == 0 )
# return best_match;
for ( cmd_seq in pc )
{
local cmd = pc[cmd_seq];
local score: int = 0;
# if the command is compatible with the reply code
# code 500 (syntax error) is compatible with all commands
if ( reply_code == 500 || [cmd$cmd, reply_code] in ftp_cmd_reply_code )
score = score + 100;
# if the command or the command arg appears in the reply message
if ( strstr(reply_msg, cmd$cmd) > 0 )
score = score + 20;
if ( strstr(reply_msg, cmd$cmd) > 0 )
score = score + 10;
if ( score > best_score ||
( score == best_score && best_seq > cmd_seq ) ) # break tie with sequence number
{
best_score = score;
best_seq = cmd_seq;
best_match = cmd;
}
}
if ( [best_match$cmd, reply_code] !in ftp_cmd_reply_code )
{
local annotation = "";
if ( |pc| == 1 )
annotation = "for sure";
else
{
for ( i in pc )
annotation = cat(annotation, " ", pc[i]);
annotation = cat("candidates:", annotation);
}
# add ftp_unexpected_cmd_reply[fmt("[\"%s\", %d], # %s",
# best_match$cmd, reply_code, annotation)];
}
return best_match;
}
function remove_pending_cmd(pc: PendingCmds, ca: CmdArg): bool
{
if ( ca$seq in pc )
{
delete pc[ca$seq];
return T;
}
else
return F;
}
function pop_pending_cmd(pc: PendingCmds, reply_code: count, reply_msg: string): CmdArg
{
local ca = get_pending_cmd(pc, reply_code, reply_msg);
remove_pending_cmd(pc, ca);
return ca;
}

133
policy/ftp-lib.bro Normal file
View file

@ -0,0 +1,133 @@
module FTP;
export {
type CmdArg: record {
cmd: string &default="<unknown>";
arg: string &default="";
seq: count &default=0;
};
type PendingCmds: table[count] of CmdArg;
const cmd_reply_code: set[string, count] = {
# According to RFC 959
["<init>", [120, 220, 421]],
["USER", [230, 331, 332, 421, 530, 500, 501]],
["PASS", [230, 202, 332, 421, 530, 500, 501, 503]],
["ACCT", [230, 202, 421, 530, 500, 501, 503]],
["CWD", [250, 421, 500, 501, 502, 530, 550]],
["CDUP", [200, 250, 421, 500, 501, 502, 530, 550]],
["SMNT", [202, 250, 421, 500, 501, 502, 530, 550]],
["REIN", [120, 220, 421, 500, 502]],
["QUIT", [221, 500]],
["PORT", [200, 421, 500, 501, 530]],
["PASV", [227, 421, 500, 501, 502, 530]],
["MODE", [200, 421, 500, 501, 502, 504, 530]],
["TYPE", [200, 421, 500, 501, 504, 530]],
["STRU", [200, 421, 500, 501, 504, 530]],
["ALLO", [200, 202, 421, 500, 501, 504, 530]],
["REST", [200, 350, 421, 500, 501, 502, 530]],
["STOR", [110, 125, 150, 226, 250, 421, 425, 426, 451, 551, 552, 532, 450, 452, 553, 500, 501, 530, 550]],
["STOU", [110, 125, 150, 226, 250, 421, 425, 426, 451, 551, 552, 532, 450, 452, 553, 500, 501, 530, 550]],
["RETR", [110, 125, 150, 226, 250, 421, 425, 426, 451, 450, 500, 501, 530, 550]],
["LIST", [125, 150, 226, 250, 421, 425, 426, 451, 450, 500, 501, 502, 530, 550]],
["NLST", [125, 150, 226, 250, 421, 425, 426, 451, 450, 500, 501, 502, 530, 550]],
["APPE", [125, 150, 226, 250, 421, 425, 426, 451, 551, 552, 532, 450, 550, 452, 553, 500, 501, 502, 530]],
["RNFR", [350, 421, 450, 550, 500, 501, 502, 530]],
["RNTO", [250, 421, 532, 553, 500, 501, 502, 503, 530]],
["DELE", [250, 421, 450, 550, 500, 501, 502, 530]],
["RMD", [250, 421, 500, 501, 502, 530, 550]],
["MKD", [257, 421, 500, 501, 502, 530, 550]],
["PWD", [257, 421, 500, 501, 502, 550]],
["ABOR", [225, 226, 421, 500, 501, 502]],
["SYST", [215, 421, 500, 501, 502, 530]],
["STAT", [211, 212, 213, 421, 450, 500, 501, 502, 530]],
["HELP", [200, 211, 214, 421, 500, 501, 502]],
["SITE", [200, 202, 214, 500, 501, 502, 530]],
["NOOP", [200, 421, 500]],
# Extensions
["LPRT", [500, 501, 521]], # RFC1639
["FEAT", [211, 500, 502]], # RFC2389
["OPTS", [200, 451, 501]], # RFC2389
["EPSV", [229, 500, 501]], # RFC2428
["EPRT", [200, 500, 501, 522]], # RFC2428
["SIZE", [213, 500, 501, 550]], # RFC3659
["MDTM", [213, 500, 501, 550]], # RFC3659
["MLST", [150, 226, 250, 500, 501, 550]], # RFC3659
["MLSD", [150, 226, 250, 500, 501, 550]], # RFC3659
["CLNT", [200, 500]], # No RFC (indicate client software)
["MACB", [200, 500, 550]], # No RFC (test for MacBinary support)
["<init>", 0], # unexpected command-reply pair
["<missing>", 0], # unexpected command-reply pair
["QUIT", 0], # unexpected command-reply pair
} &redef;
}
function add_pending_cmd(pc: PendingCmds, cmd: string, arg: string): CmdArg
{
local ca = [$cmd = cmd, $arg = arg, $seq=|pc|+1];
pc[ca$seq] = ca;
return ca;
}
# Determine which is the best command to match with based on the
# response code and message.
function get_pending_cmd(pc: PendingCmds, reply_code: count, reply_msg: string): CmdArg
{
local best_match: CmdArg;
local best_seq = 0;
local best_score: int = -1;
for ( cmd_seq in pc )
{
local cmd = pc[cmd_seq];
local score: int = 0;
# if the command is compatible with the reply code
# code 500 (syntax error) is compatible with all commands
if ( reply_code == 500 || [cmd$cmd, reply_code] in cmd_reply_code )
score = score + 100;
# if the command or the command arg appears in the reply message
if ( strstr(reply_msg, cmd$cmd) > 0 )
score = score + 20;
if ( strstr(reply_msg, cmd$arg) > 0 )
score = score + 10;
if ( score > best_score ||
( score == best_score && best_seq > cmd_seq ) ) # break tie with sequence number
{
best_score = score;
best_seq = cmd_seq;
best_match = cmd;
}
}
#if ( [best_match$cmd, reply_code] !in cmd_reply_code )
# {
# # TODO: maybe do something when best match doesn't have an expected response code?
# }
return best_match;
}
function remove_pending_cmd(pc: PendingCmds, ca: CmdArg): bool
{
if ( ca$seq in pc )
{
delete pc[ca$seq];
return T;
}
else
return F;
}
function pop_pending_cmd(pc: PendingCmds, reply_code: count, reply_msg: string): CmdArg
{
local ca = get_pending_cmd(pc, reply_code, reply_msg);
remove_pending_cmd(pc, ca);
return ca;
}

View file

@ -1,56 +1,55 @@
@load functions
@load notice.bro
@load ftp-cmd-arg
@load ftp-lib
#@load conn
#@load scan
#@load hot-ids
#@load terminate-connection
# TODO:
# * Handle encrypted sessions correctly (get an example?)
# * Detect client software with CLNT command
# * Detect server software with initial 220 message
# * Detect client software with password given for anonymous users (e.g. cyberduck@example.net)
module FTP;
redef enum Notice::Type += {
FTP_UnexpectedConn, # FTP data transfer from unexpected src
FTP_ExcessiveFilename, # very long filename seen
FTP_PrivPort, # privileged port used in PORT/PASV;
# $sub says which
FTP_BadPort, # bad format in PORT/PASV; $sub says which
FTP_Sensitive, # sensitive connection - not more specific
FTP_SiteExecAttack, # specific "site exec" attack seen
## This indicates that a "SITE EXEC" command/arg pair was seen.
FTP_SiteExec,
};
export {
redef enum Log::ID += { FTP_AUTH, FTP_FILES };
type LogFiles: record {
ts: time;
id: conn_id;
user: string &default="";
password: string &optional;
command: string &default="";
url: string &default="";
mime_type: string &default="";
mime_desc: string &default="";
reply_code: count &default=0;
reply_msg: string &default="";
redef enum Log::ID += { FTP };
type Log: record {
ts: time;
id: conn_id;
user: string &default="";
password: string &optional;
command: string &default="";
arg: string &default="";
mime_type: string &default="";
mime_desc: string &default="";
file_size: count &default=0;
reply_code: count &default=0;
reply_msg: string &default="";
};
type SessionInfo: record {
ts: time; # time of request
ts: time;
id: conn_id;
user: string &default="<unknown>";
password: string &optional;
cwd: string &default="<before_login>/";
command: CmdArg &optional;
reply_code: count &default=0; # the most recent reply code
reply_msg: string &default=""; # the most recent reply message
reply_code: count &default=0;
reply_msg: string &default="";
mime_type: string &default="";
mime_desc: string &default="";
file_size: count &default=0;
pending_commands: PendingCmds;
pending_commands: PendingCmds; # pending requests
log_it: bool &default=F; # if true, log the request(s)s
log_it: bool &default=F; # if true, log the command/response
has_response: bool &default=F;
};
type FTPExpectedConn: record {
type ExpectedConn: record {
host: addr;
session: SessionInfo;
};
@ -60,160 +59,47 @@ export {
y: count; # middle (2nd) digit
z: count; # bottom digit
};
# TODO: add this back in some form. raise a notice again?
#const excessive_filename_len = 250 &redef;
#const excessive_filename_trunc_len = 32 &redef;
## These are user IDs that can be considered "anonymous".
const guest_ids = { "anonymous", "ftp", "guest" } &redef;
# Indexed by source & destination addresses and the id.
#const skip_hot: set[addr, addr, string] &redef;
# see: http://packetstormsecurity.org/UNIX/penetration/rootkits/index4.html
# for current list of rootkits to include here
#const hot_files =
# /.*(etc\/|master\.)?(passwd|shadow|s?pwd\.db)/
# | /.*snoop\.(tar|tgz).*/
# | /.*bnc\.(tar|tgz).*/
# | /.*datapipe.*/
# | /.*ADMw0rm.*/
# | /.*newnick.*/
# | /.*sniffit.*/
# | /.*neet\.(tar|tgz).*/
# | /.*\.\.\..*/
# | /.*ftpscan.txt.*/
# | /.*jcc.pdf.*/
# | /.*\.[Ff]rom.*/
# | /.*sshd\.(tar|tgz).*/
# | /.*\/rk7.*/
# | /.*rk7\..*/
# | /.*[aA][dD][oO][rR][eE][bB][sS][dD].*/
# | /.*[tT][aA][gG][gG][eE][dD].*/
# | /.*shv4\.(tar|tgz).*/
# | /.*lrk\.(tar|tgz).*/
# | /.*lyceum\.(tar|tgz).*/
# | /.*maxty\.(tar|tgz).*/
# | /.*rootII\.(tar|tgz).*/
# | /.*invader\.(tar|tgz).*/
#&redef;
#const hot_guest_files =
# /.*\.rhosts/
# | /.*\.forward/
# &redef;
#const hot_cmds: table[string] of pattern = {
# ["SITE"] = /[Ee][Xx][Ee][Cc].*/,
#} &redef;
const excessive_filename_len = 250 &redef;
const excessive_filename_trunc_len = 32 &redef;
const guest_ids = { "anonymous", "ftp", "guest", } &redef;
# Invalid PORT/PASV directives that exactly match the following
# don't generate notice's.
const ignore_invalid_PORT =
/,0,0/ # these are common, dunno why
&redef;
# Some servers generate particular privileged PASV ports for benign
# reasons (presumably to tunnel through firewalls, sigh).
const ignore_privileged_PASVs = { ssh, } &redef;
# Pairs of IP addresses for which we shouldn't bother logging if one
# of them is used in lieu of the other in a PORT or PASV directive.
const skip_unexpected: set[addr] = {
15.253.0.10, 15.253.48.10, 15.254.56.2, # hp.com
gvaona1.cns.hp.com,
## The list of commands that should have their command/response pairs logged.
const logged_commands = {
"APPE", "DELE", "RETR", "STOR", "STOU", "CLNT", "ACCT", "SITE"
} &redef;
const skip_unexpected_net: set[addr] &redef;
# This tracks all of the currently established FTP control sessions.
global ftp_sessions: table[conn_id] of SessionInfo;
# Indexed by the responder pair, yielding the address expected to connect to it.
global ftp_data_expected: table[addr, port] of FTPExpectedConn &create_expire=1min;
global ftp_ports = { 21/tcp } &redef;
redef dpd_config += { [ANALYZER_FTP] = [$ports = ftp_ports] };
redef capture_filters += { ["ftp"] = "port 20 or port 21" };
## This tracks all of the currently established FTP control sessions.
global active_conns: table[conn_id] of SessionInfo &read_expire=15mins;
}
global ftp_data_expected: table[addr, port] of ExpectedConn &create_expire=5mins;
event bro_init()
{
Log::create_stream("FTP_FILES", "FTP::LogFiles");
Log::add_default_filter("FTP_FILES");
Log::create_stream("FTP", "FTP::Log");
Log::add_default_filter("FTP");
}
const ftp_file_cmds = {
# A set of commands where the argument can be expected to refer
# to a file or directory.
const file_cmds = {
"APPE", "CWD", "DELE", "MKD", "RETR", "RMD", "RNFR", "RNTO",
"STOR", "STOU",
"STOR", "STOU", "REST", "SIZE", "MDTM",
};
const ftp_absolute_path_pat = /(\/|[A-Za-z]:[\\\/]).*/;
const ftp_dir_operation = {
["CWD", 250],
["CDUP", 200], # typo in RFC?
["CDUP", 250], # as found in traces
["PWD", 257],
# Commands that either display or change the current working directory.
const directory_cmds = {
["CWD", 250],
["CDUP", 200], # typo in RFC?
["CDUP", 250], # as found in traces
["PWD", 257],
["XPWD", 257],
};
const ftp_skip_replies = {
150, # "status okay - about to open connection"
331 # "user name okay, need password"
};
const ftp_replies: table[count] of string = {
[150] = "ok",
[200] = "ok",
[220] = "ready for new user",
[221] = "closed",
[226] = "complete",
[230] = "logged in",
[250] = "ok",
[257] = "done",
[331] = "id ok",
[500] = "syntax error",
[530] = "denied",
[550] = "unavail",
};
const ftp_other_replies = { ftp_replies };
const ftp_all_cmds: set[string] = {
"<init>", "<missing>",
"USER", "PASS", "ACCT",
"CWD", "CDUP", "SMNT",
"REIN", "QUIT",
"PORT", "PASV", "MODE", "TYPE", "STRU",
"ALLO", "REST", "STOR", "STOU", "RETR", "LIST", "NLST", "APPE",
"RNFR", "RNTO", "DELE", "RMD", "MKD", "PWD", "ABOR",
"SYST", "STAT", "HELP",
"SITE", "NOOP",
# FTP extensions
"SIZE", "MDTM", "MLST", "MLSD",
"EPRT", "EPSV",
};
# const ftp_state_diagram: table[string] of count = {
# ["ABOR", "ALLO", "DELE", "CWD", "CDUP",
# "SMNT", "HELP", "MODE", "NOOP", "PASV",
# "QUIT", "SITE", "PORT", "SYST", "STAT",
# "RMD", "MKD", "PWD", "STRU", "TYPE"] = 1,
# ["APPE", "LIST", "NLST", "RETR", "STOR", "STOU"] = 2,
# ["REIN"] = 3,
# ["RNFR", "RNTO"] = 4,
# };
function cmd_pending(s: SessionInfo): bool
{
return |s$pending_commands| > 0;
}
function parse_ftp_reply_code(code: count): ReplyCode
{
local a: ReplyCode;
@ -228,72 +114,8 @@ function parse_ftp_reply_code(code: count): ReplyCode
return a;
}
# Process ..'s and eliminate duplicate '/'s
# Deficiency: gives wrong results when a symbolic link is followed by ".."
function compress_path(dir: string): string
{
const cdup_sep = /((\/)+([^\/]|\\\/)+)?((\/)+\.\.(\/)+)/;
local parts = split_n(dir, cdup_sep, T, 1);
if ( length(parts) > 1 )
{
parts[2] = "/";
dir = cat_string_array(parts);
return compress_path(dir);
}
const multislash_sep = /(\/){2,}/;
parts = split_all(dir, multislash_sep);
for ( i in parts )
if ( i % 2 == 0 )
parts[i] = "/";
dir = cat_string_array(parts);
return dir;
}
# Computes the absolute path with cwd (current working directory).
function absolute_path(session: SessionInfo, file_name: string): string
{
local abs_file_name: string;
if ( file_name == ftp_absolute_path_pat ) # start with '/' or 'A:\'
abs_file_name = file_name;
else
abs_file_name = string_cat(session$cwd, "/", file_name);
return compress_path(abs_file_name);
}
event ftp_unexpected_conn(id: conn_id, orig: addr, expected: addr)
{
if ( orig in skip_unexpected || expected in skip_unexpected ||
mask_addr(orig, 24) in skip_unexpected_net ||
mask_addr(expected, 24) in skip_unexpected_net )
; # don't bother reporting
else if ( mask_addr(orig, 24) == mask_addr(expected, 24) )
; # close enough, probably multi-homed
else if ( mask_addr(orig, 16) == mask_addr(expected, 16) )
; # ditto
#else
# Notice::NOTICE([$note=FTP_UnexpectedConn, $id=id,
# $msg=fmt("%s > %s FTP connection from %s",
# id$orig_h, id$resp_h, orig)]);
}
event expected_connection_seen(c: connection, a: count)
{
local id = c$id;
if ( [id$resp_h, id$resp_p] in ftp_data_expected )
{
add c$service["ftp-data"];
delete ftp_data_expected[id$resp_h, id$resp_p];
}
}
function new_ftp_session(c: connection, add_init: bool)
function new_ftp_session(c: connection)
{
local id = c$id;
@ -301,51 +123,73 @@ function new_ftp_session(c: connection, add_init: bool)
info$id = id;
local cmds: table[count] of CmdArg = table();
info$pending_commands = cmds;
if ( add_init )
add_pending_cmd(info$pending_commands, "<init>", "");
ftp_sessions[id] = info;
#append_addl(c, fmt("#%s", prefixed_id(new_id)));
# Add a shim command so the server can respond with some init response.
add_pending_cmd(info$pending_commands, "<init>", "");
#print log_file, fmt("%.6f #%s %s start", c$start_time, prefixed_id(new_id),
# id_string(session));
active_conns[id] = info;
}
function ftp_message(s: SessionInfo)
{
if ( !s$log_it ) return;
if ( s$log_it || s$command$cmd in logged_commands )
{
local pass = "\\N";
if ( to_lower(s$user) in guest_ids && s?$password )
pass = s$password;
local pass = "";
if ( s$user in guest_ids && s?$password )
pass = s$password;
local pathfile = sub(absolute_path(s, s$command$arg), /<unknown>/, "/.");
local arg = s$command$arg;
if ( s$command$cmd in file_cmds )
{
local pathfile = sub(absolute_path(s$cwd, arg), /<unknown>/, "/.");
arg = fmt("ftp://%s%s", s$id$resp_h, pathfile);
}
Log::write("FTP", [$ts=network_time(), $id=s$id,
$user=s$user, $password=pass,
$command=s$command$cmd, $arg=arg,
$mime_type=s$mime_type, $mime_desc=s$mime_desc,
$file_size=s$file_size,
$reply_code=s$reply_code,
$reply_msg=s$reply_msg]);
}
# The MIME and file_size fields are specific to file transfer commands
# and may not be used in all commands so they need reset to "blank"
# values after logging.
# TODO: change these to blank or remove the field when moving to the new
# logging framework
s$mime_type="\\N";
s$mime_desc="\\N";
s$file_size=0;
if ( s$command$cmd in ftp_file_cmds )
Log::write("FTP_FILES", [$ts=network_time(), $id=s$id,
$user=s$user, $password=pass,
$command=s$command$cmd,
$url=fmt("ftp://%s%s", s$id$resp_h, pathfile),
$mime_type="", $mime_desc="",
$reply_code=s$reply_code, $reply_msg=s$reply_msg]);
s$log_it = F;
s$log_it=F;
}
event ftp_request(c: connection, command: string, arg: string)
{
local id = c$id;
# TODO: find out if this issue is fixed with DPD
# Command may contain garbage, e.g. if we're parsing something
# which isn't ftp. Ignore this.
if ( is_string_binary(command) )
return;
#if ( is_string_binary(command) ) return;
if ( id !in ftp_sessions )
new_ftp_session(c, F);
local session = ftp_sessions[id];
local id = c$id;
if ( id !in active_conns )
new_ftp_session(c);
local session = active_conns[id];
# Queue up the command and argument
# Log the previous command when a new command is seen.
# The downside here is that commands definitely aren't logged until the
# next command is issued or the control session ends. In practicality
# this isn't an issue, but I suppose it could be a delay tactic for
# attackers.
if ( session?$command && session$has_response )
{
remove_pending_cmd(session$pending_commands, session$command);
ftp_message(session);
session$has_response=F;
}
# Queue up the new command and argument
add_pending_cmd(session$pending_commands, command, arg);
if ( command == "USER" )
@ -354,22 +198,6 @@ event ftp_request(c: connection, command: string, arg: string)
else if ( command == "PASS" )
session$password = arg;
else if ( command in ftp_file_cmds )
{
if ( |arg| >= excessive_filename_len )
{
arg = fmt("%s..[%d]..",
sub_bytes(arg, 1, excessive_filename_trunc_len), |arg|);
#Notice::NOTICE([$note=FTP_ExcessiveFilename, $id=session$id,
# #$user=session$user, $filename=arg,
# $msg=fmt("%s excessive filename: %s",
# id_string(session$id), arg)]);
}
}
else if ( command == "ACCT" )
append_addl(c, fmt("(account %s)", arg));
else if ( command == "PORT" || command == "EPRT" )
{
local data = (command == "PORT") ?
@ -377,260 +205,145 @@ event ftp_request(c: connection, command: string, arg: string)
if ( data$valid )
{
#if ( data$h != id$orig_h )
# ftp_message(id, fmt("*> PORT host %s doesn't match originator host %s", data$h, id$orig_h));
#if ( data$p < 1024/tcp && data$p in port_names )
# Notice::NOTICE([$note=FTP_PrivPort, $id=id,
# $user=session$user,
# $msg=fmt("%s privileged PORT %d: %s",
# id_string(id),data$p, arg),
# $sub="PORT"]);
local expected = [$host=c$id$resp_h, $session=session];
ftp_data_expected[data$h, data$p] = expected;
expect_connection(c$id$resp_h, data$h, data$p,
ANALYZER_FILE, 5 min);
print data;
expect_connection(id$resp_h, data$h, data$p, ANALYZER_FILE, 5mins);
}
else
{
# TODO: raise a notice? does anyone care?
}
#else if ( arg != ignore_invalid_PORT )
# Notice::NOTICE([$note=FTP_BadPort, $id=id,
# #$user=session$user,
# $msg=fmt("%s invalid ftp PORT directive: %s",
# id_string(id), arg),
# $sub="PORT"]);
}
#if ( command in hot_cmds && arg == hot_cmds[command] )
# {
# session$log_it = T;
# TODO: generate a notice instead of terminating here.
# Special hack for "site exec" attacks.
### Obviously, this should be generic and not specialized
### like the following.
#if ( command == "SITE" && /[Ee][Xx][Ee][Cc]/ in arg &&
# # We see legit use of "site exec cp / /", God knows why.
# |arg| > 32 )
# { # Terminate with extreme prejudice.
# TerminateConnection::terminate_connection(c);
# Notice::NOTICE([$note=FTP_SiteExecAttack, $conn=c, $conn=c,
# $msg=fmt("%s %s", command, arg)]);
# }
# }
}
event ftp_binary_response(session: SessionInfo, code: count, msg: string)
{
#print log_file, fmt("%.6f #%s binary response",
# network_time(), prefixed_id(session$id));
}
function extract_dir_from_reply(session: SessionInfo, msg: string,
hint: string): string
{
const dir_pattern = /\"([^\"]|\"\")*(\/|\\)([^\"]|\"\")*\"/;
local parts = split_all(msg, dir_pattern);
if ( |parts| != 3 )
{ # not found or ambiguous
# print log_file, fmt("%.6f #%s cannot extract directory: \"%s\"",
# network_time(), prefixed_id(session$id), msg);
return hint;
if ( command == "SITE" && /[Ee][Xx][Ee][Cc]/ in arg )
{
Notice::NOTICE([$note=FTP_SiteExec, $conn=c,
$msg=fmt("%s %s", command, arg)]);
}
local d = parts[2];
return sub_bytes(d, 2, int_to_count(|d| - 2));
}
function do_ftp_login(c: connection, session: SessionInfo)
{
#event login_successful(c, session$user);
}
event ftp_reply(c: connection, code: count, msg: string, cont_resp: bool)
{
# Not sure how to handle multiline responses yet.
# TODO: figure out what to do with continued FTP response
if ( cont_resp ) return;
local id = c$id;
if ( id !in ftp_sessions )
new_ftp_session(c, T);
local session = ftp_sessions[id];
if ( id !in active_conns )
new_ftp_session(c);
local session = active_conns[id];
session$command = get_pending_cmd(session$pending_commands, code, msg);
session$reply_code = code;
session$reply_msg = msg;
session$has_response = T;
local cmd_arg = get_pending_cmd(session$pending_commands, code, msg);
local response_xyz = parse_ftp_reply_code(code);
# TODO: do some sort of generic clear text login processing here.
#local response_xyz = parse_ftp_reply_code(code);
#if ( response_xyz$x == 2 && # successful
# (cmd_arg$cmd == /USER|PASS|ACCT/) )
# session$command$cmd == "PASS" )
# do_ftp_login(c, session);
# skip password prompt, which we can get when the requests are stacked up
if ( code != 530 && code != 331 )
session$log_it = T;
if ( code == 150 && session$command$cmd == "RETR" )
{
local parts = split_all(msg, /\([0-9]+[[:blank:]]+/);
if ( |parts| >= 3 )
session$file_size = to_count(gsub(parts[2], /[^0-9]/, ""));
}
else if ( code == 213 && session$command$cmd == "SIZE" )
{
# NOTE: this isn't exactly the right thing to do here since the size
# on a different file could be checked, but the file size will
# be overwritten by the server response to the RETR command
# if that's given as well which would be more correct.
session$file_size = to_count(msg);
}
if ( code == 227 || code == 229 )
# PASV and EPSV processing
if ( (code == 227 || code == 229) &&
(session$command$cmd == "PASV" || session$command$cmd == "EPSV") )
{
local data = (code == 227) ? parse_ftp_pasv(msg) : parse_ftp_epsv(msg);
if ( code == 229 && data$h == 0.0.0.0 )
data$h = id$resp_h;
if ( data$valid )
{
#if ( data$h != id$resp_h )
# ftp_message(id, fmt("*< PASV host %s doesn't match responder host %s", data$h, id$resp_h));
#if ( data$p < 1024/tcp &&
# data$p !in ignore_privileged_PASVs )
# Notice::NOTICE([$note=FTP_PrivPort, $id=id,
# $msg=fmt("%s privileged PASV %d: %s",
# id_string(id), data$p, msg),
# $n=code, $sub="PASV"]);
if ( code == 229 && data$h == 0.0.0.0 )
data$h = id$resp_h;
local expected = [$host=id$orig_h, $session=session];
ftp_data_expected[data$h, data$p] = expected;
expect_connection(id$orig_h, data$h, data$p, ANALYZER_FILE, 5 min);
msg = fmt("%s %d", data$h, data$p);
expect_connection(id$orig_h, data$h, data$p, ANALYZER_FILE, 5mins);
}
else if ( msg != ignore_invalid_PORT )
{
#Notice::NOTICE([$note=FTP_BadPort, $id=id,
# $msg=fmt("%s invalid ftp PASV directive: %s",
# id_string(id), msg),
# $sub="PASV", $n=code]);
msg = "invalid PASV";
}
}
if ( [cmd_arg$cmd, code] in ftp_dir_operation )
{
local cwd: string;
if ( cmd_arg$cmd == "CWD" )
{
if ( cmd_arg$arg == ftp_absolute_path_pat ) # absolute dir
cwd = cmd_arg$arg;
else
cwd = cat(session$cwd, "/", cmd_arg$arg);
}
else if ( cmd_arg$cmd == "CDUP" )
cwd = cat(session$cwd, "/..");
else if ( cmd_arg$cmd == "PWD" || cmd_arg$cmd == "XPWD" )
# Here we need to guess how to extract the
# directory from the reply.
cwd = extract_dir_from_reply(session, msg, session$cwd);
session$cwd = cwd;
}
if ( cmd_pending(session) )
{
if ( code in ftp_skip_replies )
; # Don't flush request yet.
else
{
local reply = code in ftp_replies ? ftp_replies[code] :
fmt("%d %s", code, msg);
#local session_msg = fmt("%s%s (%s)",
# |session$pending_commands| > 1 ? "*" : "",
# session$command, reply);
#
#if ( session$log_it )
# Notice::NOTICE([$note=FTP_Sensitive, $id=id, $n=code,
# $msg=fmt("ftp: %s %s",
# id_string(id), session_msg)]);
#ftp_message(id, "whatever");
#session$command = "";
# TODO: do something if there was a problem parsing the PASV message?
}
}
else
if ( [session$command$cmd, code] in directory_cmds )
{
# An unpaired response. This can happen in particular
# when the session is encrypted, so we check for that here.
if ( /[\x80-\xff]{3}/ in msg )
# Three 8-bit characters in a row - good enough.
# Note, this should of course be customizable.
event ftp_binary_response(session, code, msg);
if ( session$command$cmd == "CWD" )
session$cwd = build_full_path(session$cwd, session$command$arg);
else
print fmt("Saw an unpaired response %d %s", code, msg);
#print log_file, fmt("%.6f #%s response (%d %s)",
# network_time(), prefixed_id(session$id), code, msg);
else if ( session$command$cmd == "CDUP" )
session$cwd = cat(session$cwd, "/..");
else if ( session$command$cmd == "PWD" || session$command$cmd == "XPWD" )
session$cwd = extract_directory(msg);
}
#if ( cmd_pending(session) )
# {
# if ( response_xyz$x == 1 )
# # nothing
# ;
#
# else if ( response_xyz$x >= 2 && response_xyz$x <= 5 )
# {
# remove_pending_cmd(session$pending_commands, cmd_arg);
# # print log_file, fmt("*** DEBUG *** %.06f #%d: [%s %s] [%d %s]",
# # network_time(), session$id, cmd_arg$cmd, cmd_arg$arg, code, msg);
# }
# }
session$command = pop_pending_cmd(session$pending_commands, code, msg);
# Go ahead and log for the oldest command.
ftp_message(session);
#else if ( code != 421 ) # closing connection
# ftp_message(id, fmt("spontaneous response (%d %s)", code, msg));
# In case there are multiple commands queued, go ahead and remove the
# command here and log because we can't do the normal processing pipeline
# to wait for a new command before logging the command/response pair.
if ( |session$pending_commands| > 1 )
{
remove_pending_cmd(session$pending_commands, session$command);
ftp_message(session);
}
}
# Use state remove event instead of finish to cover connections terminated by
# RST.
# Use state remove event to cover connections terminated by RST.
event connection_state_remove(c: connection)
{
local id = c$id;
if ( id !in ftp_sessions )
return;
if ( id !in active_conns ) return;
local session = active_conns[id];
local session = ftp_sessions[id];
if ( cmd_pending(session) )
# NOTE: Only dealing with a single pending command here.
# Extra pending commands are ignored for now.
if ( |session$pending_commands| > 0 )
{
#local msg = fmt("%s%s (no reply)",
# |session$pending_commands| > 1 ? "*" : "",
# session$command);
#
#if ( session$log_it )
# Notice::NOTICE([$note=FTP_Sensitive, $id=id,
# $msg=fmt("ftp: %s %s", id_string(id), msg)]);
local ca = get_pending_cmd(session$pending_commands, 0, "<finish>");
pop_pending_cmd(session$pending_commands, 0, "<finish>");
ftp_message(session);
}
#ftp_message(id, "finish");
delete ftp_sessions[id];
delete active_conns[id];
}
event expected_connection_seen(c: connection, a: count)
{
local id = c$id;
if ( [id$resp_h, id$resp_p] in ftp_data_expected )
add c$service["ftp-data"];
}
event file_transferred(c: connection, prefix: string, descr: string,
mime_type: string)
{
if ( [c$id$resp_h, c$id$resp_p] in ftp_data_expected )
print "saw a file transfer";
local id = c$id;
if ( [id$resp_h, id$resp_p] in ftp_data_expected )
{
local expected = ftp_data_expected[c$id$resp_h, c$id$resp_p];
print fmt("%.6f ftp-data %s '%s'",
c$start_time,
mime_type, descr);
#append_addl(c, descr);
local expected = ftp_data_expected[id$resp_h, id$resp_p];
local s = expected$session;
s$mime_type = mime_type;
s$mime_desc = descr;
# TODO: not sure if it's ok to delete this here, but it should
# always be called since the file analyzer is always attached
# to ftp-data sessions.
delete ftp_data_expected[id$resp_h, id$resp_p];
}
}

View file

@ -8,6 +8,63 @@ function is_string_binary(s: string): bool
return byte_len(gsub(s, /[\x00-\x7f]/, "")) * 100 / |s| >= 25;
}
# Given an arbitrary string, this should extract a single directory.
# TODO: Make this work on Window's style directories.
# NOTE: This does nothing to remove a filename if that's included.
function extract_directory(input: string): string
{
const dir_pattern = /\"([^\"]|\"\")*(\/|\\)([^\"]|\"\")*\"/;
local parts = split_all(input, dir_pattern);
# This basically indicates no identifiable directory was found.
if ( |parts| < 3 )
return "";
local d = parts[2];
return sub_bytes(d, 2, int_to_count(|d| - 2));
}
# Process ..'s and eliminate duplicate '/'s
# Deficiency: gives wrong results when a symbolic link is followed by ".."
function compress_path(dir: string): string
{
const cdup_sep = /((\/)+([^\/]|\\\/)+)?((\/)+\.\.(\/)+)/;
local parts = split_n(dir, cdup_sep, T, 1);
if ( length(parts) > 1 )
{
parts[2] = "/";
dir = cat_string_array(parts);
return compress_path(dir);
}
const multislash_sep = /(\/){2,}/;
parts = split_all(dir, multislash_sep);
for ( i in parts )
if ( i % 2 == 0 )
parts[i] = "/";
dir = cat_string_array(parts);
return dir;
}
const absolute_path_pat = /(\/|[A-Za-z]:[\\\/]).*/;
# Computes the absolute path with cwd (current working directory).
function absolute_path(cwd: string, file_name: string): string
{
local abs_file_name: string;
if ( file_name == absolute_path_pat ) # start with '/' or 'A:\'
abs_file_name = file_name;
else
abs_file_name = string_cat(cwd, "/", file_name);
return compress_path(abs_file_name);
}
function build_full_path(cwd: string, file_name: string): string
{
return (file_name == absolute_path_pat) ?
file_name : cat(cwd, "/", file_name);
}
# Simple functions for generating ASCII connection identifiers.
############# BEGIN ID FORMATTING #############
@ -34,14 +91,14 @@ function directed_id_string(id: conn_id, is_orig: bool): string
############# BEGIN THRESHOLD CHECKING #############
type track_count: record {
type TrackCount: record {
n: count &default=0;
index: count &default=0;
};
function default_track_count(a: addr): track_count
function default_track_count(a: addr): TrackCount
{
local x: track_count;
local x: TrackCount;
return x;
}
@ -51,7 +108,7 @@ const default_notice_thresholds: vector of count = {
# This is total rip off from scan.bro, but placed in the global namespace
# and slightly reworked to be easier to work with and more general.
function check_threshold(v: vector of count, tracker: track_count): bool
function check_threshold(v: vector of count, tracker: TrackCount): bool
{
if ( tracker$index <= |v| && tracker$n >= v[tracker$index] )
{
@ -61,7 +118,7 @@ function check_threshold(v: vector of count, tracker: track_count): bool
return F;
}
function default_check_threshold(tracker: track_count): bool
function default_check_threshold(tracker: TrackCount): bool
{
return check_threshold(default_notice_thresholds, tracker);
}