Deployment guide

vsftpd 3.0.5

Overview

This guide covers launching and using the vsftpd 3.0.5 on Ubuntu 24.04 AMI from SecureAnyCloud on Amazon EC2. The AMI is one version of the SecureAnyCloud File Sharing and Sync listing on AWS Marketplace. You pick it as the software version when you subscribe and launch.

vsftpd (the "Very Secure FTP Daemon") is a small, fast FTP server with a long security record. Use it when your partners, devices or older systems speak FTP. Typical clients are scanners and cameras that upload over FTP, line-of-business systems that drop files every night, and people who use FileZilla, WinSCP or Cyberduck.

A stock vsftpd install accepts plain FTP logins from every Linux account on the machine. The SecureAnyCloud image changes that. What you get:

  • FTPS only. Explicit FTP over TLS (AUTH TLS on port 21) is required for the login and for every transfer and directory listing. A client that tries plain FTP with a real user name is refused before it can send a password, and a client that encrypts the login but not the data is refused too. Only TLS 1.2 with ECDHE key exchange and AEAD ciphers (AES-GCM, ChaCha20-Poly1305) is accepted; Step 15 explains why TLS 1.3 is switched off for this server.
  • A user, password and certificate unique to your instance. First boot creates the FTP user ftpuser with a random 24-character password and a TLS certificate for your instance. It writes them to /root/vsftpd-credentials.txt, which only root can read. The system stores only a yescrypt hash of the password. No password or key is baked into the image.
  • Only FTP users can log in, and they can never use SSH. Anonymous FTP is off. A login must belong to the vsftpd-users group, and root, ubuntu and every system account are on a deny list. FTP users have no shell, and SSH refuses the whole vsftpd-users group, so an FTP password never opens a shell or a tunnel.
  • Everyone in a jail. Each user is locked (chrooted) into its own directory, /srv/vsftp/<user>, and uploads into /files inside it. Every user works as its own Linux account, so users cannot read each other's files. The jail lives on a dedicated 20 GiB EBS data volume, mounted nosuid,nodev,noexec so an upload can never be run as a program.
  • Passive mode that works out of the box on EC2. Passive data ports are fixed to 40000-40099, and the public address announced to clients is read from instance metadata (IMDSv2) on every boot, so it stays right after a stop and start. Active mode is off, so the server never connects out to addresses a client names.
  • Login limits. Three failed logins end a session, every failure costs three seconds, one address may hold at most 10 connections and the server at most 50.
  • Closed until first boot, and fail closed. The image holds no FTP user and no certificate key, so the server cannot start. First boot creates both, starts the server, proves a full encrypted round trip (including five 1 MiB uploads) and checks that no password leaked into a log; if anything fails, the server is stopped again.
  • Hardened service. The server runs in a systemd sandbox that can write only to /srv/vsftp, it is blocked from the instance metadata service, and the AMI requires IMDSv2.
  • Fully patched Ubuntu 24.04 LTS at build time, with unattended security upgrades switched on.

What is on the image

  • vsftpd 3.0.5, Ubuntu's own package vsftpd 3.0.5-0ubuntu3.1 from the noble-updates main archive, installed unmodified with apt. 3.0.5 is the newest vsftpd release. Because it is the distribution package, Ubuntu's security updates for vsftpd and OpenSSL arrive through apt and unattended-upgrades (Step 16).
  • SecureAnyCloud adds only configuration, the PAM policy, the systemd units, and the account and maintenance tools in /usr/local/sbin (vsftpd-*, Step 10).
  • Ubuntu 24.04 LTS, OpenSSL 3, cloud-init and the AWS Systems Manager agent. Every package the image adds comes from Ubuntu's main archive.
  • Default user: ubuntu
  • Architecture: x86_64
  • Recommended instance type: m5.large

Prerequisites

  • An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet with a route to the internet (or a private route you use for management).
  • A security group that allows the following, from your own networks and your FTP users' addresses only:
    • TCP 22 for SSH administration
    • TCP 21 for the FTPS control connection
    • TCP 40000-40099 for passive data connections (the exact range the server uses)
  • An FTPS client: FileZilla, WinSCP, Cyberduck, lftp or curl.

Do not open ports 21 and 40000-40099 to 0.0.0.0/0 unless the server must accept uploads from anywhere on the internet. If you do, expect constant password guessing from scanners. The per-instance password and the login limits keep them out, but every attempt still costs you log noise and connections, and this image has no automatic ban (Step 9). List the address ranges your users and systems connect from instead.

1

Launch the instance

In the AWS Marketplace listing, choose Continue to Subscribe, then Continue to Configuration. Pick vsftpd 3.0.5 on Ubuntu 24.04 as the software version, pick your Region, and choose Launch through EC2.

Choose m5.large, your key pair, and a security group as described above. FTP needs little CPU, so size the instance for your users and transfer volume.

The AMI carries two volumes: the 8 GiB root volume and a 20 GiB gp3 data volume (/dev/sdf) for /srv/vsftp. You can make the data volume larger at launch, and every boot grows the file system to fill it.

To launch with the AWS CLI, replace the values in angle brackets. The second mapping sets the data volume size:

aws ec2 run-instances --region <region> --image-id <ami-id-from-the-listing> \
  --instance-type m5.large --key-name <your-key-pair> \
  --security-group-ids <your-security-group> --subnet-id <your-subnet> \
  --metadata-options HttpTokens=required,HttpEndpoint=enabled \
  --block-device-mappings 'DeviceName=/dev/sdf,Ebs={VolumeSize=100,VolumeType=gp3}' \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=vsftpd}]'

To add the FTPS rules to an existing security group for one client network, use the following commands. The example uses the documentation range 203.0.113.0/24, so substitute your own range:

aws ec2 authorize-security-group-ingress --group-id <your-security-group> \
  --ip-permissions 'IpProtocol=tcp,FromPort=21,ToPort=21,IpRanges=[{CidrIp=203.0.113.0/24}]' \
  'IpProtocol=tcp,FromPort=40000,ToPort=40099,IpRanges=[{CidrIp=203.0.113.0/24}]'

