395 lines
6.5 KiB
Go
395 lines
6.5 KiB
Go
/*
|
|
©AngelaMos | 2026
|
|
handler.go
|
|
|
|
FTP command handler and data channel management for the honeypot
|
|
|
|
Implements the FTP control protocol with USER/PASS authentication,
|
|
PASV data channels for directory listings, and STOR upload capture
|
|
limited to 1MB per file. Presents a realistic ProFTPD environment
|
|
with fake directory listings that mirror an Ubuntu server layout.
|
|
*/
|
|
|
|
package ftpd
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
stateInit = iota
|
|
stateUser
|
|
stateAuth
|
|
)
|
|
|
|
type ftpConn struct {
|
|
ctrl net.Conn
|
|
reader *bufio.Reader
|
|
state int
|
|
username string
|
|
cwd string
|
|
binary bool
|
|
dataListen net.Listener
|
|
}
|
|
|
|
func newFTPConn(conn net.Conn) *ftpConn {
|
|
return &ftpConn{
|
|
ctrl: conn,
|
|
reader: bufio.NewReader(conn),
|
|
cwd: "/",
|
|
}
|
|
}
|
|
|
|
func (f *ftpConn) reply(code int, msg string) {
|
|
fmt.Fprintf(f.ctrl, "%d %s\r\n", code, msg)
|
|
}
|
|
|
|
func (f *ftpConn) readLine() (string, string, error) {
|
|
line, err := f.reader.ReadString('\n')
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
line = strings.TrimRight(line, "\r\n")
|
|
parts := strings.SplitN(line, " ", 2)
|
|
cmd := strings.ToUpper(parts[0])
|
|
|
|
arg := ""
|
|
if len(parts) > 1 {
|
|
arg = parts[1]
|
|
}
|
|
|
|
return cmd, arg, nil
|
|
}
|
|
|
|
func (f *ftpConn) close() {
|
|
if f.dataListen != nil {
|
|
_ = f.dataListen.Close()
|
|
}
|
|
}
|
|
|
|
type cmdResult struct {
|
|
cmd string
|
|
arg string
|
|
quit bool
|
|
password string
|
|
upload []byte
|
|
filename string
|
|
}
|
|
|
|
func (f *ftpConn) dispatch(
|
|
cmd, arg string,
|
|
) cmdResult {
|
|
res := cmdResult{cmd: cmd, arg: arg}
|
|
|
|
switch cmd {
|
|
case "USER":
|
|
f.username = arg
|
|
f.state = stateUser
|
|
f.reply(
|
|
331, "Password required for "+arg,
|
|
)
|
|
|
|
case "PASS":
|
|
f.state = stateAuth
|
|
f.reply(
|
|
230, "User "+f.username+" logged in",
|
|
)
|
|
res.password = arg
|
|
|
|
case "SYST":
|
|
f.reply(215, "UNIX Type: L8")
|
|
|
|
case "FEAT":
|
|
fmt.Fprintf(
|
|
f.ctrl,
|
|
"211-Features:\r\n"+
|
|
" PASV\r\n"+
|
|
" UTF8\r\n"+
|
|
" SIZE\r\n"+
|
|
"211 End\r\n",
|
|
)
|
|
|
|
case "PWD", "XPWD":
|
|
f.reply(
|
|
257,
|
|
fmt.Sprintf(`"%s"`, f.cwd),
|
|
)
|
|
|
|
case "CWD", "XCWD":
|
|
target := resolveFTPPath(arg, f.cwd)
|
|
f.cwd = target
|
|
f.reply(250, "CWD command successful")
|
|
|
|
case "CDUP":
|
|
f.cwd = parentFTPPath(f.cwd)
|
|
f.reply(250, "CWD command successful")
|
|
|
|
case "TYPE":
|
|
f.binary = strings.ToUpper(arg) == "I"
|
|
f.reply(200, "Type set")
|
|
|
|
case "PASV":
|
|
f.openPASV()
|
|
|
|
case "LIST", "NLST":
|
|
f.sendListing()
|
|
|
|
case "RETR":
|
|
if f.dataListen != nil {
|
|
_ = f.dataListen.Close()
|
|
f.dataListen = nil
|
|
}
|
|
f.reply(
|
|
550,
|
|
arg+": No such file or directory",
|
|
)
|
|
|
|
case "STOR":
|
|
data := f.recvUpload()
|
|
if data != nil {
|
|
res.upload = data
|
|
res.filename = arg
|
|
}
|
|
|
|
case "SIZE":
|
|
f.reply(550, arg+": No such file")
|
|
|
|
case "MDTM":
|
|
f.reply(550, arg+": No such file")
|
|
|
|
case "MKD", "XMKD":
|
|
f.reply(
|
|
257,
|
|
fmt.Sprintf(`"%s" created`, arg),
|
|
)
|
|
|
|
case "RMD", "XRMD", "DELE":
|
|
f.reply(250, "OK")
|
|
|
|
case "RNFR":
|
|
f.reply(350, "Awaiting new name")
|
|
|
|
case "RNTO":
|
|
f.reply(250, "Rename successful")
|
|
|
|
case "NOOP":
|
|
f.reply(200, "OK")
|
|
|
|
case "HELP":
|
|
f.reply(214, "No help available")
|
|
|
|
case "QUIT":
|
|
f.reply(221, "Goodbye")
|
|
res.quit = true
|
|
|
|
default:
|
|
f.reply(502, "Command not implemented")
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
func (f *ftpConn) openPASV() {
|
|
if f.dataListen != nil {
|
|
_ = f.dataListen.Close()
|
|
}
|
|
|
|
l, err := net.Listen("tcp", ":0")
|
|
if err != nil {
|
|
f.reply(425, "Cannot open data connection")
|
|
return
|
|
}
|
|
f.dataListen = l
|
|
|
|
tcpAddr, ok := l.Addr().(*net.TCPAddr)
|
|
if !ok {
|
|
f.reply(425, "Cannot open data connection")
|
|
return
|
|
}
|
|
port := tcpAddr.Port
|
|
p1 := port / 256
|
|
p2 := port % 256
|
|
|
|
localAddr, ok := f.ctrl.LocalAddr().(*net.TCPAddr)
|
|
if !ok {
|
|
f.reply(425, "Cannot open data connection")
|
|
return
|
|
}
|
|
ip := localAddr.IP.To4()
|
|
if ip == nil {
|
|
ip = net.ParseIP("127.0.0.1").To4()
|
|
}
|
|
|
|
f.reply(227, fmt.Sprintf(
|
|
"Entering Passive Mode (%d,%d,%d,%d,%d,%d)",
|
|
ip[0], ip[1], ip[2], ip[3], p1, p2,
|
|
))
|
|
}
|
|
|
|
func (f *ftpConn) acceptData() net.Conn {
|
|
if f.dataListen == nil {
|
|
return nil
|
|
}
|
|
defer func() {
|
|
_ = f.dataListen.Close()
|
|
f.dataListen = nil
|
|
}()
|
|
|
|
tcpL, ok := f.dataListen.(*net.TCPListener)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
_ = tcpL.SetDeadline(
|
|
time.Now().Add(10 * time.Second),
|
|
)
|
|
|
|
dc, err := f.dataListen.Accept()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return dc
|
|
}
|
|
|
|
func (f *ftpConn) sendListing() {
|
|
if f.dataListen == nil {
|
|
f.reply(425, "Use PASV first")
|
|
return
|
|
}
|
|
|
|
f.reply(150, "Opening data connection")
|
|
|
|
dc := f.acceptData()
|
|
if dc == nil {
|
|
f.reply(
|
|
425, "Cannot open data connection",
|
|
)
|
|
return
|
|
}
|
|
|
|
_, _ = fmt.Fprint(dc, fakeDirListing(f.cwd))
|
|
_ = dc.Close()
|
|
|
|
f.reply(226, "Transfer complete")
|
|
}
|
|
|
|
func (f *ftpConn) recvUpload() []byte {
|
|
if f.dataListen == nil {
|
|
f.reply(425, "Use PASV first")
|
|
return nil
|
|
}
|
|
|
|
f.reply(150, "Opening data connection")
|
|
|
|
dc := f.acceptData()
|
|
if dc == nil {
|
|
f.reply(
|
|
425, "Cannot open data connection",
|
|
)
|
|
return nil
|
|
}
|
|
|
|
data, _ := io.ReadAll(
|
|
io.LimitReader(dc, 1<<20),
|
|
)
|
|
_ = dc.Close()
|
|
|
|
f.reply(226, "Transfer complete")
|
|
return data
|
|
}
|
|
|
|
func fakeDirListing(cwd string) string {
|
|
now := time.Now()
|
|
month := now.AddDate(0, -1, 0).Format("Jan 02 15:04")
|
|
old := now.AddDate(0, -6, 0).Format("Jan 02 15:04")
|
|
|
|
lines := []string{
|
|
fmt.Sprintf(
|
|
"drwxr-xr-x 2 root root 4096 %s .",
|
|
month,
|
|
),
|
|
fmt.Sprintf(
|
|
"drwxr-xr-x 3 root root 4096 %s ..",
|
|
month,
|
|
),
|
|
}
|
|
|
|
if cwd == "/" {
|
|
lines = append(lines,
|
|
fmt.Sprintf(
|
|
"drwxr-xr-x 2 root root 4096 %s bin",
|
|
month,
|
|
),
|
|
fmt.Sprintf(
|
|
"drwxr-xr-x 5 root root 4096 %s etc",
|
|
month,
|
|
),
|
|
fmt.Sprintf(
|
|
"drwxr-xr-x 3 root root 4096 %s home",
|
|
month,
|
|
),
|
|
fmt.Sprintf(
|
|
"drwxr-xr-x 2 root root 4096 %s tmp",
|
|
month,
|
|
),
|
|
fmt.Sprintf(
|
|
"drwxr-xr-x 8 root root 4096 %s var",
|
|
month,
|
|
),
|
|
fmt.Sprintf(
|
|
"-rw-r--r-- 1 root root 220 %s .bashrc",
|
|
old,
|
|
),
|
|
)
|
|
}
|
|
|
|
return strings.Join(lines, "\r\n") + "\r\n"
|
|
}
|
|
|
|
func resolveFTPPath(path, cwd string) string {
|
|
if strings.HasPrefix(path, "/") {
|
|
return cleanFTPPath(path)
|
|
}
|
|
if cwd == "/" {
|
|
return cleanFTPPath("/" + path)
|
|
}
|
|
return cleanFTPPath(cwd + "/" + path)
|
|
}
|
|
|
|
func parentFTPPath(path string) string {
|
|
if path == "/" {
|
|
return "/"
|
|
}
|
|
parts := strings.Split(path, "/")
|
|
if len(parts) <= 2 {
|
|
return "/"
|
|
}
|
|
return strings.Join(parts[:len(parts)-1], "/")
|
|
}
|
|
|
|
func cleanFTPPath(path string) string {
|
|
parts := strings.Split(path, "/")
|
|
var clean []string
|
|
for _, p := range parts {
|
|
if p == "" || p == "." {
|
|
continue
|
|
}
|
|
if p == ".." {
|
|
if len(clean) > 0 {
|
|
clean = clean[:len(clean)-1]
|
|
}
|
|
continue
|
|
}
|
|
clean = append(clean, p)
|
|
}
|
|
if len(clean) == 0 {
|
|
return "/"
|
|
}
|
|
return "/" + strings.Join(clean, "/")
|
|
}
|