En administrationspanel på en server går nästan alltid in i samma vägg: webbprocessen behöver göra något som den inte har rättigheter till. Blockera en adress, öppna en port, läsa journalen. Svaret som ligger nära till hands är att bevilja en enda smal rad i sudoers, och inget mer:

www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client set * banip *

Raden läses som ”bara blockera”. I själva verket betyder den ”vad som helst, som root”. Nedan följer varför det blir så, hur du kontrollerar det på din egen server på fem minuter och vad sådana regler ersätts med.

Vad * verkligen gör

Den avgörande detaljen: sudo jämför inte argument för argument utan kommandoraden som helhet, och * i mönstret går obehindrat över blanksteg — det vill säga över argumentgränser. Literalen banip som står mitt i regeln begränsar därför ingenting: det räcker att ordet banip förekommer någonstans i kommandot, och före som efter det kan vad som helst skickas med.

Därefter letar man efter ett program som kan få ett kommando att köra. I vårt fall stod det i själva regeln. I fail2ban anges blockeringsåtgärden som text, och den texten kan omdefinieras under drift:

fail2ban-client get portscan actions
sudo fail2ban-client set portscan action nftables-multiport actionban "<kommando>" banip 192.0.2.10
sudo fail2ban-client set portscan banip 192.0.2.11

Det första anropet byter ut åtgärdstexten i en verklig åtgärd, det andra får jailet att slå till — och fail2ban, som kör som root, utför det som satts in. Vi gjorde detta den 4 juli på vår egen server: en fil i /root skapades som root, det vill säga webbprocessen fick fulla rättigheter på maskinen ur en regel som såg smal ut.

På vägen kom tre detaljer fram som vanligtvis saknas i andras genomgångar:

  • flaggan -c före set klipps bort av sudo: i mönstret sitter set fast vid binärens namn och ingenting kan skjutas in före;
  • addaction och action utan ordet banip går inte igenom heller — men allt nödvändigt ryms i ett enda kommando set … banip …, och därmed är mönstret uppfyllt;
  • åtgärdens namn måste vara ett riktigt, annars finns inget att byta ut: fail2ban-client get <jail> actions visar det.

Vilka fler som står på samma lista

fail2ban är varken skyldigt här eller något undantag. Det farliga är kombinationen NOPASSWD plus asterisk. Vad vi hittade intill på våra egna servrar:

  • journalctl * — journalen öppnas via en pager, och från en pager startar man ett skal. Regeln ser ut att handla om att läsa loggar; i praktiken är den ett rootskal. Botemedlet är inte ett smalare mönster utan gruppen systemd-journal: då fungerar journalctl helt utan sudo;
  • grep * /var/log/fail2ban.log — första argumentet till grep är mönstret, men asterisken tillåter att även en andra sökväg anges, och grep kör som root. Att läsa /etc/shadow med den regeln är ett enda kommando. Ersättningen är densamma: gruppen adm för att läsa loggar;
  • ufw --force * och nakna ufw allow/deny/delete * — webbprocessen kan stänga av brandväggen helt. Särskilt obehagligt: i båda fallen använde panelen inte de reglerna alls, döda rättigheter kvar från tidiga versioner av installationsprogrammet;
  • lynis-scan.sh * — ett omslag som skickar vidare "$@" till en rootprocess är likvärdigt med en regel utan några begränsningar.

Så ser du vad du har

Det som ska granskas är inte filen utan de faktiska rättigheterna för en bestämd användare — den som PHP-FPM-poolen verkligen kör som (inte alltid www-data; på en av våra maskiner kördes panelen som admin):

ps -o user= -C php-fpm | sort -u
sudo -l -U www-data
sudo -l -U admin

Därefter själva fillistan, och här finns två fällor som kostat oss tid:

sudo grep -rn 'NOPASSWD' /etc/sudoers /etc/sudoers.d/
ls -la /etc/sudoers.d/

Den första: regler lever på två ställen samtidigt. Hos oss fanns den farliga raden både i /etc/sudoers.d/monitor och i huvudfilen /etc/sudoers — i den senare i ungefär åtta kopior, samlade av olika versioner av installationsprogrammet. Att städa ett ställe och slå sig till ro är det vanliga sättet att lämna hålet öppet.