You need both rules. With only port 21 open, clients log in but every listing and transfer hangs, because the security group drops the data connection to a port in 40000-40099.

2

Connect to your instance

Connect over SSH as the default user for your version:

  • vsftpd 3.0.5 on Ubuntu 24.04: SSH user ubuntu
ssh -i <your-key.pem> ubuntu@<public-ip>

Port 22 is for you, the administrator, with your EC2 key pair (password logins are off). It is not an SFTP service for your FTP users, because SSH refuses every member of the vsftpd-users group (Step 8). If your users need SFTP rather than FTPS, choose an SFTP server version from the same listing.

3

Check the services

First boot creates the user and the certificate, starts the server, and verifies an encrypted round trip before it finishes (usually under a minute):

systemctl is-active vsftpd vsftpd-prepare vsftpd-firstboot
sudo test -f /var/lib/sac/data/vsftpd-firstboot.done && echo FIRSTBOOT_DONE
sudo vsftpd-healthcheck
active active active FIRSTBOOT_DONE PRIMARY_HEALTH_OK vsftpd answers on :21, offers AUTH TLS and refuses cleartext logins (530)

Only SSH and vsftpd listen on public addresses. vsftpd listens on IPv4 only, and a passive data port opens only for the duration of a transfer:

sudo ss -lntpH | awk '{split($6,p,"\""); print $4, p[2]}' | grep -v '127.0.0' | sort
0.0.0.0:21 vsftpd 0.0.0.0:22 sshd [::]:22 sshd
4

Get your user and password

Only root can read the credentials file. This command shows everything in it except the password:

sudo grep -E '^(FTP_HOST|FTP_PORT|FTP_USER|FTP_TLS|FTP_PASSIVE_PORTS|FTP_PASSIVE_ADDRESS|FTP_UPLOAD_DIR|FTP_HOME|TLS_CERT_SHA256)=' /root/vsftpd-credentials.txt
sudo stat -c '%a %U %n' /root/vsftpd-credentials.txt
FTP_HOST=ec2-<public-ip-dashed>.compute-1.amazonaws.com FTP_PORT=21 FTP_USER=ftpuser FTP_TLS=explicit FTPS (AUTH TLS) required for login and data FTP_PASSIVE_PORTS=40000-40099 FTP_PASSIVE_ADDRESS=<public-ip> FTP_UPLOAD_DIR=/files FTP_HOME=/srv/vsftp/ftpuser TLS_CERT_SHA256=<certificate-sha256-fingerprint> 600 root /root/vsftpd-credentials.txt

To print the password itself, do this where nobody can read your screen, and do not paste it into tickets or chat:

sudo grep '^FTP_PASSWORD=' /root/vsftpd-credentials.txt | cut -d= -f2-

FTP_HOST is the instance's public DNS name from EC2 instance metadata. When the subnet has no public DNS names, it is the public or private IP address. The certificate also names the public and private IP addresses, so connecting by IP address works too.

FTP_PASSIVE_ADDRESS is the address the server announces for passive transfers (Step 11). To use your own DNS name, see Step 12. To give the password a new value, see Step 10.

5

Connect with an FTPS client

In FileZilla, open File > Site Manager > New site and set the following fields:

  • Protocol: FTP - File Transfer Protocol
  • Host: FTP_HOST from Step 4, port 21
  • Encryption: Require explicit FTP over TLS
  • Logon type: Normal (or Ask for password)
  • User and Password: ftpuser and the password from Step 4

On the Transfer Settings tab, choose Passive mode. The server refuses active mode.

After you connect, you are in your jail, which holds one directory, files. Open it and upload there, because the top level of the jail is read-only.

The first time you connect, FileZilla shows the server's certificate. It is self-signed and was created on your instance. Compare its SHA-256 fingerprint with TLS_CERT_SHA256 from Step 4 before you tick Always trust this certificate, and do not accept a certificate whose fingerprint does not match. You can print the fingerprint on the instance at any time:

sudo openssl x509 -noout -subject -fingerprint -sha256 -ext subjectAltName,basicConstraints -in /etc/vsftpd/tls/vsftpd.crt
subject=CN = ec2-<public-ip-dashed>.compute-1.amazonaws.com, O = sac sha256 Fingerprint=<certificate-sha256-fingerprint> X509v3 Subject Alternative Name: DNS:ec2-<public-ip-dashed>.compute-1.amazonaws.com, IP Address:<public-ip>, IP Address:<private-ip>, IP Address:127.0.0.1 X509v3 Basic Constraints: critical CA:FALSE

The certificate is a server certificate only (CA:FALSE). Trusting it lets your client trust this one server and nothing else.

In WinSCP, set File protocol to FTP and Encryption to TLS/SSL Explicit encryption. In Cyberduck, choose FTP-SSL (Explicit AUTH TLS).

To connect from a terminal on your own computer, first save the server's certificate and check its fingerprint against TLS_CERT_SHA256. Then let curl verify the server with the saved certificate. The --ssl-reqd option makes curl refuse to continue without TLS, and curl asks for the password when you give only the user name:

openssl s_client -starttls ftp -connect <FTP_HOST>:21 </dev/null 2>/dev/null | openssl x509 > vsftpd.crt
openssl x509 -noout -subject -fingerprint -sha256 -in vsftpd.crt curl --ssl-reqd --cacert vsftpd.crt -u ftpuser -T ./report.txt ftp://<FTP_HOST>/files/report.txt curl --ssl-reqd --cacert vsftpd.crt -u ftpuser --list-only ftp://<FTP_HOST>/files/ curl --ssl-reqd --cacert vsftpd.crt -u ftpuser ftp://<FTP_HOST>/files/report.txt curl --ssl-reqd --cacert vsftpd.crt -u ftpuser -Q 'DELE files/report.txt' -o /dev/null ftp://<FTP_HOST>/

