detect/ftp: Add ftp.completion_code keyword

Issue: 7507

Implement the ftp.completion_code sticky buffer. Multi-buffer as an FTP
command can produce multiple responses.

E.g., with the FTP command RETR
    RETR temp.txt
    150 Opening BINARY mode data connection for temp.txt (1164 bytes).
    226 Transfer complete.
pull/13262/head
Jeff Lucovsky 5 months ago committed by Victor Julien
parent 31a395c734
commit f8575dab50

@ -15,7 +15,6 @@
* 02110-1301, USA. * 02110-1301, USA.
*/ */
use std::ffi::CString;
use std::os::raw::c_char; use std::os::raw::c_char;
use std::ptr; use std::ptr;
use std::slice; use std::slice;
@ -25,6 +24,7 @@ pub struct FTPResponseLine {
code: *mut u8, // Response code as a string (may be null) code: *mut u8, // Response code as a string (may be null)
response: *mut u8, // Response string response: *mut u8, // Response string
length: usize, // Length of the response string length: usize, // Length of the response string
code_length: usize, // Length of the response code string
truncated: bool, // Uses TX/state value. truncated: bool, // Uses TX/state value.
total_size: usize, // Total allocated size in bytes total_size: usize, // Total allocated size in bytes
} }
@ -34,38 +34,34 @@ pub struct FTPResponseLine {
/// - (single response) "530 Login incorrect" /// - (single response) "530 Login incorrect"
/// - (single response, no code) "Login incorrect" /// - (single response, no code) "Login incorrect"
fn parse_response_line(input: &str) -> Option<FTPResponseLine> { fn parse_response_line(input: &str) -> Option<FTPResponseLine> {
// Find the first complete response line (delimited by `\r\n`) // Split the input on the first \r\n to get the response line
let mut split = input.splitn(2, "\r\n"); let response_line = input.split("\r\n").next().unwrap_or("").trim_end();
let response_line = split.next().unwrap_or("").trim_end();
if response_line.is_empty() { if response_line.is_empty() {
return None; // Ignore empty input return None;
} }
// Extract response code as a string // Try to split off the 3-digit FTP status code
let mut parts = response_line.splitn(2, ' '); let (code_str, response_str) = match response_line.split_once(' ') {
let (code, response) = match (parts.next(), parts.next()) { Some((prefix, rest)) if prefix.len() == 3 && prefix.chars().all(|c| c.is_ascii_digit()) => {
(Some(num_str), Some(rest)) (prefix, rest)
if num_str.len() == 3 && num_str.chars().all(|c| c.is_ascii_digit()) =>
{
(num_str.to_string(), rest)
} }
_ => ("".to_string(), response_line), // No valid numeric code found _ => ("", response_line),
}; };
// Convert response and code to C strings let code_bytes = code_str.as_bytes().to_vec();
let c_code = CString::new(code).ok()?; let response_bytes = response_str.as_bytes().to_vec();
let c_response = CString::new(response).ok()?;
// Compute memory usage let code_len = code_bytes.len();
let total_size = std::mem::size_of::<FTPResponseLine>() let response_len = response_bytes.len();
+ c_code.as_bytes_with_nul().len()
+ c_response.as_bytes_with_nul().len(); let total_size = std::mem::size_of::<FTPResponseLine>() + code_len + response_len;
Some(FTPResponseLine { Some(FTPResponseLine {
code: c_code.into_raw() as *mut u8, code: Box::into_raw(code_bytes.into_boxed_slice()) as *mut u8,
response: c_response.into_raw() as *mut u8, response: Box::into_raw(response_bytes.into_boxed_slice()) as *mut u8,
length: response.len(), length: response_len,
code_length: code_len,
truncated: false, truncated: false,
total_size, total_size,
}) })
@ -99,30 +95,36 @@ pub unsafe extern "C" fn SCFTPFreeResponseLine(response: *mut FTPResponseLine) {
let response = Box::from_raw(response); let response = Box::from_raw(response);
if !response.code.is_null() { if !response.response.is_null() {
let _ = CString::from_raw(response.code as *mut c_char); let _ = Vec::from_raw_parts(
response.code,
response.code_length,
response.code_length,
);
} }
if !response.response.is_null() { if !response.response.is_null() {
let _ = CString::from_raw(response.response as *mut c_char); let _ = Box::from_raw(std::slice::from_raw_parts_mut(
response.response,
response.length,
));
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::ffi::CStr;
#[test] #[test]
fn test_parse_valid_response() { fn test_parse_valid_response() {
let input = "220 Welcome to FTP\r\n"; let input = "220 Welcome to FTP\r\n";
let parsed = parse_response_line(input).unwrap(); let parsed = parse_response_line(input).unwrap();
let code_cstr = unsafe { CStr::from_ptr(parsed.code as *mut c_char) }; let code_slice = unsafe { slice::from_raw_parts(parsed.code, parsed.code_length) };
let code_str = code_cstr.to_str().unwrap(); let code_str = std::str::from_utf8(code_slice).expect("Invalid UTF-8");
assert_eq!(code_str, "220"); assert_eq!(code_str, "220");
let response_cstr = unsafe { CStr::from_ptr(parsed.response as *mut c_char) }; let response_slice = unsafe { slice::from_raw_parts(parsed.response, parsed.length) };
let response_str = response_cstr.to_str().unwrap(); let response_str = std::str::from_utf8(response_slice).expect("Invalid UTF-8");
assert_eq!(response_str, "Welcome to FTP"); assert_eq!(response_str, "Welcome to FTP");
assert_eq!(parsed.length, "Welcome to FTP".len()); assert_eq!(parsed.length, "Welcome to FTP".len());
} }
@ -132,12 +134,12 @@ mod tests {
let input = "Some random text\r\n"; let input = "Some random text\r\n";
let parsed = parse_response_line(input).unwrap(); let parsed = parse_response_line(input).unwrap();
let code_cstr = unsafe { CStr::from_ptr(parsed.code as *mut c_char) }; let code_slice = unsafe { slice::from_raw_parts(parsed.code, parsed.code_length) };
let code_str = code_cstr.to_str().unwrap(); let code_str = std::str::from_utf8(code_slice).expect("Invalid UTF-8");
assert_eq!(code_str, ""); assert_eq!(code_str, "");
let response_cstr = unsafe { CStr::from_ptr(parsed.response as *mut c_char) }; let response_slice = unsafe { slice::from_raw_parts(parsed.response, parsed.length) };
let response_str = response_cstr.to_str().unwrap(); let response_str = std::str::from_utf8(response_slice).expect("Invalid UTF-8");
assert_eq!(response_str, "Some random text"); assert_eq!(response_str, "Some random text");
assert_eq!(parsed.length, "Some random text".len()); assert_eq!(parsed.length, "Some random text".len());
} }
@ -147,11 +149,11 @@ mod tests {
let input = "331 Password required \r\n"; let input = "331 Password required \r\n";
let parsed = parse_response_line(input).unwrap(); let parsed = parse_response_line(input).unwrap();
let code_cstr = unsafe { CStr::from_ptr(parsed.code as *mut c_char) }; let code_slice = unsafe { slice::from_raw_parts(parsed.code, parsed.code_length) };
let code_str = code_cstr.to_str().unwrap(); let code_str = std::str::from_utf8(code_slice).expect("Invalid UTF-8");
assert_eq!(code_str, "331"); assert_eq!(code_str, "331");
let response_cstr = unsafe { CStr::from_ptr(parsed.response as *mut c_char) }; let response_slice = unsafe { slice::from_raw_parts(parsed.response, parsed.length) };
let response_str = response_cstr.to_str().unwrap(); let response_str = std::str::from_utf8(response_slice).expect("Invalid UTF-8");
assert_eq!(response_str, " Password required"); assert_eq!(response_str, " Password required");
assert_eq!(parsed.length, " Password required".len()); assert_eq!(parsed.length, " Password required".len());
} }
@ -161,11 +163,11 @@ mod tests {
let input = "220 Hello FTP Server\n"; let input = "220 Hello FTP Server\n";
let parsed = parse_response_line(input).unwrap(); let parsed = parse_response_line(input).unwrap();
let code_cstr = unsafe { CStr::from_ptr(parsed.code as *mut c_char) }; let code_slice = unsafe { slice::from_raw_parts(parsed.code, parsed.code_length) };
let code_str = code_cstr.to_str().unwrap(); let code_str = std::str::from_utf8(code_slice).expect("Invalid UTF-8");
assert_eq!(code_str, "220"); assert_eq!(code_str, "220");
let response_cstr = unsafe { CStr::from_ptr(parsed.response as *mut c_char) }; let response_slice = unsafe { slice::from_raw_parts(parsed.response, parsed.length) };
let response_str = response_cstr.to_str().unwrap(); let response_str = std::str::from_utf8(response_slice).expect("Invalid UTF-8");
assert_eq!(response_str, "Hello FTP Server"); assert_eq!(response_str, "Hello FTP Server");
assert_eq!(parsed.length, "Hello FTP Server".len()); assert_eq!(parsed.length, "Hello FTP Server".len());
} }
@ -187,11 +189,11 @@ mod tests {
let input = "99 Incorrect code\r\n"; let input = "99 Incorrect code\r\n";
let parsed = parse_response_line(input).unwrap(); let parsed = parse_response_line(input).unwrap();
let code_cstr = unsafe { CStr::from_ptr(parsed.code as *mut c_char) }; let code_slice = unsafe { slice::from_raw_parts(parsed.code, parsed.code_length) };
let code_str = code_cstr.to_str().unwrap(); let code_str = std::str::from_utf8(code_slice).expect("Invalid UTF-8");
assert_eq!(code_str, ""); assert_eq!(code_str, "");
let response_cstr = unsafe { CStr::from_ptr(parsed.response as *mut c_char) }; let response_slice = unsafe { slice::from_raw_parts(parsed.response, parsed.length) };
let response_str = response_cstr.to_str().unwrap(); let response_str = std::str::from_utf8(response_slice).expect("Invalid UTF-8");
assert_eq!(response_str, "99 Incorrect code"); assert_eq!(response_str, "99 Incorrect code");
assert_eq!(parsed.length, "99 Incorrect code".len()); assert_eq!(parsed.length, "99 Incorrect code".len());
} }
@ -201,12 +203,12 @@ mod tests {
let input = "500 '🌍 ABOR': unknown command\r\n"; let input = "500 '🌍 ABOR': unknown command\r\n";
let parsed = parse_response_line(input).unwrap(); let parsed = parse_response_line(input).unwrap();
let code_cstr = unsafe { CStr::from_ptr(parsed.code as *mut c_char) }; let code_slice = unsafe { slice::from_raw_parts(parsed.code, parsed.code_length) };
let code_str = code_cstr.to_str().unwrap(); let code_str = std::str::from_utf8(code_slice).expect("Invalid UTF-8");
assert_eq!(code_str, "500"); assert_eq!(code_str, "500");
let response_cstr = unsafe { CStr::from_ptr(parsed.response as *mut c_char) }; let response_slice = unsafe { slice::from_raw_parts(parsed.response, parsed.length) };
let response_str = response_cstr.to_str().unwrap(); let response_str = std::str::from_utf8(response_slice).expect("Invalid UTF-8");
assert_eq!(response_str, "'🌍 ABOR': unknown command"); assert_eq!(response_str, "'🌍 ABOR': unknown command");
assert_eq!(parsed.length, "'🌍 ABOR': unknown command".len()); assert_eq!(parsed.length, "'🌍 ABOR': unknown command".len());
} }

@ -177,6 +177,7 @@ noinst_HEADERS = \
detect-ftp-command-data.h \ detect-ftp-command-data.h \
detect-ftp-command.h \ detect-ftp-command.h \
detect-ftp-dynamic-port.h \ detect-ftp-dynamic-port.h \
detect-ftp-completion-code.h \
detect-ftp-reply.h \ detect-ftp-reply.h \
detect-ftpbounce.h \ detect-ftpbounce.h \
detect-ftpdata.h \ detect-ftpdata.h \
@ -769,6 +770,7 @@ libsuricata_c_a_SOURCES = \
detect-ftp-command-data.c \ detect-ftp-command-data.c \
detect-ftp-command.c \ detect-ftp-command.c \
detect-ftp-dynamic-port.c \ detect-ftp-dynamic-port.c \
detect-ftp-completion-code.c \
detect-ftp-reply.c \ detect-ftp-reply.c \
detect-ftpbounce.c \ detect-ftpbounce.c \
detect-ftpdata.c \ detect-ftpdata.c \

@ -212,6 +212,7 @@
#include "detect-ftp-command.h" #include "detect-ftp-command.h"
#include "detect-entropy.h" #include "detect-entropy.h"
#include "detect-ftp-command-data.h" #include "detect-ftp-command-data.h"
#include "detect-ftp-completion-code.h"
#include "detect-ftp-reply.h" #include "detect-ftp-reply.h"
#include "detect-ftp-mode.h" #include "detect-ftp-mode.h"
#include "detect-ftp-reply-received.h" #include "detect-ftp-reply-received.h"
@ -728,6 +729,7 @@ void SigTableSetup(void)
DetectJa4HashRegister(); DetectJa4HashRegister();
DetectFtpCommandRegister(); DetectFtpCommandRegister();
DetectFtpCommandDataRegister(); DetectFtpCommandDataRegister();
DetectFtpCompletionCodeRegister();
DetectFtpReplyRegister(); DetectFtpReplyRegister();
DetectFtpModeRegister(); DetectFtpModeRegister();
DetectFtpReplyReceivedRegister(); DetectFtpReplyReceivedRegister();

@ -334,6 +334,7 @@ enum DetectKeywordId {
DETECT_FTP_REPLY, DETECT_FTP_REPLY,
DETECT_FTP_MODE, DETECT_FTP_MODE,
DETECT_FTP_REPLY_RECEIVED, DETECT_FTP_REPLY_RECEIVED,
DETECT_FTP_COMPLETION_CODE,
DETECT_VLAN_ID, DETECT_VLAN_ID,
DETECT_VLAN_LAYERS, DETECT_VLAN_LAYERS,

@ -0,0 +1,105 @@
/* Copyright (C) 2025 Open Information Security Foundation
*
* You can copy, redistribute or modify this Program under the terms of
* the GNU General Public License version 2 as published by the Free
* Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* version 2 along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*/
/**
*
* \author Jeff Lucovsky <jlucovsky@oisf.net>
*
* Implements the ftp.completion-code sticky buffer
*
*/
#include "suricata-common.h"
#include "detect.h"
#include "detect-parse.h"
#include "detect-engine.h"
#include "detect-engine-buffer.h"
#include "detect-engine-helper.h"
#include "detect-ftp-completion-code.h"
#include "app-layer.h"
#include "app-layer-ftp.h"
#include "flow.h"
#include "util-debug.h"
#define KEYWORD_NAME "ftp.completion_code"
#define KEYWORD_DOC "ftp-keywords.html#ftp-completion_code"
#define BUFFER_NAME "ftp.completion_code"
#define BUFFER_DESC "ftp completion code"
static int g_ftp_ccode_buffer_id = 0;
static int DetectFtpCompletionCodeSetup(DetectEngineCtx *de_ctx, Signature *s, const char *str)
{
if (SCDetectBufferSetActiveList(de_ctx, s, g_ftp_ccode_buffer_id) < 0)
return -1;
if (DetectSignatureSetAppProto(s, ALPROTO_FTP) < 0)
return -1;
return 0;
}
static bool DetectFTPCompletionCodeGetData(DetectEngineThreadCtx *_det_ctx, const void *txv,
uint8_t _flow_flags, uint32_t index, const uint8_t **buffer, uint32_t *buffer_len)
{
FTPTransaction *tx = (FTPTransaction *)txv;
if (tx->command_descriptor.command_code == FTP_COMMAND_UNKNOWN)
return false;
if (!TAILQ_EMPTY(&tx->response_list)) {
uint32_t count = 0;
FTPResponseWrapper *wrapper;
TAILQ_FOREACH (wrapper, &tx->response_list, next) {
DEBUG_VALIDATE_BUG_ON(wrapper->response == NULL);
if (index == count) {
*buffer = (const uint8_t *)wrapper->response->code;
*buffer_len = wrapper->response->code_length;
return true;
}
count++;
}
}
*buffer = NULL;
*buffer_len = 0;
return false;
}
void DetectFtpCompletionCodeRegister(void)
{
/* ftp.completion_code sticky buffer */
sigmatch_table[DETECT_FTP_COMPLETION_CODE].name = KEYWORD_NAME;
sigmatch_table[DETECT_FTP_COMPLETION_CODE].desc =
"sticky buffer to match on the FTP completion code buffer";
sigmatch_table[DETECT_FTP_COMPLETION_CODE].url = "/rules/" KEYWORD_DOC;
sigmatch_table[DETECT_FTP_COMPLETION_CODE].Setup = DetectFtpCompletionCodeSetup;
sigmatch_table[DETECT_FTP_COMPLETION_CODE].flags |= SIGMATCH_NOOPT;
DetectAppLayerMultiRegister(
BUFFER_NAME, ALPROTO_FTP, SIG_FLAG_TOCLIENT, 0, DetectFTPCompletionCodeGetData, 2);
DetectBufferTypeSetDescriptionByName(BUFFER_NAME, BUFFER_DESC);
g_ftp_ccode_buffer_id = DetectBufferTypeGetByName(BUFFER_NAME);
SCLogDebug("registering " BUFFER_NAME " rule option");
}

@ -0,0 +1,29 @@
/* Copyright (C) 2025 Open Information Security Foundation
*
* You can copy, redistribute or modify this Program under the terms of
* the GNU General Public License version 2 as published by the Free
* Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* version 2 along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*/
/**
* \file
*
* \author Jeff Lucovsky <jlucovsky@oisf.net>
*/
#ifndef SURICATA_DETECT_FTP_COMPLETION_CODE_H
#define SURICATA_DETECT_FTP_COMPLETION_CODE_H
void DetectFtpCompletionCodeRegister(void);
#endif /* SURICATA_DETECT_FTP_COMPLETION_CODE_H */

@ -99,13 +99,13 @@ bool EveFTPLogCommand(void *vtx, SCJsonBuilder *jb)
if (!reply_truncated && response->truncated) { if (!reply_truncated && response->truncated) {
reply_truncated = true; reply_truncated = true;
} }
uint32_t code_len = (uint32_t)strlen((const char *)response->code); if (response->code_length > 0) {
if (code_len > 0) {
if (!is_cc_array_open) { if (!is_cc_array_open) {
SCJbOpenArray(jb, "completion_code"); SCJbOpenArray(jb, "completion_code");
is_cc_array_open = true; is_cc_array_open = true;
} }
SCJbAppendStringFromBytes(jb, (const uint8_t *)response->code, code_len); SCJbAppendStringFromBytes(
jb, (const uint8_t *)response->code, response->code_length);
} }
if (response->length) { if (response->length) {
SCJbAppendStringFromBytes(js_resplist, (const uint8_t *)response->response, SCJbAppendStringFromBytes(js_resplist, (const uint8_t *)response->response,

Loading…
Cancel
Save