Den andra: sudo ignorerar filer vars namn innehåller en punkt. En fil www-data.bak3 i /etc/sudoers.d/ ser ut som en gällande regel och läses som en gällande regel, men har ingen verkan. Det slår åt båda hållen: ”regeln finns, rättigheterna inte”, och den falska tryggheten i en konfigurationskopia som ”ligger precis intill”.

Vad man ersätter med

Att göra mönstret smalare hjälper inte — en asterisk var som helst på raden sätter uppgiften tillbaka på start. En enda väg håller: ett rootomslag som tar fasta positionsargument, validerar dem själv och anropar programmet utan minsta möjlighet att lägga till något.

#!/bin/sh
# /usr/local/sbin/arciveo-f2b — ban|unban <jail> <ip>, och inget mer
set -eu
action="$1"; jail="$2"; ip="$3"
case "$action" in ban|unban) ;; *) echo "invalid action"; exit 1 ;; esac
case "$jail" in *[!a-zA-Z0-9_-]*|'') echo "invalid jail"; exit 1 ;; esac
printf '%s' "$ip" | grep -Eq '^[0-9a-fA-F:.]+$' || { echo "invalid ip"; exit 1; }

case "$action" in
  ban)   exec /usr/bin/fail2ban-client set "$jail" banip   "$ip" ;;
  unban) exec /usr/bin/fail2ban-client set "$jail" unbanip "$ip" ;;
esac

Omslaget ägs av root, rättigheter 755, och — detta är inte valfritt — det får inte vara skrivbart från webben, annars är hela konstruktionen meningslös. I sudoers står bara det kvar:

www-data ALL=(root) NOPASSWD: /usr/local/sbin/arciveo-f2b

Argument räknas inte upp i regeln: skriptet kontrollerar dem själv, och positionerna i det är fasta, så det finns ingenstans att smyga in en åtgärdstext. Regler som bara läser — statusar, ss, ipset list — hamnar på egna rader, också utan asterisk där det är möjligt.

Separat om att ersätta sudo med grupper. Angreppssättet är riktigt: systemd-journal för journalen och adm för loggarna blir billigare och säkrare än vilken sudo-regel som helst. Men att dra ut webbanvändaren ur grupper i blindo går inte. Vi tog bort www-data ur en grupp på en panelvärd och fick 403 på alla webbplatser på en gång: Apache kör där som www-data, webbplatsernas filer tillhör en annan användare, och läsåtkomsten gavs just av det gruppmedlemskapet. Återställningen krävde en fullständig omstart — inte en reload — av Apache och PHP-FPM, eftersom gamla arbetsprocesser behåller sin tidigare uppsättning grupper och ger ”det funkar, sedan 403”.

Kontroll efter ändringen

Innan en sudoers-fil byts ut måste dess syntax kontrolleras — annars kan man bli stående helt utan sudo:

sudo visudo -cf /etc/sudoers.d/monitor

Därefter ska det bekräftas att den gamla vägen är död, och det i namn av just den användaren:

sudo -k
sudo -u www-data sudo -n fail2ban-client set portscan banip 192.0.2.10

Svaret ska vara a password is required, medan ett anrop av omslaget med skräp i stället för en adress ska ge invalid ip. Här lurar en egen fälla: sudo -v sparar bekräftelsen i femton minuter, och efter det går varje kontroll med sudo -n igenom ”med lyckat resultat”. Precis så bekräftade vi en gång en regel som inte alls fanns på servern. Därför är sudo -k före kontrollen obligatoriskt.

Vad iakttagelsen är till för

Sudo-regler ändras sällan, men de ändras ljudlöst: installationsprogrammet skriver till, hostingpanelen fyller på med sitt, ett borttaget paket lämnar sina rader kvar. En engångsgenomgång stänger det som finns i dag och säger ingenting om vad nästa uppdatering för med sig. Den praktiska slutsatsen är enkel: listan över vilka som kan bli root hör hemma i synfältet, intill serverns övriga tillstånd, snarare än i minnet efter en incident. Hur det ser ut sammanställt visar demosidorna nedan.