Trumpai: PHP atmintį atlaisvina pirmiausia nuorodų skaičiavimu: kiekviena reikšmė žino, kiek kintamųjų į ją rodo, ir ištrinama, kai tik šis skaitiklis pasiekia nulį. Ciklinėms nuorodoms, kurios taip niekada nebūtų atlaisvintos, yra ciklų šiukšlių surinkėjas. Be to, kiekvienos užklausos pabaigoje PHP sutvarko viską, ką užklausa užėmė. Abi PHP2Shell spragos kimba už atminties būsenų: už atminties trūkumo (Out-of-Memory) būsenos apdorojant užklausą ir už vidinės būsenos, kuri po atlaisvinimo išlieka per ilgai.
Kaip PHP valdo atmintį: apžvalga
Tokiose kalbose kaip C programuotojas atmintį turi pats užsiprašyti ir vėl atlaisvinti. PHP tai perima. Kiekvienas kintamasis, masyvas ir objektas užima atmintį, kurią PHP valdo per savo atminties tvarkytuvą. Ribą nustato memory_limit, pasak vadovo, pagal numatymą 128M.
Sprendimų priėmėjams viena aplinkybė esminė: įprastoje eksploatacijoje su PHP-FPM ar mod_php veikia ilgaamžiai darbiniai procesai (worker), kurie paeiliui apdoroja daug užklausų. Ką užklausa palieka, turi būti patikimai sutvarkyta prieš kitą. Būtent čia slypi viena iš PHP2Shell spragų.
Nuorodų skaičiavimas: skaičiuojama, kas naudoja reikšmę
PHP kiekvieną reikšmę saugo konteineryje, vadinamame zval. Be tipo ir turinio, pasak vadovo, jis turi nuorodų skaitiklį (refcount) ir žymę, ar reikšmė yra PHP nuorodos su & dalis (is_ref).
<?php
$a = str_repeat('x', 1000); // viena reikšmė, refcount 1
$b = $a; // nekopijuojama, refcount 2
$b .= 'y'; // tik dabar kopijuojama (Copy-on-Write)
unset($a); // originalo refcount mažėja
// Kai skaitiklis pasiekia 0, PHP atmintį iškart atlaisvina.
Principas vadinasi Copy-on-Write: PHP reikšmę nukopijuoja tik tada, kai vienas iš kintamųjų ją pakeičia. Tai sutaupo daug atminties. Kintamojo skaitiklį galima padaryti matomą su plėtiniu Xdebug ir funkcija xdebug_debug_zval(). Vadovas pažymi, kad tikslūs skaičiai įvairiose PHP versijose gali skirtis.
Ciklai: kai nuorodų skaičiavimo nepakanka
Nuorodų skaičiavimas turi žinomą silpnybę. Jei struktūra rodo į save, jos skaitiklis niekada nenukrenta iki nulio.
<?php
$a = ['eins'];
$a[] = &$a; // masyve yra nuoroda į save patį
unset($a); // niekas nebegali jo pasiekti, bet refcount lieka 1
Pasak vadovo, tokios ciklinės nuorodos ypač pasitaiko objektuose, pavyzdžiui, tėvo ir vaiko struktūrose ar analizatoriuose. Pavieniai nutekėjimai nekenksmingi, nes PHP užklausos pabaigoje sutvarko. Tačiau ilgai veikiančiuose procesuose, tokiuose kaip eilių darbiniai procesai ar dideli testų paleidimai, milijonai jų gali sudaryti tikrą atminties problemą.
Ciklų šiukšlių surinkėjas
Nuo PHP 5.3.0 PHP tokiems ciklams rasti naudoja Bacon ir Rajan algoritmą. Pagrindinė idėja: ciklas gali atsirasti tik tada, kai skaitiklis nukrenta iki didesnės už nulį reikšmės. Tik šias reikšmes PHP įsimena kaip galimas šaknis.
- Galimos šaknys patenka į buferį. Vadovas kaip numatytąjį dydį nurodo 10 000 įrašų; dabar galiojančią slenkstinę reikšmę rodo
gc_status()laukethreshold. - Kai buferis pilnas, PHP pereina per visas pasiekiamas reikšmes ir bandomuoju būdu sumažina jų skaitiklius.
- Antrame pereinime galioja: reikšmės su nuliniu skaitikliu yra šiukšlės, visų kitų sumažinimas atšaukiamas.
- Šaknys pašalinamos iš buferio ir visos kaip šiukšlės atpažintos reikšmės atlaisvinamos.
Tai valdoma per zend.enable_gc (pagal numatymą įjungta) arba vykdymo metu su gc_enable() ir gc_disable(). gc_collect_cycles() priverstinai paleidžia ciklą ir grąžina surinktų ciklų skaičių.
Atminties matavimas ir stebėjimas
| Funkcija | Paskirtis |
|---|---|
memory_get_usage() |
šiuo metu skripto užimta atmintis |
memory_get_usage(true) |
atminties tvarkytuvo sistemoje rezervuota atmintis; pagal šią reikšmę tikrinamas memory_limit |
memory_get_peak_usage() |
didžiausia užimtos atminties reikšmė |
gc_status() |
paleidimai, surinkti ciklai, slenkstis, šaknys; nuo PHP 8.3.0 ir vykdymo laikai |
gc_collect_cycles() |
iškart paleisti ciklų surinkimą |
WeakMap |
priskyrimas objektams netrukdant jų atlaisvinimui, nuo PHP 8.0 |
Pasak vadovo, memory_get_usage() apima tik atmintį, kurią PHP užsiprašo per savo atminties tvarkytuvą. Ką plėtiniai paima tiesiai iš operacinės sistemos, šiuose skaičiuose nematyti. Viso proceso atminties ribą todėl nustato operacinė sistema ar konteineris.
Atminties valdymas ir PHP2Shell
Abiejų spragų aprašymai veda tiesiai į šią temą. Esant CVE-2026-109618, atminties trūkumo būsena gali pertraukti PHP, kai užklausos duomenys inicializuoti tik iš dalies; sutvarkymo metu neteisingi duomenys tuomet gali būti traktuojami kaip teisingi. Kas viduje vyksta atminties perpildymo metu, paaiškina puslapis memory_limit ir Out-of-Memory. Esant CVE-2026-109619, užklausos vidinė būsena atlaisvinus atmintį gali išlikti ir būti panaudota vėlesnės užklausos tame pačiame darbiniame procese (worker), kol dar nebuvo atstatyta. Eigą nuo pradžios iki sutvarkymo aprašo puslapis Užklausos gyvavimo ciklas.
Kaip pereinamąją priemonę php.net rekomenduoja nustatyti memory_limit=-1 taip, kad programos jo negalėtų perrašyti, pavyzdžiui, su php_admin_value FPM baseine, o atmintį vietoj to riboti per konteinerį ar cgroups, o ne per RLIMIT_AS ar RLIMIT_DATA. Kaip tai padaryti su systemd, rodo straipsnis Atminties ribų nustatymas su systemd ir cgroups.
; FPM baseinas: pereinamoji priemonė pagal php.net, po to visus darbinius procesus (worker) paleisti iš naujo
php_admin_value[memory_limit] = -1
Be ribos per konteinerį ar cgroup vienas procesas gali apkrauti visą serverį. O jokie nustatymai neatstoja atnaujinimo iki PHP 8.5.12, 8.4.27, 8.3.36 ar 8.2.35 kartu su visų darbinių procesų (worker) paleidimu iš naujo, nes tik paleidimas iš naujo pašalina senus procesus iš atminties.
Kaip PHP SOS padeda konkrečiai?
- Tikriname veikiančius atminties nustatymus kiekviename baseine ir ar programos gali juos perrašyti.
- Pereinamąsias priemones įgyvendiname su atminties riba konteinerio ar cgroup lygiu ir išbandome apkrovą.
- Įdiegiame atnaujinimą, paleidžiame iš naujo visus darbinius procesus (worker) ir įrodome, kad nebeveikia seni procesai, pagal mūsų PHP pataisų paslaugą.
- Vertiname atminties klaidas ir nutrūkusius darbinius procesus žurnaluose nuo 2026-10-20.
Dažni klausimai
Kas yra nuorodų skaičiavimas PHP?
PHP kiekvienai reikšmei skaičiuoja, kiek kintamųjų ar elementų į ją rodo. Kai skaitiklis nukrenta iki nulio, atmintis iškart atlaisvinama. Taip didžioji dalis atminties grąžinama be atskiro valymo ciklo.
Kam tuomet PHP dar reikia šiukšlių surinkėjo?
Ciklams. Jei masyvas rodo į save arba du objektai žino vienas kitą, skaitiklis niekada nenukrenta iki nulio, net jei niekas į juos nebesikreipia. Ciklų šiukšlių surinkėjas tokias struktūras suranda ir pašalina.
Ar PHP atlaisvina atmintį užklausos pabaigoje?
Taip, atmintį, kurią skriptas užėmė per užklausą, PHP sutvarko užklausos pabaigoje. Tačiau PHP2Shell atveju CVE-2026-109619 liečia būtent atvejį, kai vidinė būsena atlaisvinus atmintį išlieka ir gali būti panaudota vėlesnės užklausos tame pačiame darbiniame procese (worker).
Kodėl php.net rekomenduoja memory_limit=-1 kaip pereinamąją priemonę?
Spraga CVE-2026-109618 susijusi su atminties trūkumo (Out-of-Memory) būsena. php.net rekomenduoja memory_limit=-1 nustatyti taip, kad programos jo negalėtų perrašyti, o atmintį vietoj to riboti per konteinerį ar cgroups. Tai nėra visiška apsauga ir neatstoja atnaujinimo.