Run from a computer outside AWS against a test instance (Ubuntu 24.04's curl 8.5.0 with OpenSSL 3.0), these commands printed the fingerprint, the listing and the downloaded file:

subject=CN = ec2-<public-ip-dashed>.compute-1.amazonaws.com, O = sac sha256 Fingerprint=<certificate-sha256-fingerprint> report.txt hello from my laptop

The curl that ships with macOS works too, and it was tested with the same commands. In scripts, put user = "ftpuser:<password>" in a file that only you can read. Pass it with curl -K <file> instead of -u, so the password never appears in your process list or shell history.

With lftp, force TLS on the login and the data, and trust the saved certificate:

lftp -u ftpuser <FTP_HOST> lftp ftpuser@<FTP_HOST>:~> set ftp:ssl-force true lftp ftpuser@<FTP_HOST>:~> set ftp:ssl-protect-data true lftp ftpuser@<FTP_HOST>:~> set ssl:ca-file vsftpd.crt lftp ftpuser@<FTP_HOST>:~> put lftp.txt -o files/lftp.txt lftp ftpuser@<FTP_HOST>:~> ls files
-rw-r----- 1 ftp ftp 16 Oct 31 20:16 lftp.txt

The listing shows ftp as the owner of every file on purpose (hide_ids=YES). Clients never learn the Linux account names on the server.

6

Upload and download from the instance

These commands upload a file into /files, read it back, list the directory, delete the file, and then show that the top level of the jail is read-only. Everything runs over FTPS to the instance's own private address, and curl checks the server certificate. The password is passed to curl on standard input, so it never appears in the process list, your shell history or the output:

sudo bash -c ' F=/root/vsftpd-credentials.txt U=$(grep "^FTP_USER=" $F | cut -d= -f2-); P=$(grep "^FTP_PASSWORD=" $F | cut -d= -f2-) IP=$(hostname -I | cut -d" " -f1) ftps() { printf "user = \"%s:%s\"\n" "$U" "$P" | curl -K - -sS --ssl-reqd --cacert /etc/vsftpd/tls/vsftpd.crt "$@"; } echo "hello from sac" > /run/report.txt ftps -T /run/report.txt "ftp://$IP/files/report.txt" && echo "upload -> ok" echo "download -> $(ftps "ftp://$IP/files/report.txt")" echo "list -> $(ftps --list-only "ftp://$IP/files/" | tr "\n" " ")" ftps -Q "DELE files/report.txt" -o /dev/null "ftp://$IP/" && echo "delete -> ok" ftps -T /run/report.txt "ftp://$IP/report.txt" 2>&1 || echo "upload to the jail root refused (curl rc $?)" rm -f /run/report.txt '
upload -> ok download -> hello from sac list -> report.txt delete -> ok curl: (25) Failed FTP upload: 553 upload to the jail root refused (curl rc 25)

Uploaded files land in /srv/vsftp/ftpuser/files, owned by the ftpuser account with mode 0640. To drop files in for a user over SSH, copy them there and give ownership to that user with sudo chown ftpuser:ftpuser /srv/vsftp/ftpuser/files/<file>.

7

Run the built-in check

The image ships an end-to-end check that you can run at any time, and first boot runs it too. The check reads the credentials file itself, passes the password to curl in a root-only temporary file, and drives the server over FTPS through the instance's private address:

sudo vsftpd-verify.sh
ok banner '220 FTP server ready (explicit FTPS required)' (no software/version) ok FEAT offers AUTH TLS ok plain FTP login refused before PASS (530, curl rc 67) ok anon login refused over FTPS (530) ok ftp login refused over FTPS (530) ok root login refused over FTPS (530) ok wrong password refused (530) ok cleartext data channel refused (522, curl rc 19) ok server certificate is this instance's (<fingerprint>...) ok tls1_2 accepted (ECDHE AEAD) ok tls1_3 refused (TLS 1.2 only) ok tls1 refused ok tls1_1 refused ok non-AEAD (CBC) ciphers refused ok upload (EPSV) files/sac-verify-6665.bin ok download (PASV) byte-identical ok passive data port 40000 inside 40000-40099 ok PASV reply carries the passive address <public-ip> ok data channel protected (PROT P accepted) ok listing shows the upload ok delete ok file gone after delete ok file gone from /srv/vsftp/ftpuser/files ok 5 of 5 consecutive 1 MiB uploads complete and byte-identical ok the jail root is not writable (curl rc 25) ok CWD .. / ../.. stay at the jail root ok ../../../etc/passwd unreachable (curl rc 9) ok /etc/passwd (absolute) unreachable (curl rc 9) ok /proc/self/root/etc/passwd unreachable ok jail root lists only files/ ok active mode (PORT and EPRT) refused ok live session process runs as ftpuser VSFTPD_ROUNDTRIP_OK target <private-ip>

The check takes about 15 seconds, because it tries one wrong password on purpose and every failed login is delayed by three seconds. It leaves the user's directory as it found it.

8

Security checks

Plain FTP is refused. A client that does not start TLS gets the welcome banner, which names no software or version. The server then refuses the client at the user name, before it can send a password. The name anonymous is asked for a password (by FTP convention an email address) and then refused, because anonymous logins are off:

IP=$(hostname -I | cut -d" " -f1) (curl -sv -m 8 -u ftpuser:not-the-password "ftp://$IP/" 2>&1 || true) | grep -E '^[<>] ' | tr -d '\r' (curl -sv -m 8 "ftp://$IP/" 2>&1 || true) | grep -E '^[<>] ' | tr -d '\r'
< 220 FTP server ready (explicit FTPS required) > USER ftpuser < 530 Non-anonymous sessions must use encryption. < 220 FTP server ready (explicit FTPS required) > USER anonymous < 331 Please specify the password. > PASS ftp@example.com < 530 Login incorrect.

TLS 1.2 only. The server refuses everything except TLS 1.2, even for a client willing to speak the older protocols. TLS 1.3 is refused too (Step 15 explains why):

IP=$(hostname -I | cut -d" " -f1) for v in tls1 tls1_1 tls1_2 tls1_3; do r=$(timeout 20 openssl s_client -starttls ftp -$v -cipher 'DEFAULT:@SECLEVEL=0' -connect "$IP:21" </dev/null 2>/dev/null | grep -m1 '^New,') case "$r" in *"(NONE)"*|"") echo "$v refused" ;; *) echo "$v accepted: ${r#New, }" ;; esac done
tls1 refused tls1_1 refused tls1_2 accepted: TLSv1.2, Cipher is ECDHE-RSA-AES256-GCM-SHA384 tls1_3 refused

