. "$BST_LIB" # BST-TIMEOUT: 300 # pure-ftpd 1.0.52 (meta-networking recipes-daemons/pure-ftpd) -- the FTP SERVER # and its companion tools, the security-first counterpart to proftpd. Both are on # this image; what makes this one worth its own suite is that it treats every # credential call as fatal where proftpd logs and carries on, so it exercises # brintos/linux#19 from the strict side. # # Six facts shape the file, and the first two decide how it is driven. # # 1. THE SESSION IS DRIVEN UNDER inetd, AND THAT IS pure-ftpd's OWN DEFAULT # RATHER THAN A TEST HARNESS. main() calls check_standalone(), which is # getsockname(0, ...): a socket on fd 0 means a super-server has already # accepted the connection, so it dup2()s to fd 1 and runs the whole session # in THIS process with no fork of its own (ftpd.c:4732, :6216). Only the # -S/standalone arm forks per connection, and that is the arm this substrate # breaks -- see fact 2. # # 2. THE STANDALONE DAEMON IS ASSERTED AS FAR AS IT GETS, AND NO FURTHER. # proftpd measured this on the same image (BRINTOS-1054): a forked child # cannot move bytes on the accepted socket it inherited, because the child's # Worker dies in the coroutine runtime and the kernel reclaims the lane # (brintos/hwjs-cc#80). pure-ftpd forks the same way, so the standalone case # is driven in `greet' mode only -- accept, then whether a banner arrives -- # and a red there is that gap rather than this port's. # # 3. THE CREDENTIAL SETTERS ARE ENOSYS AND pure-ftpd DIES ON THEM (linux#19), # WHICH IS WHY THIS PORT CARRIES A PATCH AND WHY THAT PATCH HAS A CONTROL. # Unpatched, a CORRECT password on a valid account answers `421 Sorry, but I # can't trust you' -- doinitsupgroups()'s setgroups(1U, &gid) returns # -1/ENOSYS and ftpd.c:1756 turns that into die(421). Patch 0001 accepts # ENOSYS at that site and the two that follow it, and ONLY where the call # would have changed nothing. pure-ftpd/credential-enosys-scoped is the # negative control for exactly that scope: the SAME server, over the same # /etc/passwd and /etc/shadow, logged into twice as accounts that differ in # nothing but their uid and gid -- root at 0/0 and a scratch account at # 1000/1000, same password, same home, same shell. The first must reach 230 # and the second must not, because for the second the refused setgid IS a # privilege change. If both logged in the patch would be a hole rather than a # workaround, and this case says so; if NEITHER did, the case says that too # rather than reading the shared failure as a pass. # # The uid-1000 half needs an /etc/passwd entry, so this suite edits that file # as well as /etc/shadow and restores both. A pair of -l puredb virtual users # was the tidier vehicle and is not available twice over: the stock recipe # passes no --with-puredb, so the server answers `Unknown authentication # method: puredb', and pure-pw refuses to create a uid-0 virtual user at all, # so the positive half could not have been built that way either. pure-pw is # still exercised, offline, at the end of this file -- which is what the # ticket asks of it, and where the note there explains why it is last. # # 4. THE IMAGE SHIPS AN /etc/shadow IN WHICH EVERY PASSWORD IS DISABLED # (`root:*'), 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. # # 5. THE SERVER IS GIVEN -4 -H AND NO CHROOT. -4 keeps it off the loopback-only # AI_ADDRCONFIG wall this layer records for tftp-hpa; -H stops the reverse # lookup of the client address on a guest with no resolver; no -A/-a means no # chroot(2), which this ticket does not measure. -E refuses anonymous logins, # so the `ftp'-account path -- which has credential calls of its own that # patch 0001 deliberately does NOT touch -- is never entered. # # 6. NOTHING HERE WAITS ON A SIGNAL. Signal delivery is unreliable # (brintos/linux#21), so every bound is an iteration count or a tcl [after]. # pure-ftpd's own randomsleep() before a failed-login reply is why the bad # password gets a longer bound inside the client than every other reply. require /usr/sbin/pure-ftpd pure-ftpd/present PF=/usr/sbin/pure-ftpd PW_=/usr/bin/pure-pw INETD=/usr/bin/inetd.inetutils CLIENT="$BST_DATA/pure-ftpd-client.tcl" PORT=8931 SPORT=8932 USER_=root # The negative-control account. Identical to root in this suite's eyes -- same # password, same home, same shell -- and different in the one thing the control # is about. LOWUSER=bstlow LOWUID=1000 PW=bstpureftpd # sha512-crypt of $PW, computed off target. Verifying it is what proves # libxcrypt resolved crypt(3) -- this glibc's pool has no crypt symbol at all. HASH='$6$bstpureftp$XA11w57OwjGvyJeHLuxoKmiXKLNclb/.R1/kxfr8ly6VLqEk8YftGDFDMzA0aY/ZNJacIlqjihSlZX80yuCVa1' D=/tmp/.bst-pure-ftpd rm -rf "$D"; mkdir -p "$D/root" || { bst_notok pure-ftpd/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 pure-ftpd/version "1.0.52" "$PF" --help # The build's own account of itself. `[privsep]' is in the version banner because # WITHOUT_PRIVSEP is not defined, and it is asserted here because --with-nonroot # was the alternative this port declined: taking it would have compiled privsep, # chroot and the capability drops out, and this line is what would notice. out=$("$PF" --help 2>&1 | head -1) case "$out" in *"[privsep]"*) bst_ok pure-ftpd/privsep-build ;; *) bst_notok pure-ftpd/privsep-build "the version banner no longer reports privsep, so this is a different build than the port measured: $(_bst_1line "$out")" ;; esac check pure-ftpd/pure-pw test -x /usr/bin/pure-pw check pure-ftpd/pure-pwconvert test -x /usr/bin/pure-pwconvert check pure-ftpd/pure-statsdecode test -x /usr/bin/pure-statsdecode check pure-ftpd/pure-ftpwho test -x /usr/sbin/pure-ftpwho check pure-ftpd/pure-authd test -x /usr/sbin/pure-authd check pure-ftpd/pure-certd test -x /usr/sbin/pure-certd check pure-ftpd/pure-quotacheck test -x /usr/sbin/pure-quotacheck check pure-ftpd/pure-uploadscript test -x /usr/sbin/pure-uploadscript check pure-ftpd/pure-mrtginfo test -x /usr/sbin/pure-mrtginfo check pure-ftpd/stock-conf test -f /etc/pure-ftpd.conf # --- the shadow entry (fact 4) ------------------------------------------------ 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 # /etc/passwd and /etc/group carry the control account and are restored the same # way. Backed up BEFORE anything is written, and only written at all if both # copies succeeded -- a suite that cannot put an image's account database back # has no business editing it. pwok=0 if cp -p /etc/passwd "$D/passwd.orig" 2>/dev/null && cp -p /etc/group "$D/group.orig" 2>/dev/null; then pwok=1 fi net_skip() { for c in inetd-registered standalone-startup standalone-serves \ standalone-session 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 \ control-account credential-enosys-scoped; do bst_skip "pure-ftpd/$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 1 says the session is driven under it" elif [ "$had" -eq 2 ] || [ "$pwok" -ne 1 ]; then net_skip "this image's account database could not be backed up (shadow=$had passwd/group=$pwok); refusing to overwrite it" elif ! { printf '%s:%s:19000:0:99999:7:::\n' "$USER_" "$HASH"; printf '%s:%s:19000:0:99999:7:::\n' "$LOWUSER" "$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 pure-ftpd to verify against" else chmod 0600 /etc/shadow 2>/dev/null # The control account, appended rather than rewritten so the image's own # entries are untouched. /bin/sh is already in /etc/shells, which pure-ftpd # checks with getusershell(3) before it looks at a password at all. printf '%s:x:%s:%s:brintos selftest:%s:/bin/sh\n' "$LOWUSER" "$LOWUID" "$LOWUID" "$D/root" >> /etc/passwd 2>/dev/null printf '%s:x:%s:\n' "$LOWUSER" "$LOWUID" >> /etc/group 2>/dev/null if id "$LOWUSER" >/dev/null 2>&1; then bst_ok pure-ftpd/control-account else bst_notok pure-ftpd/control-account "the uid-$LOWUID control account did not resolve after being written to /etc/passwd, so credential-enosys-scoped below has only one arm"; fi # --- fact 2: the standalone daemon, asserted as far as it gets --------------- "$PF" -4 -H -E -S "$SPORT" -p 30100:30120 > "$D/alone.log" 2>&1 & alone_pid=$! # The loop costs one greeting bound at most: a daemon that is not listening # yet refuses the connect immediately, and the first connect that SUCCEEDS # ends the loop whether or not a banner follows -- which is the whole point, # because under hwjs-cc#80 the banner never does. i=0 while [ $i -lt 20 ]; do i=$((i+1)) tclsh "$CLIENT" "$SPORT" x x x x x x x greet > "$D/probe.out" 2>&1 grep -q '^R0-connect 1' "$D/probe.out" 2>/dev/null && break sleep 1 done if grep -q '^R0-connect 1' "$D/probe.out" 2>/dev/null; then bst_ok pure-ftpd/standalone-startup else bst_notok pure-ftpd/standalone-startup "the standalone daemon did not accept on $SPORT after $i polls: $(_bst_1line "$(cat "$D/alone.log" 2>/dev/null)")" fi # WHETHER THE FORKED CHILD CAN TALK IS THE INTERESTING HALF, AND IT IS # MEASURED HERE RATHER THAN ASSUMED. proftpd's forked child could not # (brintos/hwjs-cc#80): it logged a complete session open, wrote its banner, # and the client received nothing. pure-ftpd forks the same way, so this case # is the second witness either way -- a red repeats #80 on a second package, a # green says #80 does not reach this fork and the issue is narrower than one # package's evidence suggested. if grep -q '^R1-greeting 220' "$D/probe.out" 2>/dev/null; then bst_ok pure-ftpd/standalone-serves else bst_notok pure-ftpd/standalone-serves "brintos/hwjs-cc#80: the standalone daemon accepts and forks, and the client gets no banner from the child. Under inetd the same binary serves the whole session; see the cases below. client=[$(_bst_1line "$(cat "$D/probe.out" 2>/dev/null)")] daemon=[$(tail -3 "$D/alone.log" 2>/dev/null | tr '\n' '|')]" fi # A banner is not a session. This drives the WHOLE protocol through the forked # child -- login, both data connections, a byte-exact RETR -- because that is # what separates "the child wrote one buffer before it died" from "the child # serves". Only reached when the banner arrived, so a #80-shaped failure costs # this case one line rather than every bound in the client. if grep -q '^R1-greeting 220' "$D/probe.out" 2>/dev/null; then SOUT=$(tclsh "$CLIENT" "$SPORT" "$USER_" "$PW" "$D/root" \ payload "$D/sgot" "$D/root/payload" suploaded 2>&1) spass=$(printf '%s\n' "$SOUT" | awk '$1=="R3-pass"{print $2; exit}') squit=$(printf '%s\n' "$SOUT" | awk '$1=="R13-quit"{print $2; exit}') if [ "$spass" = "230" ] && [ "$squit" = "221" ] && cmp -s "$D/root/payload" "$D/sgot" 2>/dev/null; then bst_ok pure-ftpd/standalone-session else bst_notok pure-ftpd/standalone-session "the standalone child greeted but did not carry a whole session: PASS=${spass:-none} QUIT=${squit:-none} retr=$(wc -c < "$D/sgot" 2>/dev/null | tr -d ' ')/$(wc -c < "$D/root/payload" | tr -d ' ') bytes -- $(_bst_1line "$SOUT")" fi else bst_skip pure-ftpd/standalone-session "the standalone child never greeted, so there is no session to drive -- see pure-ftpd/standalone-serves" fi kill "$alone_pid" 2>/dev/null # --- the session, under inetd ------------------------------------------------ # ONE service. Both arms of the credential control go through it, because the # control's whole claim is that the SERVER is the same and only the account # differs; a second daemon on a second port would put another variable in. printf '127.0.0.1:%s stream tcp nowait root %s pure-ftpd -4 -H -E -p 30000:30020\n' \ "$PORT" "$PF" > "$D/inetd.tab" "$INETD" -d "$D/inetd.tab" > "$D/inetd.log" 2>&1 & inetd_pid=$! i=0 while [ $i -lt 40 ]; do grep -q 'registered ' "$D/inetd.log" 2>/dev/null && break i=$((i+1)); sleep 1 done if grep -q 'registered ' "$D/inetd.log" 2>/dev/null; then bst_ok pure-ftpd/inetd-registered else bst_notok pure-ftpd/inetd-registered "inetd bound no service after $i polls: $(_bst_1line "$(cat "$D/inetd.log" 2>/dev/null)")" fi OUT=$(tclsh "$CLIENT" "$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}'; } want() { # want _v=$(val "$2") if [ "$_v" = "$3" ]; then bst_ok "pure-ftpd/$1" elif [ -z "$_v" ]; then bst_notok "pure-ftpd/$1" "client printed no $2 line: $(_bst_1line "$OUT")" else bst_notok "pure-ftpd/$1" "$2=$_v want $3: $(_bst_1line "$OUT")"; 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 pure-ftpd/list else bst_notok pure-ftpd/list "LIST moved ${n:-no} bytes"; 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 pure-ftpd/retr-bytes else bst_notok pure-ftpd/retr-bytes "RETR moved ${got_sz:-no} bytes of $want_sz"; fi if cmp -s "$D/root/payload" "$D/got" 2>/dev/null; then bst_ok pure-ftpd/retr-bytewise else bst_notok pure-ftpd/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 pure-ftpd/stor-landed else bst_notok pure-ftpd/stor-landed "uploaded copy differs or is absent ($(wc -c < "$D/root/uploaded" 2>/dev/null | tr -d ' ') bytes)"; fi if [ "$(val R11-size)" = "$want_sz" ]; then bst_ok pure-ftpd/size else bst_notok pure-ftpd/size "SIZE said $(val R11-size), want $want_sz"; fi # --- fact 3's negative control ------------------------------------------------ # The same server, the same /etc/passwd and /etc/shadow, the same password and # the same home. One variable: the account's uid/gid. root at 0/0 is the # positive half and has already logged in above; it is re-read here from that # run rather than re-driven, so the two arms are the same server generation. # bstlow at 1000/1000 must NOT get in: for it the setgid patch 0001 tolerates # for root is a real privilege change, and upstream's _EXIT still fires. LOW=$(tclsh "$CLIENT" "$PORT" "$LOWUSER" "$PW" "$D/root" \ payload "$D/lowgot" "$D/root/payload" lowup 2>&1) c0=$(val R3-pass) c1=$(printf '%s\n' "$LOW" | awk '$1=="R3-pass"{print $2; exit}') if [ "$c0" = "230" ] && [ "$c1" != "230" ]; then bst_ok pure-ftpd/credential-enosys-scoped elif [ "$c0" != "230" ]; then bst_notok pure-ftpd/credential-enosys-scoped "the POSITIVE half did not log in (root answered ${c0:-nothing}), so the uid-$LOWUID refusal proves nothing about patch 0001's scope -- read pure-ftpd/pass before trusting this case" else bst_notok pure-ftpd/credential-enosys-scoped "patch 0001 has widened: an account at uid $LOWUID reached 230, so ENOSYS was accepted for a credential change that was a REAL privilege drop. Either the patch's no-op test is wrong or brintos/linux#19 has been fixed and the whole patch should go: $(_bst_1line "$LOW")" fi kill "$inetd_pid" 2>/dev/null # cp -p restores the 0400 the image shipped along with the contents, and the # account database goes back whole rather than by deleting the lines this # suite appended. if [ "$had" -eq 1 ]; then cp -p "$D/shadow.orig" /etc/shadow; else rm -f /etc/shadow; fi cp -p "$D/passwd.orig" /etc/passwd 2>/dev/null cp -p "$D/group.orig" /etc/group 2>/dev/null fi # --- pure-pw, offline (the ticket's second half) -------------------------------- # LAST IN THE FILE, DELIBERATELY, AND FED THROUGH A PIPE -- for a reason that is # an OBSERVATION, not a diagnosis, and is written that way on purpose. # # pure-pw useradd reads its password with two fgets(stdin) it cannot be talked # out of (pure-pw.c:774, :782; there is no password-from-file option) and then # hashes it with crypt(3) at bcrypt cost 10 (best_crypt, pure-pw.c:326-340). # With the password REDIRECTED FROM A FILE in /tmp and two useradds in the # block, two of three consecutive runs of this suite stalled here and lost every # case after them to the runner's own bound -- 13 and 12 cases reported of 44, # the third run completing 44/44. Nothing on the guest console explained it: no # CONTAINMENT, no VIOLATION, and peak guest RSS was unremarkable, so it was not # the memory storm that brintos/linux#21 and brintos/hardwarejs#20 cover and the # harness detects separately. # # WHAT CHANGED AFTERWARDS, AND WHY IT IS NOT A CONCLUSION: the password is now # PIPED rather than redirected, and the block does one useradd instead of two. # Three further runs showed no stall here and the whole suite fell from 69-91 s # to 48-54 s. TWO THINGS CHANGED AT ONCE, so that is not evidence for either -- # a pipe being a different kernel path from a regular-file read is a hypothesis, # and one fewer bcrypt is enough on its own to explain the elapsed. The # mechanism is unestablished and no brintos.io issue is filed on it; what is # recorded is the measurement. # # The placement is the part that does not depend on being right. This block sits # at the END, so if the stall recurs it costs these three cases and not the # forty protocol cases above them, which are what the ticket actually asks for. # A bound cannot be put around it from here: signal delivery is unreliable # (brintos/linux#21), so timeout(1) is decorative. Each case therefore reports # its own elapsed seconds, so the next occurrence says whether it was slow or # stopped instead of leaving that to be re-derived. # # pure-pw also refuses a virtual user at uid 0 ("You must give (non-root) uid # and gid", pure-pw.c:864, gated on ACCEPT_ROOT_VIRTUAL_USERS) -- measured green # on this image twice, and not asserted here: it needs a second interactive # useradd, which is the exposure this block was just reshaped to halve. VDB="$D/pureftpd.passwd" t0=$(date +%s 2>/dev/null || echo 0) printf '%s\n%s\n' "$PW" "$PW" | "$PW_" useradd bstvu1 -u 1000 -g 1000 \ -d "$D/root" -f "$VDB" -m > "$D/pw1.log" 2>&1 rc1=$? t1=$(date +%s 2>/dev/null || echo 0) el=$((t1 - t0)) if [ "$rc1" -eq 0 ] && [ -s "$VDB" ]; then bst_ok pure-ftpd/puredb-useradd # Outside the BST protocol on purpose -- the parser reads only `BST ok/not # ok/skip' lines, so this reaches the transcript without becoming a case. It # is what tells the next reader whether a future stall here was slow or # stopped, which is the one thing this run could not establish. printf 'pure-ftpd: pure-pw useradd took %ss (bcrypt cost 10, piped stdin)\n' "$el" else bst_notok pure-ftpd/puredb-useradd "pure-pw useradd rc=$rc1 after ${el}s, db $( [ -s "$VDB" ] && echo present || echo absent ): $(_bst_1line "$(cat "$D/pw1.log" 2>/dev/null)")" fi # Read the database back and require the fields that went in to come out. A # useradd that "succeeded" and stored nothing is what this separates out. "$PW_" show bstvu1 -f "$VDB" > "$D/pwshow.log" 2>&1 if grep -q "1000" "$D/pwshow.log" 2>/dev/null && grep -q "$D/root" "$D/pwshow.log" 2>/dev/null; then bst_ok pure-ftpd/puredb-show else bst_notok pure-ftpd/puredb-show "pure-pw show did not read back the uid and home that useradd stored: $(_bst_1line "$(cat "$D/pwshow.log" 2>/dev/null)")" fi # The compiled index is the form pure-ftpd would read at -l puredb. This build # has no puredb backend to read it -- the stock recipe passes no --with-puredb, # and this port does not turn the backend on for a test's convenience -- so the # assertion is that pure-pw can WRITE one, which is the offline half the ticket # names. "$PW_" mkdb "$VDB.pdb" -f "$VDB" > "$D/mkdb.log" 2>&1 if [ $? -eq 0 ] && [ -s "$VDB.pdb" ]; then bst_ok pure-ftpd/puredb-mkdb else bst_notok pure-ftpd/puredb-mkdb "pure-pw mkdb produced no index: $(_bst_1line "$(cat "$D/mkdb.log" 2>/dev/null)")" fi # --- what this image does not carry ------------------------------------------- bst_skip pure-ftpd/man-pages "pure-ftpd.8, pure-pw.8 and the rest are packaged into pure-ftpd-doc, and this image installs no -doc packages" rm -rf "$D"