]> git.ipfire.org Git - thirdparty/suricata.git/commitdiff
detect/ftp: Add ftp.completion_code keyword
authorJeff Lucovsky <jlucovsky@oisf.net>
Sat, 29 Mar 2025 15:01:17 +0000 (11:01 -0400)
committerVictor Julien <victor@inliniac.net>
Mon, 19 May 2025 19:22:08 +0000 (21:22 +0200)
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.

rust/src/ftp/response.rs
src/Makefile.am
src/detect-engine-register.c
src/detect-engine-register.h
src/detect-ftp-completion-code.c [new file with mode: 0644]
src/detect-ftp-completion-code.h [new file with mode: 0644]
src/output-json-ftp.c

index 03729ec1fd75cd39653c15d19b8beef8e7aa0c09..098fe85318bb73e7f020e0c929b72d2884b68e7d 100644 (file)
  * 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<FTPResponseLine> {
-    // 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::<FTPResponseLine>()
-        + 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::<FTPResponseLine>() + 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());
     }
index 81fbfcf38cb9acab3351aafe6507f277347abf6d..94c1bed87ccd7591579b9ba675066e2245dfa85f 100755 (executable)
@@ -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 \
index 1fc07061e91c12c2c9603c5f66ac396b3378152e..58370a6dfa0a8712b957c6a7eb0adf66e6e2e1d0 100644 (file)
 #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();
index dcb458925932da967c6d0847754cd7ae50e422aa..d2f459f9460743d4a1099980b795112a0ac9ae72 100644 (file)
@@ -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 (file)
index 0000000..ed457a1
--- /dev/null
@@ -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");
+}
diff --git a/src/detect-ftp-completion-code.h b/src/detect-ftp-completion-code.h
new file mode 100644 (file)
index 0000000..50eb528
--- /dev/null
@@ -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 */
index e41d506f8e4f5c4ff5bf49d95f1ceca1c93ac2e9..839db2c9d50d1514977af470de2c71f96c6595ef 100644 (file)
@@ -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,