The configuration. TLS is required for logins and data, and anonymous access is off. Everyone is jailed in a root-owned directory. Passive mode uses a fixed range, and data connections must come from the client's own address. Active mode is off, failed logins are limited, and the protocol stream (which carries PASS) is never logged:

grep -E '^(listen|listen_ipv6|anonymous_enable|local_enable|chroot_local_user|allow_writeable_chroot|local_root|ssl_enable|force_local_logins_ssl|force_local_data_ssl|ssl_ciphers|require_ssl_reuse|pasv_min_port|pasv_max_port|pasv_promiscuous|port_enable|max_login_fails|delay_failed_login|max_per_ip|max_clients|seccomp_sandbox|log_ftp_protocol)=' /etc/vsftpd.conf
grep -E '^(MinProtocol|MaxProtocol|CipherString)' /etc/vsftpd/openssl-vsftpd.cnf
listen=YES listen_ipv6=NO anonymous_enable=NO local_enable=YES chroot_local_user=YES allow_writeable_chroot=NO local_root=/srv/vsftp/$USER ssl_enable=YES force_local_logins_ssl=YES force_local_data_ssl=YES ssl_ciphers=ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:@SECLEVEL=2 require_ssl_reuse=NO pasv_min_port=40000 pasv_max_port=40099 pasv_promiscuous=NO port_enable=NO max_login_fails=3 delay_failed_login=3 max_per_ip=10 max_clients=50 seccomp_sandbox=NO log_ftp_protocol=NO MinProtocol = TLSv1.2 MaxProtocol = TLSv1.2 CipherString = ECDHE+AESGCM:ECDHE+CHACHA20:@SECLEVEL=2

/etc/vsftpd/openssl-vsftpd.cnf applies to vsftpd only, because vsftpd.service sets OPENSSL_CONF to it. The rest of the system keeps Ubuntu's OpenSSL defaults.

Who may log in. The PAM policy for vsftpd first refuses every name in /etc/ftpusers (root, ubuntu and the system accounts). It then lets in only members of vsftpd-users and checks their password against /etc/shadow:

grep -vE '^[[:space:]]*(#|$)' /etc/pam.d/vsftpd
grep -vE '^(#|$)' /etc/ftpusers | tr '\n' ' '; echo
auth required pam_listfile.so item=user sense=deny file=/etc/ftpusers onerr=fail auth required pam_succeed_if.so user ingroup vsftpd-users quiet_success auth required pam_unix.so account required pam_succeed_if.so user ingroup vsftpd-users quiet_success account required pam_unix.so session required pam_permit.so root daemon bin sys sync games man lp mail news uucp nobody ubuntu ftp vsftpd-nopriv ssm-user ec2-user

FTP users can never use SSH. SSH accepts keys only and refuses root. It also refuses the whole vsftpd-users group, so even a leaked FTP password opens no shell, port forward or SFTP session:

sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|denygroups) '
permitrootlogin no passwordauthentication no kbdinteractiveauthentication no denygroups vsftpd-users

The files that matter are locked down. Only root can read the credentials file and the TLS key. The jail root /srv/vsftp/ftpuser is owned by root and is not writable by the user, which is vsftpd's safe chroot layout. Only files inside it belongs to the user. The FTP user has no shell, and /etc/shadow holds only a yescrypt ($y$) hash of its password:

sudo stat -c '%a %U:%G %n' /root/vsftpd-credentials.txt /etc/vsftpd/tls/vsftpd.key /etc/vsftpd/tls/vsftpd.crt /srv/vsftp /srv/vsftp/ftpuser /srv/vsftp/ftpuser/files getent passwd ftpuser id ftpuser
sudo getent shadow ftpuser | cut -d: -f2 | cut -c1-3
600 root:root /root/vsftpd-credentials.txt 600 root:root /etc/vsftpd/tls/vsftpd.key 644 root:root /etc/vsftpd/tls/vsftpd.crt 755 root:root /srv/vsftp 755 root:root /srv/vsftp/ftpuser 750 ftpuser:ftpuser /srv/vsftp/ftpuser/files ftpuser:x:1001:1001:sac FTPS user:/srv/vsftp/ftpuser:/usr/sbin/nologin uid=1001(ftpuser) gid=1001(ftpuser) groups=1001(ftpuser),987(vsftpd-users) $y$

The server is sandboxed and cannot reach the instance metadata service. vsftpd.service can write only to /srv/vsftp, cannot gain privileges, and carries a drop-in that denies the metadata addresses.

The check below runs one request from inside vsftpd's own control group, and the request fails with 000. It then runs the same request from your shell, and the request succeeds with 200, so you can see the block is real. The metadata token itself is discarded and never printed:

systemctl show vsftpd -p NoNewPrivileges -p ProtectSystem -p ReadWritePaths -p IPAddressDeny cat /etc/systemd/system/vsftpd.service.d/50-sac-imds-deny.conf
CG=$(systemctl show -p ControlGroup --value vsftpd)
sudo bash -c "echo \$\$ > /sys/fs/cgroup$CG/cgroup.procs && curl -s -m 5 -o /dev/null -w 'inside vsftpd: %{http_code}\n' -X PUT -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' http://169.254.169.254/latest/api/token" curl -s -m 5 -o /dev/null -w 'your shell: %{http_code}\n' -X PUT -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' http://169.254.169.254/latest/api/token
IPAddressDeny=169.254.169.254/32 168.63.129.16/32 fd00:ec2::254/128 ReadWritePaths=/srv/vsftp ProtectSystem=strict NoNewPrivileges=yes
# sac: the FTP server never needs the instance metadata service (or the Azure wire server); deny both. [Service] IPAddressDeny=169.254.169.254/32 168.63.129.16/32 fd00:ec2::254/128 inside vsftpd: 000 your shell: 200

