导读
之前讲过mysql忘记密码时的一些处理方法, 前面几种都是需要重启才生效的(包括修改ibd文件), 而不需要重启的方法(修改内存,或者gdb跳过认证)并没有给出完整实现. 而有的同学恰好就需要一个不用重启也能强制修改密码的方法…
所以今天来讲讲其中的 修改内存 实现强制修改密码操作.
原理分析
linux
原理很简单, 既然验证的密码是在内存中的, 那我们找到该密码直接修改为我们需要的密码即可.
以上, 就是这么滴简单
其实难点在于怎么访问mysqld进程的内存. /proc/PID/
下面有很多信息, 比如 io 表示这个进程读写了多少数据, 我们导入进度脚本就是查看的这个文件的rchar. 除此之外还有常用的stat,comm,fd等. 详情可以查看内核官网.
File | Content |
---|---|
clear_refs | Clears page referenced bits shown in smaps output |
cmdline | Command line arguments |
cpu | Current and last cpu in which it was executed (2.4)(smp) |
cwd | Link to the current working directory |
environ | Values of environment variables |
exe | Link to the executable of this process |
fd | Directory, which contains all file descriptors |
maps | Memory maps to executables and library files (2.4) |
mem | Memory held by this process |
root | Link to the root directory of this process |
stat | Process status |
statm | Process memory status information |
status | Process status in human readable form |
wchan | Present with CONFIG_KALLSYMS=y: it shows the kernel function symbol the task is blocked in - or “0” if not blocked. |
pagemap | Page table |
stack | Report full stack trace, enable via CONFIG_STACKTRACE |
smaps | An extension based on maps, showing the memory consumption of each mapping and flags associated with it |
smaps_rollup | Accumulated smaps stats for all mappings of the process. This can be derived from smaps, but is faster and more convenient |
numa_maps | An extension based on maps, showing the memory locality and binding policy as well as mem usage (in pages) of each mapping. |
其中有个maps
和mem
文件, 就是该进程所使用的内存映射和具体的内存fd. linux上一切皆文件, 硬件也是文件, 包括内存,磁盘等.
我们查看mysqld进程的maps文件,得到如下信息
我们以第一行为例:
00400000-00c6f000 r--p 00000000 fd:00 307653646 /soft/mysql_3386/mysqlbase/mysql/bin/mysqld
00400000-00c6f000 表示内存使用的范围为(16进制): 00400000 --> 00c6f000
r–p 表示权限. 具体的含义如下:
r = read w = write x = execute s = shared p = private (copy on write)
00000000 表示offset. 对于进程来讲, 使用的内存应该是连续的, 而实际分配的内存是断断续续的. 所以就使用offset来表示内存的相对位置, 这样每个进程看到的内存都是连续的了.
比如第一块内存是0x00c6f000 -> 0x00400000, 占用了8843264字节, 那么第二块内存的位置就该是8843264开始, 也就是offset=8843264 (0x0086f000) 同理: 0x02a10000 - 0x00c6f000 + 0x0086f000 = 0x02610000
注: 该offset是相对于fd来说的
fd:00 表示dev,就是上面说的fd
307653646 表示inode,就是文件系统的inode
/soft/mysql_3386/mysqlbase/mysql/bin/mysqld 就是对应的文件了.
mysql
所以我们只需要遍历maps就可以知道mysqld进程的内存分配情况了, 然后读取mem文件对应位置的数据查找需要的数据即可.
我们直接将这部分操作整理为函数. 这里仅为演示原理. 实际使用的时候见后面演示部分即可.
# 在内存中查找某个关键词
def find_data_in_mem(pid,key):
keysize = len(key)
with open(f'/proc/{pid}/maps','r') as f:
maps = f.readlines()
result = []
with open(f'/proc/{pid}/mem','rb') as f:
for line in maps:
addr = line.split()[0]
_flags = line.split()[1]
if _flags != 'rw-p':
continue
start_addr,stop_addr = addr.split('-')
start_addr = int(start_addr,16)
stop_addr = int(stop_addr ,16)
f.seek(start_addr,0)
data = f.read(stop_addr-start_addr)
offset = 0
while True:
offset = data.find(key,offset)
if offset != -1:
result.append([start_addr,stop_addr,offset])
offset += keysize
else:
break
return result
看着是不是很熟悉, 其实就是mysql.user表里面的数据, 但是mysql的认证并不是登录的时候,直接查询mysql.user里面的密码去匹配. 所以仅修改这里是没用的. 不然直接update mysql.user表修改密码不就得了, 干嘛还要flush privileges
呢? 这个fllush去了哪来呢?
回顾一下之前我们讲的mysql连接协议可知, 密码验证的是二进制的,而非16进制的, 所以内存中还存在着二进制的加密密码. flush刷新的值应该就是这里. 所以我们查找的应该是二进制的密码.
这个位置并没有用户之类的信息,所以我们得修改所有密码为该值的账号密码. 比如u1和u2的密码都是123456, 那么修改u2的密码的时候,u1的密码也会被修改. 当然我们可以查询源码, 找到具体的对应关系. 但这都是后话了.
修改内存的话, 就是打开mem时候, 使用r+b
即可, 即有写的权限, 然后f.write()即可, 就不单独演示了, 直接看后面的演示.
演示
理论是非常枯燥的, 所以我们来演示瞧瞧效果. 脚本见文末或者github上.
注意: --user指定user@host的时候, user和host都不需要加引号
查看用户的密码
查看的原理是遍历内存,找mysql.user表里面对应的账号记录. 如果没有查询过mysql.user表, 即mysql.user表不在内存里面的话, 是无法查询用户密码的. 当然可以查询ibd文件获取密码, frm的直接hexdump -C 就行, innodb的之前也提供过脚本的.
python3 online_modify_mysql_password.py --user u33@%
修改用户密码(方法1)
我们只是修改的flush处的密码, 所以如果再次flush的话, 我们修改的密码就失效了. 而且我们修改的是所有密码和u33一样的账号的, 所以我们还得登录数据库, 使用alter修改密码, 然后flush privileges刷新其它和u33密码一样的无辜者.
python3 online_modify_mysql_password.py --user u33@% --password newpassword_u33
修改用户密码(方法2)
还有种情况就是mysql.user不在内存中, 或者flush处的密码和mysql.user的不一致(比如使用update修改密码), 那么我们就需要人工提供mysql.user里面的密码(其实是flush处的密码).
python3 online_modify_mysql_password.py --user u33@% --password newpassword_u33 --old-password 0FE8A0B9017E2374037E9B151CBB384A05E6466B
存在多个mysqld进程的时候
如果服务器上存在多个mysqld进程, 则需要使用--pid PID
执行实际要修改的账号所在实例的进程号.
python3 online_modify_mysql_password.py --user u33@% --password newpassword_u33 --pid 18721
总结
虽然本文提供了不需要重启数据库就能强制修改密码的方法, 但还是建议重启数据库(还能释放下内存). 目前测试了5.7.38 8.0.28 8.0.41 均成功了的. 目前仅支持mysql_native_password插件的密码.
如果使用本脚本修改密码后,未登录数据库,做alter和flush的话, 再次使用脚本时也需要加上–old-password
参考:
https://www.kernel.org/doc/html/latest/filesystems/proc.html
源码
https://github.com/ddcw/ddcw/tree/master/python/online_modify_mysql_password
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# writen by ddcw @https://github.com/ddcw
# 在线修改mysql密码的工具. 仅支持 mysql_native_password 插件的
import os
import sys
import struct
import hashlib
import binascii
import argparse
def _argparse():
parser = argparse.ArgumentParser(add_help=False, description='在线修改mysqld进程的脚本')
parser.add_argument('--help', '-h', action='store_true', dest="HELP", default=False, help='show help')
parser.add_argument('--password', '-p', dest="PASSWORD", help='mysql new password')
parser.add_argument('--old-password', dest="OLD_PASSWORD", help='last modify password')
parser.add_argument('--pid', dest="PID", help='mysql pid', type=int)
parser.add_argument('--user', dest="USER", help='mysql account (user@host, root@localhost)')
if parser.parse_args().HELP:
parser.print_help()
print('Example:')
print(f'python3 f{sys.argv[0]} --user root@localhost')
print(f'python3 f{sys.argv[0]} --user root@localhost --password 123456')
print(f'python3 f{sys.argv[0]} --user root@localhost --password 123456 --pid `pidof mysqld`')
sys.exit(0)
if parser.parse_args().USER is None:
print('必须使用 --user 指定用户')
sys.exit(10)
return parser.parse_args()
def encode_password(NEW_PASSWORD):
return hashlib.sha1(hashlib.sha1(NEW_PASSWORD.encode()).digest()).digest()
# 在内存中查找某个关键词
def find_data_in_mem(pid,key):
keysize = len(key)
with open(f'/proc/{pid}/maps','r') as f:
maps = f.readlines()
result = []
with open(f'/proc/{pid}/mem','rb') as f:
for line in maps:
addr = line.split()[0]
_flags = line.split()[1]
if _flags != 'rw-p':
continue
start_addr,stop_addr = addr.split('-')
start_addr = int(start_addr,16)
stop_addr = int(stop_addr ,16)
f.seek(start_addr,0)
data = f.read(stop_addr-start_addr)
offset = 0
while True:
offset = data.find(key,offset)
if offset != -1:
result.append([start_addr,stop_addr,offset])
offset += keysize
else:
break
return result
# 设置新密码, 是直接将旧密码改为新密码, 如果多个用户的密码是一样的, 则都会修改, 不修改mysql.user等信息
def set_new_password(OLD_PASSWORD,NEW_PASSWORD,pid):
maps = find_data_in_mem(pid,OLD_PASSWORD)
if len(maps) == 0:
print('可能之前已经修改过了, 可以使用--old-password 指定上一次的密码')
sys.exit(1)
with open(f'/proc/{pid}/mem','r+b') as f:
for start,stop,offset in maps:
f.seek(start+offset-20,0)
data = f.read(20)
if data[-4:] != b'\x00\x00\x00\x00':
continue
# 5.7 255, 4, 0, 0, 0, 0
# 8.0 41, 0, 0, 0, 0, 0, 0, 0
print([ x for x in data ])
f.seek(start+offset,0)
f.write(NEW_PASSWORD)
print(f'set new password succuss! ({binascii.hexlify(NEW_PASSWORD).decode()})')
def get_pid(): # 获取mysqld进程的pid
pid = []
for entry in os.listdir('/proc'):
if not entry.isdigit():
continue
try:
comm = '/proc/'+str(entry)+'/comm'
with open(comm,'r') as f:
if f.read() == 'mysqld\n':
pid.append(entry)
except:
pass
return pid
if __name__ == "__main__":
parser = _argparse()
user,host = parser.USER.split('@')
flags = struct.pack('<B',len(host)) + host.encode() + struct.pack('<B',len(user)) + user.encode()
PIDS = get_pid()
pid = 0
if parser.PID is not None:
if str(parser.PID) in PIDS:
pid = parser.PID
else:
print(f'pid:{parser.PID} not exists {PIDS}')
sys.exit(0)
elif len(PIDS) == 1:
pid = PIDS[0]
elif len(PIDS) == 0:
print('当前不存在mysqld进程')
sys.exit(2)
else:
print(f'当前存在多个mysqld进程, 请指定一个')
sys.exit(3)
MODIFY_PASSWORD = False # 是否要修改密码, 如果没有指定密码, 则仅查看即可. 若指定了密码, 则为强制修改
NEW_PASSWORD = b''
if parser.PASSWORD is not None:
NEW_PASSWORD = encode_password(parser.PASSWORD)
MODIFY_PASSWORD = True
if parser.OLD_PASSWORD is not None:
set_new_password(bytes.fromhex(parser.OLD_PASSWORD),NEW_PASSWORD,pid,)
sys.exit(0)
# 查看当前的密码
maps = find_data_in_mem(pid,flags)
if len(maps) == 0:
print('没找到...')
sys.exit(1)
with open(f'/proc/{pid}/mem','rb') as f:
for start,stop,offset in maps:
f.seek(start,0)
data = f.read(stop-start)
MATCHED = True
offset += len(flags)
for i in range(29): # 29个权限
if data[offset:offset+1] != b'\x01':
MATCHED = False
break
else:
offset += 2
if not MATCHED:
continue
# 然后就是ssl,max_conn之类的信息
for i in range(8):
vsize = struct.unpack('<B',data[offset:offset+1])[0]
offset += 1 + vsize
# 然后就是mysql_native_password了
vsize = struct.unpack('<B',data[offset:offset+1])[0]
plugins = data[offset+1:offset+1+vsize].decode()
offset += 1 + vsize
if plugins != 'mysql_native_password':
continue
# 最后就是密码(password_expired之类的就不管了. 没必要)
vsize = struct.unpack('<B',data[offset:offset+1])[0] # 肯定得是41, 就懒得验证了
old_password = data[offset+1:offset+1+vsize].decode()
print(f'{parser.USER} password:{old_password} {start}-{stop}:{offset}') # mysql.user的信息
if MODIFY_PASSWORD: # 要修改的密码实际上是二进制的, 修改page的是没用的
set_new_password(bytes.fromhex(old_password[1:]),NEW_PASSWORD,pid,)
# mysql.user也修改下, 不然再次修改的时候,就找不到位置了. 算逑!
#with open(f'/proc/{pid}/mem','r+b') as fw:
# fw.seek(start+offset+1,0)
# fw.write(NEW_PASSWORD)
break