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,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<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());
}

@ -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 \

@ -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();

@ -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,

@ -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) {
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,

Loading…
Cancel
Save