The preparation services that read the instance's address and host name (vsftpd-prepare, vsftpd-firstboot, vsftpd-set-hostname) run outside the server's control group and use IMDSv2, which requests a token first. The instance rejects IMDSv1 requests.

No per-instance password reached a log, the journal, shell history, backups, /etc or the data volume:

sudo vsftpd-secret-leak-check.sh
LEAKCHECK_OK no per-instance password in the journal, auth.log, syslog, cloud-init, the vsftpd logs, shell history, MOTD, /var/backups, /etc, /srv/vsftp or /tmp

The log shows who did what. vsftpd logs every connection, login and failure in /var/log/vsftpd/vsftpd.log, which is rotated weekly. Each entry carries the address the client really connected from, never a forwarded header:

sudo grep -E 'OK LOGIN|FAIL LOGIN' /var/log/vsftpd/vsftpd.log | tail -n 3
Sat Oct 31 20:26:48 2026 [pid 9731] [ftpuser] OK LOGIN: Client "<private-ip>" Sat Oct 31 20:26:50 2026 [pid 9740] [ftpuser] OK LOGIN: Client "<private-ip>" Sat Oct 31 20:26:52 2026 [pid 9759] [anonymous] FAIL LOGIN: Client "<private-ip>"
9

Login limits and automatic bans

vsftpd ends a session after 3 failed logins (max_login_fails=3) and waits 3 seconds after every failure (delay_failed_login=3), so each connection can try at most one password every three seconds. One client address may hold at most 10 connections (max_per_ip=10), and the server allows at most 50 (max_clients=50). Sessions idle for 10 minutes are closed. With a random 24-character password, guessing it this way is not feasible.

This is what three wrong passwords in one encrypted session look like from a client outside AWS. After the third failure, the server closes the connection:

220 FTP server ready (explicit FTPS required) 331 Please specify the password. 530 Login incorrect. 331 Please specify the password. 530 Login incorrect. 331 Please specify the password. 530 Login incorrect. <openssl-process-id>:error:0A000126:SSL routines:ssl3_read_n:unexpected eof while reading:../ssl/record/rec_layer_s3.c:316:

There is no automatic ban. These limits work per session, so a client that reconnects can try again. SecureAnyCloud does not ship fail2ban, because it is in Ubuntu's community-maintained (universe) archive, outside the main archive this image is built from.

The best protection is the security group: allow 21 and 40000-40099 only from your users' addresses. If the server must be open to the internet and you want bans, install fail2ban yourself and point its stock vsftpd filter at this image's log file. That filter matches this image's FAIL LOGIN lines. SecureAnyCloud checked it with fail2ban-regex against a real log line from this image, but fail2ban itself is not installed or tested on the image:

sudo apt-get install -y fail2ban
sudo tee /etc/fail2ban/jail.d/vsftpd-sac.local >/dev/null <<'EOF' [vsftpd] enabled = true port = 21,40000:40099 logpath = /var/log/vsftpd/vsftpd.log maxretry = 5 findtime = 10m bantime = 1h EOF
sudo systemctl restart fail2ban
sudo fail2ban-client status vsftpd
10

Users and passwords

Every FTP user is a Linux account with no shell, in the group vsftpd-users, jailed to /srv/vsftp/<user> and uploading to /files inside it. Users never see each other's files. Always use the tools below. They create the safe directory layout, pass passwords to the system on standard input only, and refuse names that are already accounts (such as root or ubuntu).

List the users and their upload directories:

sudo vsftpd-listusers
ftpuser /srv/vsftp/ftpuser/files

Add a user with a generated 24-character password, which is shown once:

sudo vsftpd-adduser bob
FTPS user "bob" created: jailed to /srv/vsftp/bob, uploads go to /files (explicit TLS required). Password (shown once, store it now): <generated-password>

Or set the password yourself on standard input, so it never appears on a command line or in your history. Use at least 12 characters and no ::

read -rs PW && printf '%s\n' "$PW" | sudo vsftpd-adduser alice --password-stdin; unset PW
FTPS user "alice" created: jailed to /srv/vsftp/alice, uploads go to /files (explicit TLS required).

Change a password with a generated value or with --password-stdin. The change applies to the next login, without a restart. For ftpuser, the tool also updates FTP_PASSWORD in /root/vsftpd-credentials.txt:

read -rs PW && printf '%s\n' "$PW" | sudo vsftpd-passwd alice --password-stdin; unset PW
sudo vsftpd-passwd ftpuser
Password for "alice" changed. Updated /root/vsftpd-credentials.txt. Password for "ftpuser" changed. New password (shown once, store it now): <generated-password>

Delete a user. This ends the user's open sessions and deletes the account. The user's files stay in /srv/vsftp/<user>/files unless you add --remove-files, which empties the directory as that user, so a link planted in it is removed and never followed:

sudo vsftpd-deluser alice --remove-files
Removed /srv/vsftp/alice. FTP user "alice" deleted.

The tools touch only FTP users:

sudo vsftpd-adduser root
sudo vsftpd-deluser ubuntu
vsftpd-adduser: ERROR: 'root' is not a valid new user name (lower-case letters, digits, _ and -, max 32; not an existing account or group; not in /etc/ftpusers) vsftpd-deluser: ERROR: 'ubuntu' is not an FTP user

The accounts live in the normal system files (/etc/passwd, /etc/shadow, /etc/group), so back those up along with the data volume (Step 13).

11

Passive mode, the passive address and the firewall

FTP uses a second connection for every transfer and listing. This server supports passive mode only. The client opens that connection to a port the server announces from 40000-40099, so your security group must allow that range as well as port 21, from the same client addresses.

Active mode is refused (PORT and EPRT get 550 Permission denied.), so the server never opens outbound connections to addresses a client names. A data connection must come from the same address as the client's control connection (pasv_promiscuous=NO).

Clients that use the EPSV command (FileZilla, WinSCP, curl, lftp and most others) reuse the address they connected to and need only the port. Older clients use PASV, where the server also announces an address.

