Hi Victor,
Here are the ruleset examples. Three scenarios, then thoughts on hook design.
Scenario 1: Read-only FTP (block uploads)¶
Access control happens on the control channel via ftp:request_command_complete. The data channel is handled with broad TCP port-range rules since the passive mode port is dynamically negotiated.
# --- Control channel (port 21) ---
accept:hook tcp:all $HOME_NET any -> 10.0.5.100/32 21 (flow:not_established; sid:1;)
accept:hook tcp:all $HOME_NET any <> 10.0.5.100/32 21 (flow:established; sid:2;)
accept:hook ftp:request_started $HOME_NET any -> 10.0.5.100/32 any (sid:3;)
# Allow read-only commands — STOR, STOU, APPE, DELE not listed → dropped by default
accept:hook ftp:request_command_complete $HOME_NET any -> 10.0.5.100/32 any (ftp.command; content:"RETR"; sid:4;)
accept:hook ftp:request_command_complete $HOME_NET any -> 10.0.5.100/32 any (ftp.command; content:"LIST"; sid:5;)
accept:hook ftp:request_command_complete $HOME_NET any -> 10.0.5.100/32 any (ftp.command; content:"PASV"; sid:6;)
accept:hook ftp:request_command_complete $HOME_NET any -> 10.0.5.100/32 any (ftp.command; content:"EPSV"; sid:7;)
accept:hook ftp:request_command_complete $HOME_NET any -> 10.0.5.100/32 any (ftp.command; content:"USER"; sid:8;)
accept:hook ftp:request_command_complete $HOME_NET any -> 10.0.5.100/32 any (ftp.command; content:"PASS"; sid:9;)
accept:hook ftp:request_command_complete $HOME_NET any -> 10.0.5.100/32 any (ftp.command; content:"TYPE"; sid:10;)
accept:hook ftp:request_command_complete $HOME_NET any -> 10.0.5.100/32 any (ftp.command; content:"PWD"; sid:11;)
accept:hook ftp:request_command_complete $HOME_NET any -> 10.0.5.100/32 any (ftp.command; content:"CWD"; sid:12;)
accept:hook ftp:request_command_complete $HOME_NET any -> 10.0.5.100/32 any (ftp.command; content:"QUIT"; sid:13;)
accept:hook ftp:response_started 10.0.5.100/32 any -> $HOME_NET any (sid:14;)
accept:hook ftp:response_complete 10.0.5.100/32 any -> $HOME_NET any (sid:15;)
# --- Data channel (passive mode) ---
accept:hook tcp:all 10.0.5.100/32 1024:65535 -> $HOME_NET any (flow:not_established; sid:16;)
accept:hook tcp:all 10.0.5.100/32 1024:65535 <> $HOME_NET any (flow:established; sid:17;)
Scenario 2: Bounce attack prevention¶
Same control channel rules as above, but PORT and EPRT are simply not in the accept list → dropped by default. Only PASV/EPSV are accepted. No rules for the server connecting outbound (active mode), so bounce attacks are blocked by default.
Scenario 3: Directory-scoped uploads¶
Allow STOR only to /incoming/ — this is where request_command_complete earns its keep, since both the command and its argument are available:
accept:hook ftp:request_command_complete $HOME_NET any -> 10.0.5.51/32 any \
(ftp.command; content:"STOR"; ftp.command_data; content:"/incoming/"; startswith; sid:20;)
Thoughts on FTP-data hooks¶
You're right that the data state machine is trivially simple. The control channel hooks do the heavy lifting — every accept/drop decision about the transfer is made at request_command_complete where we know the command and target file. By the time data flows, the policy decision is already made.
For the data channel, a simple two-state model ( request_started / request_complete ) is fine. The main value would be defense-in-depth using keywords set by the control channel:
- ftpdata_command — e.g. accept:hook ftp-data:request_started ... (ftpdata_command:retr; sid:X;) to confirm only downloads on the data channel
- file.name — e.g. accept:hook ftp-data:request_started ... (file.name; content:".pdf"; endswith; sid:X;) to restrict by file type
No need to distinguish transfer complete vs. aborted for firewall purposes.
Priority from our side:
- FTP control channel hooks — request_started , request_command_complete , response_started , response_complete . This is where all policy decisions happen.
- FTP-data hooks — request_started and request_complete with ftpdata_command and file.name validated for firewall mode. Defense-in-depth.
Let me know if you'd like me to flesh out any specific scenario further.