From: Jeff Lucovsky Date: Sat, 29 Mar 2025 15:01:17 +0000 (-0400) Subject: detect/ftp: Add ftp.completion_code keyword X-Git-Tag: suricata-8.0.0-rc1~253 X-Git-Url: http://git.ipfire.org/gitweb/gitweb.cgi?a=commitdiff_plain;h=f8575dab50e0cfde1cec2d1231be7cdf99df2fb1;p=thirdparty%2Fsuricata.git 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. --- diff --git a/rust/src/ftp/response.rs b/rust/src/ftp/response.rs index 03729ec1fd..098fe85318 100644 --- a/rust/src/ftp/response.rs +++ b/rust/src/ftp/response.rs @@ -15,18 +15,18 @@ * 02110-1301, USA. */ -use std::ffi::CString; use std::os::raw::c_char; use std::ptr; use std::slice; #[repr(C)] pub struct FTPResponseLine { - code: *mut u8, // Response code as a string (may be null) - response: *mut u8, // Response string - length: usize, // Length of the response string - truncated: bool, // Uses TX/state value. - total_size: usize, // Total allocated size in bytes + code: *mut u8, // Response code as a string (may be null) + response: *mut u8, // Response string + length: usize, // Length of the response string + code_length: usize, // Length of the response code string + truncated: bool, // Uses TX/state value. + total_size: usize, // Total allocated size in bytes } /// Parses a single FTP response line and returns an FTPResponseLine struct. @@ -34,38 +34,34 @@ pub struct FTPResponseLine { /// - (single response) "530 Login incorrect" /// - (single response, no code) "Login incorrect" fn parse_response_line(input: &str) -> Option { - // Find the first complete response line (delimited by `\r\n`) - let mut split = input.splitn(2, "\r\n"); - let response_line = split.next().unwrap_or("").trim_end(); + // Split the input on the first \r\n to get the response line + let response_line = input.split("\r\n").next().unwrap_or("").trim_end(); if response_line.is_empty() { - return None; // Ignore empty input + return None; } - // Extract response code as a string - let mut parts = response_line.splitn(2, ' '); - let (code, response) = match (parts.next(), parts.next()) { - (Some(num_str), Some(rest)) - if num_str.len() == 3 && num_str.chars().all(|c| c.is_ascii_digit()) => - { - (num_str.to_string(), rest) + // Try to split off the 3-digit FTP status code + let (code_str, response_str) = match response_line.split_once(' ') { + Some((prefix, rest)) if prefix.len() == 3 && prefix.chars().all(|c| c.is_ascii_digit()) => { + (prefix, rest) } - _ => ("".to_string(), response_line), // No valid numeric code found + _ => ("", response_line), }; - // Convert response and code to C strings - let c_code = CString::new(code).ok()?; - let c_response = CString::new(response).ok()?; + let code_bytes = code_str.as_bytes().to_vec(); + let response_bytes = response_str.as_bytes().to_vec(); - // Compute memory usage - let total_size = std::mem::size_of::() - + c_code.as_bytes_with_nul().len() - + c_response.as_bytes_with_nul().len(); + let code_len = code_bytes.len(); + let response_len = response_bytes.len(); + + let total_size = std::mem::size_of::() + code_len + response_len; Some(FTPResponseLine { - code: c_code.into_raw() as *mut u8, - response: c_response.into_raw() as *mut u8, - length: response.len(), + code: Box::into_raw(code_bytes.into_boxed_slice()) as *mut u8, + response: Box::into_raw(response_bytes.into_boxed_slice()) as *mut u8, + length: response_len, + code_length: code_len, truncated: false, total_size, }) @@ -99,30 +95,36 @@ pub unsafe extern "C" fn SCFTPFreeResponseLine(response: *mut FTPResponseLine) { let response = Box::from_raw(response); - if !response.code.is_null() { - let _ = CString::from_raw(response.code as *mut c_char); + if !response.response.is_null() { + let _ = Vec::from_raw_parts( + response.code, + response.code_length, + response.code_length, + ); } 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)] mod tests { use super::*; - use std::ffi::CStr; #[test] fn test_parse_valid_response() { let input = "220 Welcome to FTP\r\n"; let parsed = parse_response_line(input).unwrap(); - let code_cstr = unsafe { CStr::from_ptr(parsed.code as *mut c_char) }; - let code_str = code_cstr.to_str().unwrap(); + let code_slice = unsafe { slice::from_raw_parts(parsed.code, parsed.code_length) }; + let code_str = std::str::from_utf8(code_slice).expect("Invalid UTF-8"); assert_eq!(code_str, "220"); - let response_cstr = unsafe { CStr::from_ptr(parsed.response as *mut c_char) }; - let response_str = response_cstr.to_str().unwrap(); + let response_slice = unsafe { slice::from_raw_parts(parsed.response, parsed.length) }; + let response_str = std::str::from_utf8(response_slice).expect("Invalid UTF-8"); assert_eq!(response_str, "Welcome to FTP"); assert_eq!(parsed.length, "Welcome to FTP".len()); } @@ -132,12 +134,12 @@ mod tests { let input = "Some random text\r\n"; let parsed = parse_response_line(input).unwrap(); - let code_cstr = unsafe { CStr::from_ptr(parsed.code as *mut c_char) }; - let code_str = code_cstr.to_str().unwrap(); + let code_slice = unsafe { slice::from_raw_parts(parsed.code, parsed.code_length) }; + let code_str = std::str::from_utf8(code_slice).expect("Invalid UTF-8"); assert_eq!(code_str, ""); - let response_cstr = unsafe { CStr::from_ptr(parsed.response as *mut c_char) }; - let response_str = response_cstr.to_str().unwrap(); + let response_slice = unsafe { slice::from_raw_parts(parsed.response, parsed.length) }; + let response_str = std::str::from_utf8(response_slice).expect("Invalid UTF-8"); assert_eq!(response_str, "Some random text"); assert_eq!(parsed.length, "Some random text".len()); } @@ -147,11 +149,11 @@ mod tests { let input = "331 Password required \r\n"; let parsed = parse_response_line(input).unwrap(); - let code_cstr = unsafe { CStr::from_ptr(parsed.code as *mut c_char) }; - let code_str = code_cstr.to_str().unwrap(); + let code_slice = unsafe { slice::from_raw_parts(parsed.code, parsed.code_length) }; + let code_str = std::str::from_utf8(code_slice).expect("Invalid UTF-8"); assert_eq!(code_str, "331"); - let response_cstr = unsafe { CStr::from_ptr(parsed.response as *mut c_char) }; - let response_str = response_cstr.to_str().unwrap(); + let response_slice = unsafe { slice::from_raw_parts(parsed.response, parsed.length) }; + let response_str = std::str::from_utf8(response_slice).expect("Invalid UTF-8"); assert_eq!(response_str, " Password required"); assert_eq!(parsed.length, " Password required".len()); } @@ -161,11 +163,11 @@ mod tests { let input = "220 Hello FTP Server\n"; let parsed = parse_response_line(input).unwrap(); - let code_cstr = unsafe { CStr::from_ptr(parsed.code as *mut c_char) }; - let code_str = code_cstr.to_str().unwrap(); + let code_slice = unsafe { slice::from_raw_parts(parsed.code, parsed.code_length) }; + let code_str = std::str::from_utf8(code_slice).expect("Invalid UTF-8"); assert_eq!(code_str, "220"); - let response_cstr = unsafe { CStr::from_ptr(parsed.response as *mut c_char) }; - let response_str = response_cstr.to_str().unwrap(); + let response_slice = unsafe { slice::from_raw_parts(parsed.response, parsed.length) }; + let response_str = std::str::from_utf8(response_slice).expect("Invalid UTF-8"); assert_eq!(response_str, "Hello FTP Server"); assert_eq!(parsed.length, "Hello FTP Server".len()); } @@ -187,11 +189,11 @@ mod tests { let input = "99 Incorrect code\r\n"; let parsed = parse_response_line(input).unwrap(); - let code_cstr = unsafe { CStr::from_ptr(parsed.code as *mut c_char) }; - let code_str = code_cstr.to_str().unwrap(); + let code_slice = unsafe { slice::from_raw_parts(parsed.code, parsed.code_length) }; + let code_str = std::str::from_utf8(code_slice).expect("Invalid UTF-8"); assert_eq!(code_str, ""); - let response_cstr = unsafe { CStr::from_ptr(parsed.response as *mut c_char) }; - let response_str = response_cstr.to_str().unwrap(); + let response_slice = unsafe { slice::from_raw_parts(parsed.response, parsed.length) }; + let response_str = std::str::from_utf8(response_slice).expect("Invalid UTF-8"); assert_eq!(response_str, "99 Incorrect code"); assert_eq!(parsed.length, "99 Incorrect code".len()); } @@ -201,12 +203,12 @@ mod tests { let input = "500 '🌍 ABOR': unknown command\r\n"; let parsed = parse_response_line(input).unwrap(); - let code_cstr = unsafe { CStr::from_ptr(parsed.code as *mut c_char) }; - let code_str = code_cstr.to_str().unwrap(); + let code_slice = unsafe { slice::from_raw_parts(parsed.code, parsed.code_length) }; + let code_str = std::str::from_utf8(code_slice).expect("Invalid UTF-8"); assert_eq!(code_str, "500"); - let response_cstr = unsafe { CStr::from_ptr(parsed.response as *mut c_char) }; - let response_str = response_cstr.to_str().unwrap(); + let response_slice = unsafe { slice::from_raw_parts(parsed.response, parsed.length) }; + let response_str = std::str::from_utf8(response_slice).expect("Invalid UTF-8"); assert_eq!(response_str, "'🌍 ABOR': unknown command"); assert_eq!(parsed.length, "'🌍 ABOR': unknown command".len()); } diff --git a/src/Makefile.am b/src/Makefile.am index 81fbfcf38c..94c1bed87c 100755 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -177,6 +177,7 @@ noinst_HEADERS = \ detect-ftp-command-data.h \ detect-ftp-command.h \ detect-ftp-dynamic-port.h \ + detect-ftp-completion-code.h \ detect-ftp-reply.h \ detect-ftpbounce.h \ detect-ftpdata.h \ @@ -769,6 +770,7 @@ libsuricata_c_a_SOURCES = \ detect-ftp-command-data.c \ detect-ftp-command.c \ detect-ftp-dynamic-port.c \ + detect-ftp-completion-code.c \ detect-ftp-reply.c \ detect-ftpbounce.c \ detect-ftpdata.c \ diff --git a/src/detect-engine-register.c b/src/detect-engine-register.c index 1fc07061e9..58370a6dfa 100644 --- a/src/detect-engine-register.c +++ b/src/detect-engine-register.c @@ -212,6 +212,7 @@ #include "detect-ftp-command.h" #include "detect-entropy.h" #include "detect-ftp-command-data.h" +#include "detect-ftp-completion-code.h" #include "detect-ftp-reply.h" #include "detect-ftp-mode.h" #include "detect-ftp-reply-received.h" @@ -728,6 +729,7 @@ void SigTableSetup(void) DetectJa4HashRegister(); DetectFtpCommandRegister(); DetectFtpCommandDataRegister(); + DetectFtpCompletionCodeRegister(); DetectFtpReplyRegister(); DetectFtpModeRegister(); DetectFtpReplyReceivedRegister(); diff --git a/src/detect-engine-register.h b/src/detect-engine-register.h index dcb4589259..d2f459f946 100644 --- a/src/detect-engine-register.h +++ b/src/detect-engine-register.h @@ -334,6 +334,7 @@ enum DetectKeywordId { DETECT_FTP_REPLY, DETECT_FTP_MODE, DETECT_FTP_REPLY_RECEIVED, + DETECT_FTP_COMPLETION_CODE, DETECT_VLAN_ID, DETECT_VLAN_LAYERS, diff --git a/src/detect-ftp-completion-code.c b/src/detect-ftp-completion-code.c new file mode 100644 index 0000000000..ed457a1bb9 --- /dev/null +++ b/src/detect-ftp-completion-code.c @@ -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 + * + * 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"); +} diff --git a/src/detect-ftp-completion-code.h b/src/detect-ftp-completion-code.h new file mode 100644 index 0000000000..50eb528b31 --- /dev/null +++ b/src/detect-ftp-completion-code.h @@ -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 + */ + +#ifndef SURICATA_DETECT_FTP_COMPLETION_CODE_H +#define SURICATA_DETECT_FTP_COMPLETION_CODE_H + +void DetectFtpCompletionCodeRegister(void); + +#endif /* SURICATA_DETECT_FTP_COMPLETION_CODE_H */ diff --git a/src/output-json-ftp.c b/src/output-json-ftp.c index e41d506f8e..839db2c9d5 100644 --- a/src/output-json-ftp.c +++ b/src/output-json-ftp.c @@ -99,13 +99,13 @@ bool EveFTPLogCommand(void *vtx, SCJsonBuilder *jb) if (!reply_truncated && response->truncated) { reply_truncated = true; } - uint32_t code_len = (uint32_t)strlen((const char *)response->code); - if (code_len > 0) { + if (response->code_length > 0) { if (!is_cc_array_open) { SCJbOpenArray(jb, "completion_code"); 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) { SCJbAppendStringFromBytes(js_resplist, (const uint8_t *)response->response,