A fresh instance knows only its private address. On every boot, vsftpd-prepare (a oneshot service that runs before vsftpd) chooses the address:

  • If /etc/sac/vsftpd-pasv-address exists, the service uses its first line: an IPv4 address, a DNS name, or none.
  • Otherwise, the service uses the instance's public IPv4 address from instance metadata (IMDSv2). This address can change when you stop and start the instance, which is why the service runs at every boot.
  • With neither (a private-only instance), no passive address is set and PASV announces the address the client connected to.

The result is written to a runtime file and handed to vsftpd on its command line. /etc/vsftpd.conf itself is never rewritten:

cat /run/sac-vsftpd/pasv.env ps -o args= -C vsftpd
# Written by vsftpd-prepare at every boot - do not edit. Set your own passive address in /etc/sac/vsftpd-pasv-address
# (an IPv4 address, a DNS name, or "none"), then:
sudo vsftpd-prepare --restart
# source: EC2 IMDSv2 public-ipv4 VSFTPD_PASV_ADDRESS=<public-ip> VSFTPD_PASV_OPTS=-opasv_address=<public-ip> /usr/sbin/vsftpd /etc/vsftpd.conf -opasv_address=<public-ip>

This PASV session runs from a computer outside AWS, with curl told to use the announced address. Because curl -v prints the password line, the command filters it out. The output shows the public address, a port inside the range (156*256+118 = 40054), and TLS on both connections:

curl -v --ssl-reqd --cacert vsftpd.crt -u ftpuser --disable-epsv --no-ftp-skip-pasv-ip --list-only ftp://<FTP_HOST>/files/ 2>&1 | grep -v '^> PASS'
< 220 FTP server ready (explicit FTPS required) > AUTH SSL < 234 Proceed with negotiation. * SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384 / prime256v1 / RSASSA-PSS < 230 Login successful. > PBSZ 0 < 200 PBSZ set to 0. > PROT P < 200 PROT now Private. < 257 "/" is the current directory < 250 Directory successfully changed. > PASV < 227 Entering Passive Mode (<public-ip-commas>,156,118). * Connecting to <public-ip> (<public-ip>) port 40054 < 200 Switching to ASCII mode. > NLST * SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384 / prime256v1 / UNDEF < 150 Here comes the directory listing. < 226 Directory send OK.

Elastic IP, DNS name or private only. Behind an Elastic IP, a Network Load Balancer or your own DNS name, write that address or name to /etc/sac/vsftpd-pasv-address. Write none when every client is inside the VPC or connects over a VPN or Direct Connect. Then apply the setting, which rewrites the runtime file and restarts vsftpd. The restart ends open sessions:

echo 198.51.100.25 | sudo tee /etc/sac/vsftpd-pasv-address
sudo vsftpd-prepare --restart
vsftpd-prepare: passive address: 198.51.100.25 (/etc/sac/vsftpd-pasv-address)

To go back to the instance's public address, delete the file and run sudo vsftpd-prepare --restart again. It then reports (EC2 IMDSv2 public-ipv4). Do not edit /run/sac-vsftpd/pasv.env itself, because it is rewritten at every boot.

If you change pasv_min_port/pasv_max_port in /etc/vsftpd.conf, change the security group to the same range.

12

Your own DNS name and certificate

SecureAnyCloud only ever replaces its own certificate. When SecureAnyCloud creates the instance's self-signed certificate, it records that certificate's SHA-256 fingerprint in the root-owned file /var/lib/sac/data/vsftpd-tls-sac.sha256. The certificate is re-created only when the installed certificate still matches that fingerprint.

A certificate you install at /etc/vsftpd/tls/vsftpd.crt no longer matches, so it is never replaced, either by first boot or when you change the host name. Check which certificate is installed:

CUR=$(sudo openssl x509 -noout -fingerprint -sha256 -in /etc/vsftpd/tls/vsftpd.crt | cut -d= -f2) if [ "$CUR" = "$(sudo cat /var/lib/sac/data/vsftpd-tls-sac.sha256)" ]; then echo "sac self signed certificate $CUR"; else echo "your own certificate $CUR"; fi
sac self signed certificate <certificate-sha256-fingerprint>

A new name for the self-signed certificate. Point a DNS record at the instance (or attach an Elastic IP), then set the name the server presents.

While SecureAnyCloud's certificate is installed, this command creates a new key and self-signed certificate for the name and the instance's current addresses. It records the new fingerprint, updates FTP_HOST and TLS_CERT_SHA256 in /root/vsftpd-credentials.txt, restarts vsftpd, and keeps the name (in /etc/sac/vsftpd-hostname) for later runs. After a stop and start gives the instance new addresses, run the command without a name:

sudo vsftpd-set-hostname ftp.example.com
Host ftp.example.com; new certificate SHA-256 fingerprint <new-fingerprint> (SAN DNS:ftp.example.com,IP:<public-ip>,IP:<private-ip>,IP:127.0.0.1)

To go back to the EC2 name, delete /etc/sac/vsftpd-hostname and run sudo vsftpd-set-hostname with no name. Your clients see a new fingerprint each time, so compare it with TLS_CERT_SHA256 again.

Your own certificate, for example from Let's Encrypt. FTPS clients cannot use the HTTP challenge, so use a DNS challenge. This example assumes the domain is in Route 53 and the instance has a role allowed to change that zone. The Certbot Route 53 plugin runs outside the FTP server, which is the only service blocked from instance metadata:

sudo apt-get install -y certbot python3-certbot-dns-route53
sudo certbot certonly --dns-route53 -d ftp.example.com

vsftpd reads its certificate and key when it starts. It refuses to start (exit status 2) if they do not belong together, so install both files before you restart it. Use the full chain so clients get the intermediate certificate too:

sudo install -o root -g root -m 0600 /etc/letsencrypt/live/ftp.example.com/privkey.pem /etc/vsftpd/tls/vsftpd.key
sudo install -o root -g root -m 0644 /etc/letsencrypt/live/ftp.example.com/fullchain.pem /etc/vsftpd/tls/vsftpd.crt
sudo systemctl restart vsftpd && sudo vsftpd-healthcheck

