. "$BST_LIB" # BST-TIMEOUT: 900 # proftpd 1.3.7f (meta-networking recipes-daemons/proftpd) -- the FTP SERVER, and # the first thing on this image that authenticates a local account over a network # protocol. inetutils ships an ftpd too, but nothing before this verified a # password against /etc/shadow from the wire. # # Seven facts shape the file, and two of them decide how it is driven. # # 1. IT NEVER DROPS PRIVILEGE HERE, AND THAT IS ASSERTED RATHER THAN IGNORED. # arch/wasm32 wires no setuid/setgid/setreuid/setregid/setgroups # (brintos/linux#19), so every credential call answers ENOSYS. Unlike atftpd # and in.tftpd -- which exit(EX_OSERR) on exactly this and serve nothing -- # proftpd's src/privs.c LOGS the failure and carries on (privs_log_error() # then `return 0', on every arm of pr_privs_setup), so the server works and # runs as root throughout. That is a real divergence from what proftpd # promises on a normal host, so it is read out of the server's own log. # # 2. THE SESSION IS DRIVEN UNDER inetd, NOT UNDER THE STANDALONE DAEMON, AND # THAT IS A PLATFORM WORKAROUND -- measured, not assumed. Standalone proftpd # binds, listens, accepts and forks here, and its forked child gets as far as # logging `FTP session opened.' and writing the 220 banner. The client never # receives a byte of it, and the kernel says why on the console: # # [k] hwjs-rt VIOLATION[lane N]: orphan at clear: parked slot not empty # at bracket entry (... [name-of-owner-word: ] ...) # [k] hwjs #85 CONTAINMENT pid=0 (wasm_syscall nr=89): trapped on a lane # poisoned by a dead Worker (unreachable); lane reclaimed, # returning -EINTR to the caller # # -- i.e. the forked child's Worker dies in the coroutine runtime and the # lane is reclaimed. Filed as brintos/hwjs-cc#80, with the two hypotheses it # is NOT (a failed dup2; proftpd's own netio) ruled out there by measurement. # Under inetutils' inetd, which hands proftpd an # already-accepted socket on fd 0/1 and no fork of proftpd's own, the very # same binary and the very same config serve the whole session. So the # standalone half is asserted as a red of its own (proftpd/standalone-serves) # and everything the ticket names is driven through the mode that works. # ServerType inetd is upstream's own first-class mode, not a test-only hack. # # 3. PersistentPasswd on IS LOAD-BEARING, and there is a negative control for # it. proftpd's post-login setup enumerates the passwd database; with the # directive OFF that goes through glibc's NSS getpwent(), which TRAPS on this # arm (brintos/hwjs-cc#79) -- the session dies on SIGILL with the password # already verified # (`ROOT FTP login successful' in the log, then `reaped, status 0x4' from the # super-server). With it ON mod_auth_unix keeps its own FILE* and reads with # fgetpwent(3) instead, and the same session answers 230 and then 257. # # 4. THE SERVER MUST BE GIVEN A NUMERIC ADDRESS. With no DefaultAddress proftpd # resolves its OWN hostname at startup, and that is a wall in two ways -- # IPv6 on: the process TRAPS inside glibc's nss_dns gethosts() # (brintos/hwjs-cc#79); UseIPv6 off: # it fails cleanly on the loopback-only AI_ADDRCONFIG wall this layer already # records for tftp-hpa. Both are asserted below. `DefaultAddress 0.0.0.0' is # what the package's own /etc/proftpd.conf already carries. # # 5. THE IMAGE SHIPS AN /etc/shadow IN WHICH EVERY PASSWORD IS DISABLED, mode # 0400. This suite copies it, makes it writable, writes an entry of its own # -- sha512-crypt, computed off target, which is also what proves libxcrypt # is doing the work, since this glibc has no crypt() at all -- and puts both # the contents and the mode back. The chmod is not a formality: the root # filesystem is a virtiofs passthrough, so the HOST's bits decide and 0400 # refuses a write however privileged the guest's uid 0 looks from inside. # # 6. NOTHING HERE WAITS ON A SIGNAL. Signal delivery is unreliable # (brintos/linux#21), so timeout(1) is decorative and `wait' can block # forever; every bound is an iteration count or a tcl [after], and proftpd's # own alarm(2)-driven timers are disabled outright. # # 7. NO chroot AND NO reverse DNS. DefaultRoot would put chroot(2) on a # substrate this ticket does not measure, so the session is placed with # DefaultChdir; UseReverseDNS is off because the guest is loopback-only with # no resolver. Every directive used is one of the ten modules this build # links -- mod_core, mod_xfer, mod_rlimit, mod_auth_unix, mod_auth, mod_ls, # mod_log, mod_site, mod_delay, mod_facts. `IdentLookups' is NOT among them, # which proftpd answers with `fatal: unknown configuration directive'. require /usr/sbin/proftpd proftpd/present PROFTPD=/usr/sbin/proftpd INETD=/usr/bin/inetd.inetutils PORT=8921 SPORT=8922 OFFPORT=8923 USER_=root PW=bstproftpd HASH='$6$bstftp01$2hL/va8uzEtlAkuIV.0Iam9rZRb6as1faWUllqsTIKfTHhe8XH0er/6iQEj.h.6inuCRlKlPoaKGjLR/I2znp0' D=/tmp/.bst-proftpd rm -rf "$D"; mkdir -p "$D/root" || { bst_notok proftpd/workdir "cannot create $D"; exit 0; } # ~8 KB of fixed text, so a short transfer is visible while staying affordable. awk 'BEGIN{for(n=1;n<=140;n++) printf "%04d the quick brown fox jumps over the lazy dog 0123456789\n", n}' > "$D/root/payload" chmod 644 "$D/root/payload" # --- identity ----------------------------------------------------------------- check_out proftpd/version "1.3.7f" "$PROFTPD" -v check proftpd/ftpwho test -x /usr/bin/ftpwho check proftpd/ftpcount test -x /usr/bin/ftpcount check proftpd/ftpshut test -x /usr/sbin/ftpshut check proftpd/stock-conf test -f /etc/proftpd.conf # ftpdctl is BUILT AND INERT, and that is upstream's default rather than a # defect: mod_ctrls is off unless --enable-ctrls is asked for, so the binary # ships and answers with the one thing it can say. Asserted rather than left # out, so enabling ctrls later is a visible change and not a silent one. out=$(/usr/bin/ftpdctl -h 2>&1) case "$out" in *"Controls support disabled"*) bst_ok proftpd/ftpdctl-ctrls-off ;; *) bst_notok proftpd/ftpdctl-ctrls-off "$(_bst_1line "$out")" ;; esac # --- the configurations this suite drives ------------------------------------- # One template, three instances: the inetd service that carries the session, a # standalone daemon for the fork case, and an inetd service with # PersistentPasswd off as fact 3's negative control. mkconf() { # mkconf cat > "$D/$1.conf" < AllowAll AllowAll EOC } mkconf main inetd "$PORT" "PersistentPasswd on" mkconf alone standalone "$SPORT" "PersistentPasswd on" mkconf ppoff inetd "$OFFPORT" "PersistentPasswd off" # The ticket's offline half: the same file the server is about to run, validated # without starting anything. --configtest is a LONG option and exists only # because this port selects the getopt PACKAGECONFIG; see the bbappend. # # The verdict is the EXIT STATUS plus the banner proftpd prints when it enters # the mode -- deliberately NOT its closing `Syntax check complete.' line, which # is a printf(3) to stdout followed by pr_session_end() -> _exit(), so it is # lost whenever stdout is a pipe rather than a terminal and asserting it would # make this case pass or fail on how the suite was invoked. The paired # configtest-rejects case below is what stops `rc -eq 0' from being vacuous. out=$("$PROFTPD" --configtest -c "$D/main.conf" 2>&1); rc=$? case "$out" in *"Checking syntax of configuration file"*) hit=1 ;; *) hit=0 ;; esac if [ "$rc" -eq 0 ] && [ "$hit" -eq 1 ]; then bst_ok proftpd/configtest else bst_notok proftpd/configtest "rc=$rc hit=$hit: $(_bst_1line "$out")"; fi # A config that is WRONG must be refused, or the case above would pass against a # --configtest that validates nothing. printf 'ServerName "x"\nNoSuchDirective yes\n' > "$D/bad.conf" out=$("$PROFTPD" --configtest -c "$D/bad.conf" 2>&1); rc=$? if [ "$rc" -ne 0 ]; then bst_ok proftpd/configtest-rejects else bst_notok proftpd/configtest-rejects "an unknown directive was accepted: $(_bst_1line "$out")"; fi # --- fact 4's two walls, each from its own config ----------------------------- printf 'ServerName "w"\nServerType standalone\nPort %s\nUser root\nGroup root\n' "$SPORT" > "$D/trap.conf" out=$("$PROFTPD" --configtest -c "$D/trap.conf" 2>&1); rc=$? case "$rc|$out" in 132\|*|*"Illegal instruction"*|*"signature mismatch"*) bst_ok proftpd/hostname-lookup-traps ;; *) bst_notok proftpd/hostname-lookup-traps "brintos/hwjs-cc#79: the own-hostname lookup no longer traps (rc=$rc) -- if glibc's nss_dns gethosts has been fixed on this arm, re-read this case and the DefaultAddress line above: $(_bst_1line "$out")" ;; esac printf 'ServerName "w"\nServerType standalone\nUseIPv6 off\nPort %s\nUser root\nGroup root\n' "$SPORT" > "$D/addrconfig.conf" out=$("$PROFTPD" --configtest -c "$D/addrconfig.conf" 2>&1); rc=$? case "$out" in *"unable to determine IP address"*|*"no valid servers configured"*) bst_ok proftpd/hostname-lookup-addrconfig ;; *) bst_notok proftpd/hostname-lookup-addrconfig "expected the AI_ADDRCONFIG wall on a loopback-only guest; got rc=$rc: $(_bst_1line "$out")" ;; esac # --- the shadow entry (fact 5) ------------------------------------------------ had=0 if [ -e /etc/shadow ]; then had=1 cp -p /etc/shadow "$D/shadow.orig" 2>/dev/null || had=2 chmod 0600 /etc/shadow 2>/dev/null fi net_skip() { for c in inetd-registered standalone-startup standalone-connect \ standalone-serves connect greeting user pass badpass cwd pwd type \ list list-final list-names retr-open retr-bytes retr-final \ retr-bytewise stor-open stor-final stor-landed size nak quit \ login-logged privdrop-logged persistent-passwd-required; do bst_skip "proftpd/$c" "$1" done } if ! command -v tclsh >/dev/null 2>&1; then net_skip "tclsh not installed; the FTP data connection needs a real client" elif [ ! -x "$INETD" ]; then net_skip "inetutils' inetd is not installed, and fact 2 says the session is driven under it" elif [ "$had" -eq 2 ]; then net_skip "this image ships an /etc/shadow that could not be backed up; refusing to overwrite it" elif ! printf '%s:%s:19000:0:99999:7:::\n' "$USER_" "$HASH" > /etc/shadow 2>/dev/null; then net_skip "/etc/shadow is not writable even after chmod 0600 (mode now $(ls -l /etc/shadow 2>/dev/null | cut -c1-10)), so there is no password for proftpd to verify against" else chmod 0600 /etc/shadow 2>/dev/null # --- fact 2: the standalone daemon, asserted as far as it gets --------------- "$PROFTPD" -n -d 3 -c "$D/alone.conf" > "$D/alone.log" 2>&1 & i=0 while [ $i -lt 60 ]; do grep -q "standalone mode STARTUP" "$D/alone.log" 2>/dev/null && break i=$((i+1)); sleep 1 done if grep -q "standalone mode STARTUP" "$D/alone.log" 2>/dev/null; then bst_ok proftpd/standalone-startup else bst_notok proftpd/standalone-startup "no STARTUP after $i polls: $(_bst_1line "$(cat "$D/alone.log" 2>/dev/null)")" fi SOUT=$(tclsh "$BST_DATA/proftpd-client.tcl" "$SPORT" "$USER_" "$PW" "$D/root" \ payload "$D/sgot" "$D/root/payload" suploaded 2>&1) sval() { printf '%s\n' "$SOUT" | awk -v k="$1" '$1==k{print $2; exit}'; } if [ "$(sval R0-connect)" = "1" ]; then bst_ok proftpd/standalone-connect else bst_notok proftpd/standalone-connect "the standalone daemon did not accept: $(_bst_1line "$SOUT")"; fi # THE RED THAT SURFACES THE GAP. It turns green by itself the day the forked # child's Worker survives; nothing in proftpd has to change for that. if [ "$(sval R1-greeting)" = "220" ]; then bst_ok proftpd/standalone-serves else bst_notok proftpd/standalone-serves "brintos/hwjs-cc#80: the standalone daemon accepts and forks, its child logs 'FTP session opened.' and writes the banner, and the client receives nothing -- the child's Worker dies in the coroutine runtime and the kernel reclaims the lane (hwjs-rt VIOLATION / hwjs #85 CONTAINMENT, returning -EINTR). Under inetd the same binary and config serve the whole session; see the cases below. daemon=[$(tail -3 "$D/alone.log" 2>/dev/null | tr '\n' '|')]" fi kill %1 2>/dev/null # --- the session, under inetd ------------------------------------------------ printf '127.0.0.1:%s stream tcp nowait root %s proftpd -c %s\n' \ "$PORT" "$PROFTPD" "$D/main.conf" > "$D/inetd.tab" printf '127.0.0.1:%s stream tcp nowait root %s proftpd -c %s\n' \ "$OFFPORT" "$PROFTPD" "$D/ppoff.conf" >> "$D/inetd.tab" "$INETD" -d -p "$D/inetd.pid" "$D/inetd.tab" > "$D/inetd.log" 2>&1 & i=0 while [ $i -lt 40 ]; do [ "$(grep -c 'registered ' "$D/inetd.log" 2>/dev/null)" -ge 2 ] && break i=$((i+1)); sleep 1 done if [ "$(grep -c 'registered ' "$D/inetd.log" 2>/dev/null)" -ge 2 ]; then bst_ok proftpd/inetd-registered else bst_notok proftpd/inetd-registered "inetd bound $(grep -c 'registered ' "$D/inetd.log" 2>/dev/null) of 2 services after $i polls: $(_bst_1line "$(cat "$D/inetd.log" 2>/dev/null)")" fi OUT=$(tclsh "$BST_DATA/proftpd-client.tcl" "$PORT" "$USER_" "$PW" "$D/root" \ payload "$D/got" "$D/root/payload" uploaded 2>&1) val() { printf '%s\n' "$OUT" | awk -v k="$1" '$1==k{print $2; exit}'; } slog() { tail -6 "$D/sys-main.log" 2>/dev/null | tr '\n' '|'; } want() { # want _v=$(val "$2") if [ "$_v" = "$3" ]; then bst_ok "proftpd/$1" elif [ -z "$_v" ]; then bst_notok "proftpd/$1" "client printed no $2 line: $(_bst_1line "$OUT") -- server=[$(slog)]" else bst_notok "proftpd/$1" "$2=$_v want $3: $(_bst_1line "$OUT") -- server=[$(slog)]"; fi } want connect R0-connect 1 want greeting R1-greeting 220 want user R2-user 331 want pass R3-pass 230 want badpass R4-badpass 530 want cwd R5-cwd 250 want pwd R6-pwd "$D/root" want type R7-type 200 want list-final R8-final 226 want list-names R8-names 1 want retr-open R9-open 150 want retr-final R9-final 226 want stor-open R10-open 150 want stor-final R10-stor 226 want nak R12-nak 550 want quit R13-quit 221 # A listing that transferred nothing is not a listing. n=$(val R8-list) if [ -n "$n" ] && [ "$n" -gt 0 ] 2>/dev/null; then bst_ok proftpd/list else bst_notok proftpd/list "LIST moved ${n:-no} bytes -- server=[$(slog)]"; fi # The retrieved file, by size and then byte for byte. Split deliberately: a # transfer that "succeeds" having moved the wrong bytes is the failure a size # check cannot see. want_sz=$(wc -c < "$D/root/payload" | tr -d ' ') got_sz=$(val R9-retr) if [ "$got_sz" = "$want_sz" ]; then bst_ok proftpd/retr-bytes else bst_notok proftpd/retr-bytes "RETR moved ${got_sz:-no} bytes of $want_sz -- server=[$(slog)]"; fi if cmp -s "$D/root/payload" "$D/got" 2>/dev/null; then bst_ok proftpd/retr-bytewise else bst_notok proftpd/retr-bytewise "retrieved copy differs from the source ($(wc -c < "$D/got" 2>/dev/null | tr -d ' ') bytes on disk)"; fi if cmp -s "$D/root/payload" "$D/root/uploaded" 2>/dev/null; then bst_ok proftpd/stor-landed else bst_notok proftpd/stor-landed "uploaded copy differs or is absent ($(wc -c < "$D/root/uploaded" 2>/dev/null | tr -d ' ') bytes) -- server=[$(slog)]"; fi if [ "$(val R11-size)" = "$want_sz" ]; then bst_ok proftpd/size else bst_notok proftpd/size "SIZE said $(val R11-size), want $want_sz"; fi # The server's own account of the login, which is the half a client cannot see. if grep -q "FTP login successful" "$D/sys-main.log" 2>/dev/null; then bst_ok proftpd/login-logged else bst_notok proftpd/login-logged "the server logged no successful login: $(slog)"; fi # Fact 1, from the server's own words. Expected to hold while # brintos/linux#19 is open; when it closes the message goes away because the # calls start working, and this case then needs re-reading, not the port. if grep -qE "unable to (setuid|setgid|seteuid|setegid)" "$D/sys-main.log" "$D/alone.log" 2>/dev/null; then bst_ok proftpd/privdrop-logged else bst_notok proftpd/privdrop-logged "brintos/linux#19: expected proftpd to log a failed credential change and carry on; it logged none -- either the syscalls landed (re-read this case) or the daemon never reached PRIVS_SETUP: $(slog)" fi # Fact 3's negative control: the same session against PersistentPasswd off must # NOT complete, or the directive above is cargo. POUT=$(tclsh "$BST_DATA/proftpd-client.tcl" "$OFFPORT" "$USER_" "$PW" "$D/root" \ payload "$D/pgot" "$D/root/payload" puploaded 2>&1) pval() { printf '%s\n' "$POUT" | awk -v k="$1" '$1==k{print $2; exit}'; } if [ "$(pval R3-pass)" = "230" ]; then bst_notok proftpd/persistent-passwd-required "brintos/hwjs-cc#79: PersistentPasswd off now completes a login too, so the directive in the config above is no longer load-bearing -- if glibc's getpwent() has been fixed on this arm, re-read this case: $(_bst_1line "$POUT")" else bst_ok proftpd/persistent-passwd-required fi kill %1 2>/dev/null # cp -p restores the 0400 the image shipped along with the contents. if [ "$had" -eq 1 ]; then cp -p "$D/shadow.orig" /etc/shadow; else rm -f /etc/shadow; fi fi # --- what this image does not carry ------------------------------------------- bst_skip proftpd/man-pages "proftpd.8, ftpdctl.8 and the rest are packaged into proftpd-doc, and this image installs no -doc packages" rm -rf "$D"