Put the first two lines and the restart in a Certbot deploy hook (/etc/letsencrypt/renewal-hooks/deploy/vsftpd) so renewals are installed too, and set the passive address to the same name (Step 11). Your clients then trust the certificate without a fingerprint check. A certificate from your own certificate authority is installed the same way.

From then on, the SecureAnyCloud tools leave your certificate alone. This is the output on a test instance with a certificate that SecureAnyCloud did not create:

your own certificate <your-certificate-fingerprint> vsftpd-set-hostname: ERROR: the installed certificate was not minted by sac (yours?) - not replacing it. Pass --replace-own-cert to replace it with a new self-signed one.

Back to a self-signed certificate. To replace your certificate with a new SecureAnyCloud self-signed one, say so explicitly. This command creates a new key and certificate, records the new fingerprint, updates the credentials file, and restarts vsftpd:

sudo vsftpd-set-hostname --replace-own-cert
Host ec2-<public-ip-dashed>.compute-1.amazonaws.com; new certificate SHA-256 fingerprint <new-fingerprint> (SAN DNS:ec2-<public-ip-dashed>.compute-1.amazonaws.com,IP:<public-ip>,IP:<private-ip>,IP:127.0.0.1)
13

The data volume

/srv/vsftp is its own EBS volume, mounted by file system UUID with nosuid,nodev,noexec, so uploads can never be run as programs. The image ships the volume empty, and only the jails of the users you create live there:

findmnt -no SOURCE,FSTYPE,OPTIONS /srv/vsftp df -h --output=size,used,avail,target /srv/vsftp grep -c ' /srv/vsftp ext4 defaults,nofail,nosuid,nodev,noexec ' /etc/fstab
/dev/nvme1n1 ext4 rw,nosuid,nodev,noexec,relatime Size Used Avail Mounted on 20G 32K 19G /srv/vsftp 1

To grow the volume, increase its size in the EC2 console (Volumes > Modify volume) and wait until the modification reaches the optimizing state. Then grow the file system online. The next boot does this too:

sudo vsftpd-prepare

If the volume is missing (detached, or not attached at launch), vsftpd does not start rather than writing your users' files to the root volume. The service requires /srv/vsftp to be mounted.

Back up the volume with EBS snapshots or AWS Backup. A snapshot of the data volume holds every user's files.

14

Logs and monitoring

Find each kind of log information in the following places:

  • Connections, logins, failures and transfers (client address from the socket): /var/log/vsftpd/vsftpd.log, rotated weekly, 8 weeks kept
  • Service start, stop and errors: journalctl -u vsftpd
  • First boot: journalctl -u vsftpd-firstboot
  • Passive address and data volume growth at boot: journalctl -u vsftpd-prepare

For CloudWatch, ship /var/log/vsftpd/vsftpd.log with the CloudWatch agent and set an alarm on a rise in FAIL LOGIN lines. sudo vsftpd-healthcheck exits non-zero when the server is down, so it also works as a check for your monitoring system.

15

Known limitations

SecureAnyCloud made the following trade-offs deliberately. Review them to judge whether they suit your environment:

  • TLS 1.3 is off for vsftpd. vsftpd 3.0.5 (the newest release) loses uploads over TLS 1.3. On Ubuntu 24.04's package, measured by SecureAnyCloud on 31 October 2026, 11 of 20 uploads of 256 KiB over TLS 1.3 failed with 426 Failure reading network stream, while 0 of 40 failed over TLS 1.2 (and 0 of 10 at 8 MiB). A file server that silently drops uploads is worse than one without TLS 1.3, so this image allows TLS 1.2 only, with ECDHE key exchange and AEAD ciphers, which remains a strong configuration.
    A client configured to require TLS 1.3 cannot connect (curl --tlsv1.3 fails with tlsv1 alert protocol version), so use the client's default settings. First boot and vsftpd-verify.sh upload five 1 MiB files in a row to catch a regression. The rest of the instance (SSH, apt) is not affected.
  • require_ssl_reuse=NO. vsftpd can insist that every data connection resumes the control connection's TLS session, which stops another party that shares the client's address from taking over a transfer. That check is known to refuse data connections from some clients (522 SSL connection failed: session reuse required), so it is off. The data connection must still come from the client's own IP address (pasv_promiscuous=NO), and it is always encrypted (PROT P).
  • vsftpd's seccomp sandbox is off (seccomp_sandbox=NO), because its system call filter breaks TLS transfers with current glibc and OpenSSL. Sessions are still confined by the chroot jail, by running as the user's own account, by vsftpd's private process, IPC and network namespaces, and by the systemd sandbox in Step 8.
  • No automatic bans. fail2ban is not shipped (Step 9). Rely on the security group, the per-instance password and the login limits, or add fail2ban yourself.
  • IPv4 only. vsftpd listens on IPv4 (listen_ipv6=NO).
16

Updates, source and license

vsftpd is Ubuntu's own package from the main archive, so its security updates and OpenSSL's arrive through apt. Unattended security upgrades are on:

vsftpd -v 0>&1 | head -1 apt-cache policy vsftpd | sed -n 1,3p grep -vE '^(//|$)' /etc/apt/apt.conf.d/20auto-upgrades
vsftpd: version 3.0.5 vsftpd: Installed: 3.0.5-0ubuntu3.1 Candidate: 3.0.5-0ubuntu3.1 APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Unattended-Upgrade "1";

SecureAnyCloud's configuration (/etc/vsftpd.conf, /etc/pam.d/vsftpd, /etc/ftpusers) replaces the package's defaults, and /etc/systemd/system/vsftpd.service replaces the package's unit. If an update ever changes Ubuntu's default version of one of those configuration files, unattended-upgrades holds that update back instead of asking. Install the update by hand and keep your configuration:

apt list --upgradable 2>/dev/null | grep vsftpd
sudo apt-get install --only-upgrade -o Dpkg::Options::=--force-confold vsftpd
sudo systemctl restart vsftpd && sudo vsftpd-verify.sh | tail -1

When no update is pending, as on a freshly launched instance, the last two commands print:

vsftpd is already the newest version (3.0.5-0ubuntu3.1). 0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded. VSFTPD_ROUNDTRIP_OK target <private-ip>

Only the three configuration files differ from the package as shipped; the program files are unmodified:

dpkg -V vsftpd; echo "dpkg -V exit code $?"
??5?????? c /etc/ftpusers ??5?????? c /etc/pam.d/vsftpd ??5?????? c /etc/vsftpd.conf dpkg -V exit code 0

vsftpd is licensed under the GNU General Public License, version 2, with an exception that permits linking with OpenSSL (/usr/share/doc/vsftpd/copyright). The source is Ubuntu's source package (apt-get source vsftpd=3.0.5-0ubuntu3.1, or Launchpad). Provenance notes are in /usr/local/share/doc/vsftpd-sac/README.txt.

To move to a new image version, launch the new instance next to the old one. Stop vsftpd on the old instance (sudo systemctl stop vsftpd), snapshot its data volume, and create a volume from the snapshot in the new instance's Availability Zone.

On the new instance, create the same users with vsftpd-adduser. Copy each user's files into the user's new /srv/vsftp/<user>/files directory, and make the user the owner (chown -R <user>:<user>), because the numeric user IDs can differ between instances.

17

Server components

The image contains the following components:

  • vsftpd 3.0.5 (Ubuntu package 3.0.5-0ubuntu3.1, main): /usr/sbin/vsftpd
  • OpenSSL 3.0 (Ubuntu, updated by apt): system library
  • Ubuntu 24.04 LTS

These are the paths that matter:

  • /etc/vsftpd.conf: server configuration
  • /etc/vsftpd/openssl-vsftpd.cnf: TLS 1.2 only, for vsftpd only
  • /etc/pam.d/vsftpd, /etc/ftpusers: who may log in (the vsftpd-users group), and the deny list
  • /etc/vsftpd/tls/: certificate and key (directory mode 0700)
  • /var/lib/sac/data/vsftpd-tls-sac.sha256: fingerprint of the certificate SecureAnyCloud created (Step 12)
  • /etc/sac/vsftpd-pasv-address, /etc/sac/vsftpd-hostname: your passive address and host name settings (absent by default)
  • /run/sac-vsftpd/pasv.env: passive address chosen at boot (Step 11)
  • /srv/vsftp/<user>/files: each user's upload directory, on the data volume
  • /var/log/vsftpd/vsftpd.log: connection, login and transfer log
  • /root/vsftpd-credentials.txt: host, user, password and certificate fingerprint (mode 0600)
  • /usr/local/sbin/vsftpd-*: adduser, passwd, deluser, listusers, set-hostname, prepare, verify.sh, healthcheck, secret-leak-check.sh
  • vsftpd.service, vsftpd-prepare.service, vsftpd-firstboot.service: the server, the per-boot preparation and the first boot

To change a setting, edit /etc/vsftpd.conf, then run sudo systemctl restart vsftpd and sudo vsftpd-verify.sh. vsftpd stops at start with exit status 2 on an unknown or invalid setting, and journalctl -u vsftpd shows the error.

Troubleshooting

  • The client connects and logs in but the listing hangs or times out. The passive data connection is blocked: allow TCP 40000-40099 in the security group (and any network ACL or corporate firewall) from the client's address. Behind NAT, an Elastic IP or a load balancer, set the passive address (Step 11).
  • "530 Non-anonymous sessions must use encryption". The client tried plain FTP. Choose explicit FTP over TLS (Step 5).
  • "522 Data connections must be encrypted". The client encrypts the login but not the data. In lftp set ftp:ssl-protect-data true; in other clients choose Require explicit FTP over TLS.
  • "553 Could not create file". The client is uploading to the top of the jail, which is read-only. Upload into /files.
  • "530 Login incorrect". Wrong user or password, or a user that is not in vsftpd-users (create users with vsftpd-adduser). Three failures end the session.
  • "tlsv1 alert protocol version" or a TLS handshake failure. The client requires TLS 1.3 or only offers old ciphers. Allow TLS 1.2 in the client (Step 15).
  • "550 Permission denied" after PORT or EPRT (curl: "Failed to do PORT"). The client is in active mode. Switch it to passive mode.
  • The certificate warning names the wrong host after a stop and start. The public address changed. Run sudo vsftpd-set-hostname (or use an Elastic IP or DNS name, Step 12).
  • vsftpd does not start after you installed a certificate. The key does not match the certificate, or a file is missing. Check journalctl -u vsftpd, install the matching pair (Step 12) and restart.
  • Nothing answers after launch. Check systemctl status vsftpd-firstboot and journalctl -u vsftpd-firstboot, and that the data volume is mounted (findmnt /srv/vsftp). Until first boot completes, the server is deliberately closed.

Security recommendations

  • Allow ports 21 and 40000-40099 only from the addresses your FTP users connect from, and port 22 only from your administrators.
  • Give each person or system its own user, and delete users you no longer need.
  • Compare the certificate fingerprint the first time a client connects, or install a certificate your clients trust (Step 12).
  • Keep the operating system patched. Unattended security upgrades are on.
  • Back up the data volume and the account files (Step 13).
  • If the server must be reachable from anywhere, add fail2ban (Step 9).

Support and licensing

vsftpd is open source under the GNU General Public License, version 2, with an exception for linking with OpenSSL. There is no per-user or per-CPU license fee. The AMI price covers SecureAnyCloud's build, hardening, first-boot setup and support.

vsftpd is the name of the open source Very Secure FTP Daemon by Chris Evans; SecureAnyCloud is not affiliated with, endorsed by or sponsored by the vsftpd author.

  • Email: sales@secureanycloud.com
  • Website: https://secureanycloud.com
  • Support: 24/7, with a 24-hour response SLA

Deploy on AWS

Launch vsftpd 3.0.5 on Ubuntu 24.04 on AWS with 24/7 support from SecureAnyCloud.

View on Marketplace

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on AWS